@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +67 -0
- package/build.mjs +10 -0
- package/dist/contract/adapter.js +13 -0
- package/dist/contract/adapter.js.map +7 -0
- package/dist/contract/errors.js +28 -0
- package/dist/contract/errors.js.map +7 -0
- package/dist/contract/http.js +7 -0
- package/dist/contract/http.js.map +7 -0
- package/dist/contract/index.js +9 -0
- package/dist/contract/index.js.map +7 -0
- package/dist/contract/outcomes.js +58 -0
- package/dist/contract/outcomes.js.map +7 -0
- package/dist/contract/policy.js +91 -0
- package/dist/contract/policy.js.map +7 -0
- package/dist/contract/results.js +13 -0
- package/dist/contract/results.js.map +7 -0
- package/dist/contract/steps.js +1 -0
- package/dist/contract/steps.js.map +7 -0
- package/dist/contract/version.js +11 -0
- package/dist/contract/version.js.map +7 -0
- package/dist/engine/cache.js +29 -0
- package/dist/engine/cache.js.map +7 -0
- package/dist/engine/deadline.js +54 -0
- package/dist/engine/deadline.js.map +7 -0
- package/dist/engine/engine.js +481 -0
- package/dist/engine/engine.js.map +7 -0
- package/dist/engine/index.js +20 -0
- package/dist/engine/index.js.map +7 -0
- package/dist/engine/runAdapter.js +57 -0
- package/dist/engine/runAdapter.js.map +7 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/types.js.map +7 -0
- package/dist/extract/classify.js +51 -0
- package/dist/extract/classify.js.map +7 -0
- package/dist/extract/content.js +104 -0
- package/dist/extract/content.js.map +7 -0
- package/dist/extract/entities.js +95 -0
- package/dist/extract/entities.js.map +7 -0
- package/dist/extract/index.js +24 -0
- package/dist/extract/index.js.map +7 -0
- package/dist/extract/text.js +114 -0
- package/dist/extract/text.js.map +7 -0
- package/dist/extract/tokenizer.js +141 -0
- package/dist/extract/tokenizer.js.map +7 -0
- package/dist/fusion/canonicalize.js +88 -0
- package/dist/fusion/canonicalize.js.map +7 -0
- package/dist/fusion/fuse.js +88 -0
- package/dist/fusion/fuse.js.map +7 -0
- package/dist/fusion/index.js +11 -0
- package/dist/fusion/index.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/loader/describe.js +92 -0
- package/dist/loader/describe.js.map +7 -0
- package/dist/loader/index.js +31 -0
- package/dist/loader/index.js.map +7 -0
- package/dist/loader/manifest.js +32 -0
- package/dist/loader/manifest.js.map +7 -0
- package/dist/loader/registry.js +98 -0
- package/dist/loader/registry.js.map +7 -0
- package/dist/net/client.js +298 -0
- package/dist/net/client.js.map +7 -0
- package/dist/net/ssrf.js +165 -0
- package/dist/net/ssrf.js.map +7 -0
- package/dist/net/url.js +9 -0
- package/dist/net/url.js.map +7 -0
- package/dist/testing/contract.js +118 -0
- package/dist/testing/contract.js.map +7 -0
- package/dist/testing/fakeAdapter.js +44 -0
- package/dist/testing/fakeAdapter.js.map +7 -0
- package/dist/testing/index.js +15 -0
- package/dist/testing/index.js.map +7 -0
- package/jest.config.cjs +24 -0
- package/package.json +65 -0
- package/src/contract/adapter.ts +100 -0
- package/src/contract/errors.ts +48 -0
- package/src/contract/http.ts +46 -0
- package/src/contract/index.ts +8 -0
- package/src/contract/outcomes.ts +91 -0
- package/src/contract/policy.ts +157 -0
- package/src/contract/results.ts +79 -0
- package/src/contract/steps.ts +45 -0
- package/src/contract/version.ts +25 -0
- package/src/engine/__tests__/engine.test.ts +597 -0
- package/src/engine/cache.ts +46 -0
- package/src/engine/deadline.ts +65 -0
- package/src/engine/engine.ts +633 -0
- package/src/engine/index.ts +22 -0
- package/src/engine/runAdapter.ts +89 -0
- package/src/engine/types.ts +137 -0
- package/src/extract/__tests__/content.test.ts +81 -0
- package/src/extract/__tests__/text.test.ts +61 -0
- package/src/extract/__tests__/tokenizer.test.ts +85 -0
- package/src/extract/classify.ts +81 -0
- package/src/extract/content.ts +136 -0
- package/src/extract/entities.ts +98 -0
- package/src/extract/index.ts +11 -0
- package/src/extract/text.ts +124 -0
- package/src/extract/tokenizer.ts +197 -0
- package/src/fusion/__tests__/fusion.test.ts +198 -0
- package/src/fusion/canonicalize.ts +119 -0
- package/src/fusion/fuse.ts +145 -0
- package/src/fusion/index.ts +8 -0
- package/src/index.ts +15 -0
- package/src/loader/__tests__/describe.test.ts +119 -0
- package/src/loader/__tests__/registry.test.ts +134 -0
- package/src/loader/describe.ts +145 -0
- package/src/loader/index.ts +25 -0
- package/src/loader/manifest.ts +44 -0
- package/src/loader/registry.ts +156 -0
- package/src/net/__tests__/client.test.ts +273 -0
- package/src/net/__tests__/ssrf.test.ts +165 -0
- package/src/net/client.ts +434 -0
- package/src/net/ssrf.ts +235 -0
- package/src/net/url.ts +13 -0
- package/src/testing/contract.ts +172 -0
- package/src/testing/fakeAdapter.ts +69 -0
- package/src/testing/index.ts +9 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|