chore: isolate shared dsh plugins into independent monorepo
Some checks failed
build-and-publish / build-test (push) Failing after 1m6s
build-and-publish / publish (push) Has been skipped

This commit is contained in:
2026-08-26 22:44:31 +07:00
parent 0e0b68fed5
commit 81159a22e8
37 changed files with 3456 additions and 2 deletions

View File

@@ -0,0 +1,79 @@
/**
* `@deepseek-ai/dsh-web-search-searxng`: registers a SearXNG-backed
* `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a
* default-export service): a search provider does not own the `ctx.web` key —
* it registers INTO the seam's provider registry, exactly as
* `@deepseek-ai/dsh-web-search-exa` does. The key is owned by
* `@deepseek-ai/dsh-web`.
*
* @module @deepseek-ai/dsh-web-search-searxng
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-web'
import { SearXngSearchProvider } from './provider.ts'
export {
SEARXNG_DEFAULT_LANGUAGE,
SEARXNG_NO_TIME_RANGE,
SEARXNG_PROVIDER_ID,
SEARXNG_TIME_RANGES,
SearXngSearchProvider,
} from './provider.ts'
export type {
SearXngSearchProviderOptions,
SearXngSearchProviderSource,
SearXngTimeRange,
} from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-searxng'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config. `baseURL` is optional in the schema so an omitted value
* surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)
* rather than failing boot; `apply` fills constant defaults. */
export interface Config {
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
baseURL?: string
/** SearXNG `language` to request. Defaults to `auto`. */
language?: string
/** SearXNG `time_range` recency filter. Omitted = no filter. */
timeRange?: 'day' | 'week' | 'month' | 'year'
}
export const Config: z<Config> = z.object({
baseURL: z.string(),
language: z.string(),
timeRange: z.union(['day', 'week', 'month', 'year'] as const),
})
/** Settings namespace carrying the SearXNG instance and any per-search filters. */
export const SEARXNG_SETTINGS_NAMESPACE = settingsNamespace('web-search-searxng')
/** Register the SearXNG search provider with `ctx.web`, reading the live section
* per search so an in-session settings edit applies without re-registration. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
installSettingsSection(ctx, SEARXNG_SETTINGS_NAMESPACE, Config, config, {
setSource: (source) => {
current = source
},
// The registration carries no resolved value: the provider projects the
// section per search, so a committed change needs no re-registration.
onChange: () => {},
})
ctx.web.registerSearchProvider(new SearXngSearchProvider(() => ({
// SearXNG exposes no uniform public endpoint and the product requires
// evidence over an unsupported default, so `baseURL` has no constant
// fallback: an omitted value surfaces as an unavailable provider (the
// seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`), not a silent endpoint.
baseURL: current().baseURL ?? '',
...current().language !== undefined ? { language: current().language } : {},
...current().timeRange !== undefined ? { timeRange: current().timeRange } : {},
})))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-web-search-searxng`.
* @module @deepseek-ai/dsh-web-search-searxng/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-searxng'
/** Cordis companion plugin name. */
export const name = 'web-search-searxng-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,166 @@
/**
* `SearXngSearchProvider`: a `WebSearchProvider` backed by a SearXNG instance's
* JSON API (`GET /search?format=json`). It maps against the flat `results[]`,
* keeps entries even without a snippet (URL and title remain useful), and
* omits `content` because SearXNG returns no generated answer. The provider
* carries no credentials, so a request carries no `Authorization` header.
* @module @deepseek-ai/dsh-web-search-searxng/provider
*/
import { WebError } from '@deepseek-ai/dsh-web'
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { SearXngError, SearXngResult, SearXngSearchResponse } from './types.ts'
/** Stable id this provider registers under. */
export const SEARXNG_PROVIDER_ID = 'searxng'
/** Default language: let SearXNG decide per user preferences. */
export const SEARXNG_DEFAULT_LANGUAGE = 'auto'
/** `time_range` sentinel meaning "no recency filter". */
export const SEARXNG_NO_TIME_RANGE = 'none'
/** Valid `time_range` values SearXNG accepts. */
export const SEARXNG_TIME_RANGES = ['day', 'week', 'month', 'year'] as const
/** One of SearXNG's `time_range` values, passed through to the endpoint. */
export type SearXngTimeRange = (typeof SEARXNG_TIME_RANGES)[number]
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface SearXngSearchProviderOptions {
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
baseURL: string
/** SearXNG `language` to request. `auto` lets the instance decide. */
language?: string
/** SearXNG `time_range` recency filter. */
timeRange?: SearXngTimeRange
}
/** A snapshot or a per-search resolver (settings live-reload hands the latter). */
export type SearXngSearchProviderSource = SearXngSearchProviderOptions | (() => SearXngSearchProviderOptions)
/**
* Map one SearXNG result to a normalized source. The URL is always carried; a
* blank title or snippet is omitted rather than emitted as empty.
*
* @param result - one entry of SearXNG's `results[]`.
* @returns the normalized source.
*/
export function mapSearXngResult(result: SearXngResult): WebSearchSource {
return {
url: result.url,
...result.title != null && result.title.trim().length > 0 ? { title: result.title } : {},
...result.content != null && result.content.trim().length > 0 ? { snippet: result.content } : {},
...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
}
}
/**
* Map a SearXNG response envelope to a normalized search result.
*
* @param response - the parsed `format=json` response body.
* @returns the normalized result; `content` is omitted (no generated answer).
*/
export function mapSearXngResponse(response: SearXngSearchResponse): WebSearchResult {
// SearXNG cannot bound per-request result count (page size is instance
// configuration), so the request carries no count and the web service owns
// the final `maxResults` truncation; this provider reports `truncated: false`.
return { sources: (response.results ?? []).map(mapSearXngResult), truncated: false }
}
/** The SearXNG-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
export class SearXngSearchProvider implements WebSearchProvider {
readonly id = SEARXNG_PROVIDER_ID
constructor(source: SearXngSearchProviderSource) {
this.source = source
}
private readonly source: SearXngSearchProviderSource
/** Read the current options: a snapshot stays fixed, a resolver reads live settings. */
private resolveOptions(): SearXngSearchProviderOptions {
return typeof this.source === 'function' ? this.source() : this.source
}
available(): boolean {
const options = this.resolveOptions()
return isValidBaseUrl(options.baseURL)
&& (options.language === undefined || options.language.length > 0)
&& (options.timeRange === undefined || SEARXNG_TIME_RANGES.includes(options.timeRange))
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const options = this.resolveOptions()
const base = trimTrailingSlashes(options.baseURL)
const params = new URLSearchParams({ q: request.query, format: 'json' })
if (options.language !== undefined) params.set('language', options.language)
if (options.timeRange !== undefined) params.set('time_range', options.timeRange)
let response: Response
try {
response = await fetch(`${base}/search?${params}`, {
method: 'GET',
redirect: 'error',
headers: {
'accept': 'application/json',
'user-agent': USER_AGENT,
},
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`SearXNG search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
if (!response.ok) {
const status = response.status
let message = `SearXNG API error (HTTP ${status})`
try {
const parsed = await response.json() as SearXngError
const detail = parsed.error ?? parsed.message ?? parsed.content
if (detail !== undefined && detail.length > 0) message = detail
} catch (error: unknown) {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
}
throw new WebError(message, 'WEB_PROVIDER_ERROR')
}
try {
const payload = await response.json() as SearXngSearchResponse
return mapSearXngResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`SearXNG returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
}
/** True when `baseURL` parses as an absolute URL (a cheap local config check). */
function isValidBaseUrl(baseURL: string): boolean {
return URL.canParse(baseURL)
}
/** Strip a trailing slash so `${base}/search` never doubles a separator. */
function trimTrailingSlashes(baseURL: string): string {
return baseURL.replace(/\/+$/, '')
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}

View File

@@ -0,0 +1,30 @@
/**
* Wire types for the SearXNG JSON API (`GET {base}/search?q=...&format=json`).
* Types only — no runtime code. SearXNG returns a flat `results[]`; each entry
* carries a URL, an optional title, an optional `content` snippet, and an
* optional `publishedDate`.
*
* @module @deepseek-ai/dsh-web-search-searxng/types
*/
/** One entry of SearXNG's flat `results[]`. */
export interface SearXngResult {
url: string
title?: string | null
/** The page snippet SearXNG derives for the result. */
content?: string | null
publishedDate?: string | null
}
/** SearXNG's JSON search response envelope. */
export interface SearXngSearchResponse {
results?: SearXngResult[]
}
/** SearXNG's error response envelope (best-effort; fields vary by failure). */
export interface SearXngError {
error?: string
message?: string
/** Some SearXNG error responses carry the message under `content`. */
content?: string
}