@deadair/plugin-sdk 0.13.2 → 0.14.0
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/README.md +63 -0
- package/dist/boundary.json.safe.d.ts +11 -3
- package/dist/boundary.json.safe.d.ts.map +1 -1
- package/dist/capabilities/narration.d.ts +248 -0
- package/dist/capabilities/narration.d.ts.map +1 -0
- package/dist/capabilities/speech.d.ts +41 -0
- package/dist/capabilities/speech.d.ts.map +1 -1
- package/dist/define.plugin.d.ts +3 -0
- package/dist/define.plugin.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.config.fields.d.ts +10 -1
- package/dist/plugin.config.fields.d.ts.map +1 -1
- package/dist/plugin.manifest.d.ts +21 -1
- package/dist/plugin.manifest.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/boundary.json.safe.ts","../src/capabilities/analysis.ts","../src/capabilities/enrichment.ts","../src/plugin.error.ts","../src/capabilities/llm.ts","../src/capabilities/speech.ts","../src/plugin.http.ts","../src/html.text.ts","../src/article.parse.ts","../src/define.plugin.ts","../src/feed.parse.ts","../src/match.text.ts","../src/plugin.api.version.ts","../src/plugin.base.ts","../src/plugin.config.fields.ts","../src/plugin.config.read.ts","../src/plugin.host.response.ts","../src/plugin.manifest.ts","../src/plugin.permissions.ts"],"sourcesContent":["/**\n * Compile-time enforcement of the JSON-safe rule, over the payloads it applies\n * to.\n *\n * ## Which payloads, and why\n *\n * This rule used to cover everything a plugin touched, on the strength of a deferred move behind a\n * subprocess: JSON was going to be the wire format, so nothing could carry a `Date`, a `Uint8Array`\n * or a live object. That move is closed (`packages/plugin-sdk/CLAUDE.md` § \"Trust and egress\"), and\n * with it the reason to apply the rule to `host.fetch`'s arguments and return value, which nothing\n * serializes.\n *\n * What is registered here is what has an INDEPENDENT reason to survive\n * `JSON.parse(JSON.stringify(x))`: it is stored in Postgres, or sent to the\n * console over HTTP, or both. A `Date` in `TrackEnrichment` is a bug whether or\n * not plugins ever move anywhere, which is why that half of the rule outlived\n * the argument that introduced it.\n *\n * ## Why compile time\n *\n * The runtime conformance suite in `tests/` round-trips hand-written fixtures,\n * which catches a value that violates the rule despite a type that permits it\n * (a class instance where the type says `object`). What it cannot catch is a\n * newly added field: nothing populates it, so nothing round-trips it, and CI\n * stays green while a `Date` crosses the boundary.\n *\n * TypeScript types are erased, so no runtime walk can ever be exhaustive over\n * a type. That check has to happen at compile time, which is what this file\n * is. {@link AssertAllBoundaryPayloadsAreJsonSafe} fails `tsc` the moment any\n * field of any registered payload stops being JSON-safe, without anyone\n * writing a fixture for it.\n *\n * This lives in `src/` rather than `tests/` on purpose: per the repo\n * convention the build tsconfig includes only `./src/**\\/*`, so an assertion\n * in `tests/` would never run during `tsc --noEmit`. Everything here is types\n * plus three name arrays, so the runtime cost is the arrays alone.\n */\n\nimport type { AnalysisRef, TrackAnalysis, TrackCuePoints, TrackLoudness, TrackTaggedLoudness } from './capabilities/analysis.js';\nimport type { AudioJoin, AudioOverlay } from './capabilities/mixer.js';\nimport type { ChartDescriptor, ChartEntry, ChartQuery } from './capabilities/charts.js';\nimport type { NewsFeedDescriptor, NewsItem, NewsQuery } from './capabilities/news.js';\nimport type {\n PodcastAudio,\n PodcastDirectoryEntry,\n PodcastDirectoryQuery,\n PodcastEpisode,\n PodcastEpisodesQuery,\n PodcastShow,\n} from './capabilities/podcast.js';\nimport type { ScrobblePlay, ScrobbleRejection, ScrobbleResult } from './capabilities/scrobble.js';\nimport type { SearchQuery, SearchResult } from './capabilities/search.js';\nimport type { ArtistTrack, SimilarArtist } from './capabilities/similarity.js';\nimport type { WeatherConditions, WeatherDay, WeatherQuery, WeatherReading } from './capabilities/weather.js';\nimport type {\n AlbumEnrichment,\n AlbumRef,\n ArtistEnrichment,\n ArtistRef,\n ExternalId,\n ExternalLink,\n SourceDocument,\n TrackEnrichment,\n TrackRef,\n} from './capabilities/enrichment.js';\nimport type {\n GetPlaylistTracksOptions,\n ListPlaylistsOptions,\n PlaybackState,\n ProviderPlaylist,\n ProviderStream,\n ProviderTrack,\n SearchTracksOptions,\n} from './capabilities/music.provider.js';\nimport type { LlmMessage, LlmModelInfo, LlmRequest, LlmResult, LlmToolCall, LlmToolDeclaration, LlmUsage } from './capabilities/llm.js';\nimport type { SpeechRequest, SpeechVoice } from './capabilities/speech.js';\nimport type { ConfigField, ConfigFieldColumn, ConfigFieldOption } from './plugin.config.fields.js';\nimport type { PlaylistTracksRequest, TrackFetchRequest, TrackFetchSession } from './plugin.host.js';\nimport type { PluginConnectionResult } from './plugin.lifecycle.js';\nimport type { PluginManifest } from './plugin.manifest.js';\nimport type { NetworkPermissionFromConfig, NetworkPermissionHost, PluginGrantRequest, PluginPermissions } from './plugin.permissions.js';\n\n/**\n * `T` with every part that cannot survive `JSON.parse(JSON.stringify(x))`\n * replaced by `never`, so `T extends JsonSafe<T>` holds only for a genuinely\n * JSON-safe `T`.\n *\n * Deliberately narrower than structured-clone-safe. `Date`, `Map`, `Set` and\n * typed arrays all survive `structuredClone` and are still rejected here,\n * because what these payloads actually have to survive is Postgres and HTTP,\n * and both of those are JSON. A `Date` that round-trips through\n * `structuredClone` still comes back out of a `jsonb` column as a string.\n *\n * `any` and `unknown` pass through unchecked: there is nothing to inspect at\n * compile time, which is exactly the case the runtime fixture round-trip\n * covers.\n */\nexport type JsonSafe<T> = 0 extends 1 & T\n ? T // `any`\n : unknown extends T\n ? T // `unknown`\n : T extends string | number | boolean | null | undefined\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends Date | RegExp | Map<unknown, unknown> | Set<unknown> | WeakMap<object, unknown> | WeakSet<object> | Promise<unknown>\n ? never\n : T extends ArrayBuffer | SharedArrayBuffer | ArrayBufferView\n ? never\n : T extends bigint | symbol\n ? never\n : T extends readonly (infer TElement)[]\n ? readonly JsonSafe<TElement>[]\n : T extends object\n ? { [K in keyof T]: JsonSafe<T[K]> }\n : never;\n\n/**\n * `true` when `T` survives the boundary, `false` when any part of it does not.\n *\n * The tuple wrappers stop the conditional from distributing over a union, so a\n * field typed `string | Date` fails as a whole rather than partially matching.\n *\n * Written as a conditional rather than the more obvious\n * `T extends JsonSafe<T>` constraint, because TypeScript rejects that form as\n * a circular constraint (TS2313).\n */\ntype IsJsonSafe<T> = [T] extends [JsonSafe<T>] ? true : false;\n\n/**\n * Compiles only when every value in `T` is `true`. When one is not, the error\n * lands on that property, so the diagnostic names the offending boundary type.\n */\ntype AssertAllTrue<T extends Record<string, true>> = T;\n\n/**\n * Every payload that is stored or sent, asserted in one place.\n *\n * ADDING A BOUNDARY TYPE? Add it here and to {@link JSON_SAFE_PAYLOAD_TYPES}.\n * `tests/boundary.registry.test.ts` fails if an exported interface in a\n * boundary source file is in none of the three registries, so this cannot be\n * skipped by accident.\n *\n * `PluginManifest` is asserted without `configSchema`, which is a zod schema:\n * a class instance, deliberately never serialized, and stripped by the host\n * before a manifest is sent anywhere.\n */\nexport type AssertAllBoundaryPayloadsAreJsonSafe = AssertAllTrue<{\n PluginConnectionResult: IsJsonSafe<PluginConnectionResult>;\n PluginPermissions: IsJsonSafe<PluginPermissions>;\n NetworkPermissionHost: IsJsonSafe<NetworkPermissionHost>;\n NetworkPermissionFromConfig: IsJsonSafe<NetworkPermissionFromConfig>;\n PluginGrantRequest: IsJsonSafe<PluginGrantRequest>;\n ConfigField: IsJsonSafe<ConfigField>;\n ConfigFieldOption: IsJsonSafe<ConfigFieldOption>;\n ConfigFieldColumn: IsJsonSafe<ConfigFieldColumn>;\n PluginManifestWithoutConfigSchema: IsJsonSafe<Omit<PluginManifest, 'configSchema'>>;\n ProviderTrack: IsJsonSafe<ProviderTrack>;\n ProviderPlaylist: IsJsonSafe<ProviderPlaylist>;\n ProviderStream: IsJsonSafe<ProviderStream>;\n TrackFetchSession: IsJsonSafe<TrackFetchSession>;\n TrackFetchRequest: IsJsonSafe<TrackFetchRequest>;\n PlaylistTracksRequest: IsJsonSafe<PlaylistTracksRequest>;\n SearchTracksOptions: IsJsonSafe<SearchTracksOptions>;\n ListPlaylistsOptions: IsJsonSafe<ListPlaylistsOptions>;\n GetPlaylistTracksOptions: IsJsonSafe<GetPlaylistTracksOptions>;\n PlaybackState: IsJsonSafe<PlaybackState>;\n TrackRef: IsJsonSafe<TrackRef>;\n ArtistRef: IsJsonSafe<ArtistRef>;\n AlbumRef: IsJsonSafe<AlbumRef>;\n ExternalId: IsJsonSafe<ExternalId>;\n ExternalLink: IsJsonSafe<ExternalLink>;\n SourceDocument: IsJsonSafe<SourceDocument>;\n TrackEnrichment: IsJsonSafe<TrackEnrichment>;\n ArtistEnrichment: IsJsonSafe<ArtistEnrichment>;\n AlbumEnrichment: IsJsonSafe<AlbumEnrichment>;\n SpeechRequest: IsJsonSafe<SpeechRequest>;\n SpeechVoice: IsJsonSafe<SpeechVoice>;\n LlmMessage: IsJsonSafe<LlmMessage>;\n LlmToolDeclaration: IsJsonSafe<LlmToolDeclaration>;\n LlmToolCall: IsJsonSafe<LlmToolCall>;\n LlmUsage: IsJsonSafe<LlmUsage>;\n LlmRequest: IsJsonSafe<LlmRequest>;\n LlmResult: IsJsonSafe<LlmResult>;\n LlmModelInfo: IsJsonSafe<LlmModelInfo>;\n AnalysisRef: IsJsonSafe<AnalysisRef>;\n AudioJoin: IsJsonSafe<AudioJoin>;\n AudioOverlay: IsJsonSafe<AudioOverlay>;\n TrackCuePoints: IsJsonSafe<TrackCuePoints>;\n TrackLoudness: IsJsonSafe<TrackLoudness>;\n TrackTaggedLoudness: IsJsonSafe<TrackTaggedLoudness>;\n TrackAnalysis: IsJsonSafe<TrackAnalysis>;\n ChartDescriptor: IsJsonSafe<ChartDescriptor>;\n ChartQuery: IsJsonSafe<ChartQuery>;\n ChartEntry: IsJsonSafe<ChartEntry>;\n NewsFeedDescriptor: IsJsonSafe<NewsFeedDescriptor>;\n NewsQuery: IsJsonSafe<NewsQuery>;\n NewsItem: IsJsonSafe<NewsItem>;\n PodcastShow: IsJsonSafe<PodcastShow>;\n PodcastAudio: IsJsonSafe<PodcastAudio>;\n PodcastEpisode: IsJsonSafe<PodcastEpisode>;\n PodcastEpisodesQuery: IsJsonSafe<PodcastEpisodesQuery>;\n PodcastDirectoryQuery: IsJsonSafe<PodcastDirectoryQuery>;\n PodcastDirectoryEntry: IsJsonSafe<PodcastDirectoryEntry>;\n SimilarArtist: IsJsonSafe<SimilarArtist>;\n ArtistTrack: IsJsonSafe<ArtistTrack>;\n SearchQuery: IsJsonSafe<SearchQuery>;\n SearchResult: IsJsonSafe<SearchResult>;\n WeatherQuery: IsJsonSafe<WeatherQuery>;\n WeatherConditions: IsJsonSafe<WeatherConditions>;\n WeatherDay: IsJsonSafe<WeatherDay>;\n WeatherReading: IsJsonSafe<WeatherReading>;\n ScrobblePlay: IsJsonSafe<ScrobblePlay>;\n ScrobbleRejection: IsJsonSafe<ScrobbleRejection>;\n ScrobbleResult: IsJsonSafe<ScrobbleResult>;\n}>;\n\n/**\n * Names of the interfaces asserted above. Kept as a runtime array so the\n * registry-coverage test can compare it against what is actually exported\n * from the boundary source files.\n */\nexport const JSON_SAFE_PAYLOAD_TYPES = [\n 'PluginConnectionResult',\n 'PluginPermissions',\n 'NetworkPermissionHost',\n 'NetworkPermissionFromConfig',\n 'PluginGrantRequest',\n 'ConfigField',\n 'ConfigFieldOption',\n 'ConfigFieldColumn',\n 'PluginManifest',\n 'ProviderTrack',\n 'ProviderPlaylist',\n 'ProviderStream',\n 'TrackFetchSession',\n 'TrackFetchRequest',\n 'PlaylistTracksRequest',\n 'SearchTracksOptions',\n 'ListPlaylistsOptions',\n 'GetPlaylistTracksOptions',\n 'PlaybackState',\n 'TrackRef',\n 'ArtistRef',\n 'AlbumRef',\n 'ExternalId',\n 'ExternalLink',\n 'SourceDocument',\n 'TrackEnrichment',\n 'ArtistEnrichment',\n 'AlbumEnrichment',\n 'SpeechRequest',\n 'SpeechVoice',\n 'LlmMessage',\n 'LlmToolDeclaration',\n 'LlmToolCall',\n 'LlmUsage',\n 'LlmRequest',\n 'LlmResult',\n 'LlmModelInfo',\n 'AnalysisRef',\n 'AudioJoin',\n 'AudioOverlay',\n 'TrackCuePoints',\n 'TrackLoudness',\n 'TrackTaggedLoudness',\n 'TrackAnalysis',\n 'ChartDescriptor',\n 'ChartQuery',\n 'ChartEntry',\n 'NewsFeedDescriptor',\n 'NewsQuery',\n 'NewsItem',\n 'PodcastShow',\n 'PodcastAudio',\n 'PodcastEpisode',\n 'PodcastEpisodesQuery',\n 'PodcastDirectoryQuery',\n 'PodcastDirectoryEntry',\n 'SimilarArtist',\n 'ArtistTrack',\n 'SearchQuery',\n 'SearchResult',\n 'WeatherQuery',\n 'WeatherConditions',\n 'WeatherDay',\n 'WeatherReading',\n 'ScrobblePlay',\n 'ScrobbleRejection',\n 'ScrobbleResult',\n] as const;\n\n/**\n * Boundary interfaces that are deliberately NOT payloads: they describe\n * methods, so they carry functions by definition and can never be JSON-safe.\n *\n * The arguments and return values of those methods are payloads, and those\n * are what {@link JSON_SAFE_PAYLOAD_TYPES} covers. Listing the method-bearing\n * interfaces explicitly is what makes \"is this a payload or a contract?\" a\n * conscious decision for anyone adding one, rather than a silent omission.\n */\nexport const BOUNDARY_METHOD_TYPES = [\n 'PluginLogger',\n 'PluginStorage',\n 'PluginSecrets',\n 'PluginConfigAccess',\n 'PluginOAuth',\n 'PluginEvents',\n 'PluginTrackFetcher',\n 'PluginHost',\n 'PluginLifecycle',\n 'MusicProviderCatalog',\n 'MusicProviderStream',\n 'MusicProviderSteer',\n 'MusicProviderOAuth',\n 'MusicProvider',\n 'EnrichmentProvider',\n 'SpeechPluginInstance',\n 'LlmPluginInstance',\n 'AnalysisProvider',\n 'MixerProvider',\n 'ChartsProvider',\n 'NewsProvider',\n 'PodcastProvider',\n 'SimilarityProvider',\n 'SearchProvider',\n 'WeatherProvider',\n 'ScrobbleProvider',\n] as const;\n\n/**\n * Boundary interfaces that deliberately carry a LIVE object, and so are neither\n * payloads nor method contracts.\n *\n * The host and the plugin share a realm, permanently (see `packages/plugin-sdk/CLAUDE.md` § \"Trust\n * and egress\"), so handing over a real `AbortSignal` or a real stream is the correct design rather\n * than a shortcut around the rule. They are listed rather than simply left out, because the\n * registry-coverage test treats an unclassified boundary interface as an omission, and \"this one\n * holds a live object on purpose\" is a decision somebody should have to make in writing.\n */\nexport const BOUNDARY_LIVE_OBJECT_TYPES = [\n // `signal`: the invocation's own `AbortSignal`, watched by `host.fetch` and\n // passed on by the plugin to anything else that takes one.\n 'HostFetchInit',\n // `audio`: the engine's response body, usually forwarded straight through,\n // so the bytes are never held whole on either side of the call.\n 'SpeechHandle',\n // `audio`: the same thing one capability over, on the `mixer` side. A joined production is the\n // longest single piece of audio the station ever makes, so holding it whole\n // is the one thing this must not do.\n 'JoinedAudio',\n // `text`: the words as the model produces them, and `result`: a promise that\n // settles when it stops. The stream is what lets the host hold its single\n // model slot until the generation really ends rather than until the call\n // returns. `LlmResult` is the payload half, and it is JSON-safe.\n 'LlmHandle',\n] as const;\n","/**\n * The `analysis` capability. An analysis plugin takes one track's audio and\n * answers with measurements of it: where the record actually starts, where it is\n * underway, where the ending begins, where it stops.\n *\n * ## Why this is not enrichment\n *\n * Enrichment asks an upstream what it KNOWS about a recording, fans the question\n * out to everything that answers, and merges the results in priority order.\n * Nothing here works that way. There is no upstream that knows where a record's\n * outro begins — it is computed from the samples — so there is exactly one\n * answer, one source, and nothing to merge. `TrackRef` also carries no way to\n * reach the audio, deliberately, because no enrichment source wants it.\n *\n * ## The plugin is an adapter, not an analyzer\n *\n * Measuring any of this needs decoded PCM, and decoding is the one thing this\n * tree does not do in Node. So the expected implementation is a thin adapter over\n * a separate program: take {@link AnalysisRef.audioUrl}, hand it to whatever\n * actually decodes, return what comes back. That is the same relationship the\n * bundled speech plugin has with its engine.\n *\n * The reason is decoding and only decoding. An earlier version of this note also claimed the\n * boundary bought a licence position, which `analysis/README.md` § \"The rule, stated once\" retired:\n * nothing copyleft or non-commercial enters the analysis path in the first place, so there is no\n * position for the boundary to buy.\n *\n * Nothing here requires that shape. A plugin that can measure audio some other\n * way is a valid implementation; this note exists so the first one is not\n * mistaken for the contract.\n *\n * ## Why joining audio is not here\n *\n * It was, for one commit, as an optional `joinAudio`. The argument was the\n * paragraph above read backwards — joining needs decoded PCM too, so it wants the\n * same adapter over the same program, and the bundled plugin does serve both off\n * one sidecar with one address. That much is still true and is why there is still\n * one plugin.\n *\n * What it got wrong is that a capability is the unit of SELECTION, not of\n * implementation: the host picks one plugin per capability, so a joiner carried\n * here is whichever plugin the operator chose to MEASURE with. `capabilities/mixer.ts`\n * has the rest of it.\n *\n * ## What the host guarantees, and what it cannot\n *\n * The host resolves {@link AnalysisRef.audioUrl} because a plugin cannot: the\n * copy that can actually be served is a binding the catalog owns, and asking one\n * plugin to go through another is not a thing the host permits. In exchange the\n * host cannot see the bytes, so it cannot tell a complete download from a\n * truncated one — which is why {@link TrackAnalysis.complete} is reported rather\n * than inferred. See the note on that field: it is the difference between a cache\n * that can be trusted and one that cannot.\n *\n * Every shape here is JSON-safe. Offsets are integer milliseconds.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\n\n/**\n * The current shape of {@link TrackAnalysis.data}.\n *\n * Bumped whenever a detector's output changes shape, which is what lets a stored\n * row be recognised as STALE rather than read as missing or, worse, as current.\n * Reanalysis then falls out of an ordinary \"needs work\" query instead of needing\n * a migration.\n *\n * **An OPTIONAL field being added is not that**, and does not bump this. Every\n * consumer of `data` already has a defined answer for a field that is absent —\n * it has to, since an analyzer may not compute one — so a row written before the\n * field existed is still a correct row of this version rather than a stale one.\n * Bumping for it would mark the whole catalog for re-measurement to gain\n * something the station degrades over anyway. `tagGainDb` and friends arrived\n * exactly this way.\n *\n * The host compares this against what it stored, so a plugin must report the\n * version it actually produced rather than this constant, in case the two have\n * drifted apart across an upgrade.\n */\nexport const ANALYSIS_SCHEMA_VERSION = 1;\n\n/**\n * How the host asks about one track.\n *\n * Deliberately not a {@link TrackRef}: nothing here is matched by name, so\n * artist and title would be decoration. What a measurement needs is bytes and a\n * way to tell a truncation from a short record.\n */\nexport interface AnalysisRef {\n /**\n * The canonical catalog id for this track.\n *\n * Passed for the plugin's own logging and for its own caching, if it keeps\n * any. It is not a key into anything the plugin can read, and the host does\n * not expect it back.\n */\n trackId: string;\n\n /**\n * A complete, fetchable URL for the audio, resolved by the host.\n *\n * Carries its own authentication, exactly as the playout URLs do: whatever\n * fetches this sends no headers on the host's behalf. It may be short-lived,\n * so fetch it during the call rather than storing it.\n *\n * **It has to be reachable from wherever the decoding happens**, which is not\n * necessarily where this plugin runs. An address that resolves inside the\n * API process and not inside a sidecar container is the first thing to check\n * when every analysis fails at once.\n */\n audioUrl: string;\n\n /**\n * How long the catalog believes the track is.\n *\n * A cross-check rather than an input to any measurement: audio that decodes\n * to appreciably less than this was truncated, and measuring it would report\n * a confident cold ending for a record that fades. Absent when the catalog\n * never learned a duration, which is ordinary and is not a reason to refuse.\n */\n durationMs?: number;\n}\n\n/**\n * The four points, all absolute offsets into the file.\n *\n * Including `cueOut`. Storing it relative to `cueIn` is the obvious-looking\n * choice and it is wrong: everything downstream seeks in file time, so a relative\n * figure has to be re-based at every read, and eventually one read does not.\n *\n * Two lengths fall out — `intro = introEnd - cueIn` and\n * `outro = cueOut - outroStart` — and they are what a transition is actually\n * sized from. Neither is stored, because a stored derivation is a second thing\n * that can disagree with the first.\n */\nexport interface TrackCuePoints {\n /** Where audio actually starts, past the leading silence. */\n cueIn: number;\n\n /**\n * Where the record is fully underway: the beat established, or the vocal in.\n *\n * The talk-up limit, and one of the two points that is real work. A detector\n * weighted to low frequencies places this early on a record that opens with\n * a pad, which reads as \"the intro is over\" while it plainly is not.\n */\n introEnd: number;\n\n /**\n * Where the ending begins, so the earliest a blend may start.\n *\n * The other real one, and the one with a specific failure to design against:\n * a low-frequency-weighted detector places it too early on a quiet ending,\n * which is exactly the case an ending-aware transition exists to serve.\n */\n outroStart: number;\n\n /** Where audio actually stops, before the trailing silence. */\n cueOut: number;\n}\n\n/**\n * How loud the record is, and how close it already runs to its ceiling.\n *\n * Every field is OPTIONAL, and absent is a real answer rather than a gap: a\n * silent or near-silent track has no loudness, and the alternative to omitting\n * it is a floor value like -80 that a caller would then \"correct\" by fifty\n * decibels. Absent means no opinion, which is what every consumer of these\n * measurements already has to handle.\n *\n * They are also optional in the weaker sense that an analyzer may not compute\n * them at all. A plugin that only finds cue points is a valid analyzer; a\n * station reading these has to degrade to its live normalizer, which is what it\n * does today anyway.\n */\nexport interface TrackLoudness {\n /**\n * Gated programme loudness in LUFS, to ITU-R BS.1770.\n *\n * Gated, which is the whole difference between this and an average level: a\n * record with a long quiet outro is as loud as its body, not as loud as its\n * mean. The station's per-track gain is the distance from this to whatever\n * target it holds.\n */\n integratedLufs?: number;\n\n /**\n * The highest inter-sample peak in dBTP, which is what caps a boost.\n *\n * Distinct from {@link samplePeakDb} and the distinction is the point: the\n * reconstructed waveform between two samples can exceed both of them,\n * routinely by around a decibel. A gain computed against sample peak alone\n * is how a quiet master gets lifted into clipping, so this is the number a\n * boost has to respect.\n *\n * Legitimately positive. A value above 0 dBTP means the master already\n * overshoots on playback, which is worth knowing before adding anything.\n */\n truePeakDb?: number;\n\n /**\n * The highest actual sample, in dBFS.\n *\n * Carried alongside the true peak rather than instead of it, because the gap\n * between them is diagnostic: a wide one means the master is already fighting\n * its own ceiling.\n */\n samplePeakDb?: number;\n}\n\n/**\n * What the FILE says about its own loudness, as opposed to what was measured.\n *\n * A different kind of claim from {@link TrackLoudness}, which is why it is a\n * different interface: those fields are this analyzer's opinion, and these are\n * whoever mastered or scanned the record telling the station what they decided.\n * A consumer that prefers one over the other has to be able to tell them apart,\n * which it cannot do if they arrive in the same field.\n *\n * All optional, and most files carry none of them.\n */\nexport interface TrackTaggedLoudness {\n /**\n * The gain the file's own tags ask for, in dB.\n *\n * **Meaningless without {@link tagReferenceLufs}**, and that is the whole\n * reason both are reported. A gain is a correction relative to some level,\n * and the two conventions in the wild are five decibels apart, so a station\n * that stored only this would be storing the answer to a question it can no\n * longer ask.\n */\n tagGainDb?: number;\n\n /**\n * The loudness {@link tagGainDb} is relative to, in LUFS.\n *\n * -23 for an R128 tag, where the specification fixes it. -18 for a\n * ReplayGain tag, where it is an ASSUMPTION: ReplayGain 2.0 targets -18 and\n * every current scanner writes it, but the older convention used the same\n * tag names with no version field, so a file carrying it is\n * indistinguishable from the outside.\n *\n * Subtracting this pair gives the loudness the tagger believed the record\n * has, which is the figure a station's own target applies to.\n */\n tagReferenceLufs?: number;\n\n /**\n * The peak the file's tags declare, in dBFS.\n *\n * A SAMPLE peak, always, because that is what ReplayGain defines. It is not\n * a substitute for {@link TrackLoudness.truePeakDb} and nothing should cap a\n * boost with it; it is stored because the gap between the two says how hard\n * the master is already running.\n */\n tagPeakDb?: number;\n}\n\n/**\n * What one analysis produced.\n *\n * `data` is deliberately the only place measurements live, and the host stores\n * it whole without reading the individual fields. That is what lets a later\n * schema version add a beat grid or a vocal curve without touching the plugin\n * contract, the host, or the database.\n */\nexport interface TrackAnalysis {\n /**\n * The shape of {@link data}, as this plugin actually produced it.\n *\n * Report what was measured rather than {@link ANALYSIS_SCHEMA_VERSION}: an\n * adapter over a separate analyzer is reporting that analyzer's version, and\n * the two drift the moment one of them is upgraded and the other is not. A\n * version the host does not know is a configuration problem it can name,\n * where a wrong one is a row nothing can read and nothing can explain.\n */\n schemaVersion: number;\n\n /**\n * Whether the whole file was measured.\n *\n * **Load-bearing, and the host cannot check it.** A byte-capped, idle-timed\n * out or otherwise truncated download produces perfectly confident\n * measurements of a file that was never the track, and the specific lie it\n * tells is that a record which fades ended cold. Since the host never sees\n * the bytes, this is the only signal that separates a measurement worth\n * keeping from one worth discarding, and a plugin that always answers `true`\n * has quietly disabled the check.\n *\n * A genuinely short track is `true`. A download that stopped early is\n * `false`, and the two are told apart with {@link AnalysisRef.durationMs}\n * where there is one.\n */\n complete: boolean;\n\n /**\n * The measurements, in the shape {@link schemaVersion} names.\n *\n * Typed as the v1 fields plus room to grow rather than as a closed\n * interface, because the host passes it through unread. A v2 payload with a\n * tempo and a downbeat grid is the same call, the same plugin method, and a\n * different number above.\n *\n * The cue points are required and the loudness is not, which reflects what\n * each costs to produce: the points come from the decode that has already\n * happened, where loudness needs a filter chain an analyzer may reasonably\n * not implement.\n */\n data: TrackCuePoints & TrackLoudness & TrackTaggedLoudness & Record<string, unknown>;\n\n /**\n * How long the audio turned out to be once decoded.\n *\n * The honest figure, as opposed to the catalog's claim in\n * {@link AnalysisRef.durationMs}. Worth reporting even when the two agree:\n * where they do not, this is the one that the offsets above are on the same\n * timeline as.\n */\n durationMs?: number;\n\n /**\n * What did the measuring, as a name and version.\n *\n * Stored as provenance, so a row can be attributed after the fact when a\n * detector turns out to have been wrong about a class of records. Free-text\n * and never parsed.\n */\n analyzer?: string;\n}\n\n/** A plugin that can measure a track's audio. */\nexport interface AnalysisProvider extends PluginLifecycle {\n /**\n * Measure one track.\n *\n * One track per call, with no batch sibling: the unit of work is one file's\n * bytes, and there is no upstream round trip to amortise across several. How\n * many run at once is the host's decision, taken against hardware it can see\n * and this plugin cannot.\n *\n * Expect to be given minutes rather than seconds — decoding a full record is\n * not a request, it is a job — but honour `host.signal` all the same, because\n * a station shutting down should not wait on a measurement nobody will read.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no analyzer address), `upstream` when the audio could not be fetched or\n * could not be decoded, `timeout` when the analyzer did not answer.\n */\n analyzeTrack(ref: AnalysisRef): Promise<TrackAnalysis>;\n}\n","/**\n * The `enrichment` kind. An enrichment plugin takes a reference to something in\n * the catalog and returns extra facts about it: the stuff the DJ talks over,\n * the stuff the UI shows. Several enrichment plugins run for the same thing and\n * the host merges their results in `priority` order.\n *\n * Three references, three answers, because the facts have three different\n * lifetimes and three different costs. A recording is asked about once per\n * track, an artist once per artist, and a record once per album — so a rotation\n * that revisits the same few hundred artists pays for them once rather than\n * once per song. Only `enrichTrack` is required.\n *\n * Every shape here is JSON-safe.\n */\n\n/**\n * How an enrichment plugin is asked to identify a track. `isrc` is the\n * preferred key when present; otherwise match on the normalised\n * `artist|title` pair.\n */\nexport interface TrackRef {\n /** Recording ISRC. When set, prefer it over the string fields. */\n isrc?: string;\n /**\n * MusicBrainz RECORDING id, when the catalog has resolved one.\n *\n * The mirror of {@link ArtistRef.mbid} and {@link AlbumRef.mbid}, and it\n * arrives the same way: something already resolved it and the host promoted\n * it onto `tracks.mbid`, so a later pass gets it for free. Prefer it over\n * every other field here when your source can take one, because it is the\n * only identifier in this shape that cannot match the wrong record.\n */\n mbid?: string;\n /** Primary artist name, as the source provider spells it. */\n artist: string;\n title: string;\n album?: string;\n durationMs?: number;\n /** Release year, when known. Cheap disambiguator for covers and re-issues. */\n year?: number;\n}\n\n/** Match keys an enrichment plugin can look a track up by. */\nexport const ENRICHMENT_MATCH_KEY_ISRC = 'isrc';\nexport const ENRICHMENT_MATCH_KEY_ARTIST_TITLE = 'artist-title';\n\nexport const KNOWN_ENRICHMENT_MATCH_KEYS = [ENRICHMENT_MATCH_KEY_ISRC, ENRICHMENT_MATCH_KEY_ARTIST_TITLE] as const;\n\nexport type EnrichmentMatchKey = (typeof KNOWN_ENRICHMENT_MATCH_KEYS)[number];\n\n/** A named external identifier, e.g. `{ source: 'musicbrainz', id: '...' }`. */\nexport interface ExternalId {\n source: string;\n id: string;\n}\n\n/** A link out to the source, shown in the UI and usable as a citation. */\nexport interface ExternalLink {\n label: string;\n url: string;\n}\n\n/**\n * A piece of PROSE about this thing, handed over for the host to read rather\n * than for anyone to say.\n *\n * The difference between this and {@link TrackEnrichment.facts} is who wrote\n * the sentence. A `fact` is a line your plugin composed and is willing to have\n * spoken on air unchanged. A document is somebody else's article, verbatim: the\n * host extracts claims from it, checks each claim against the text, and keeps\n * the provenance. Nothing reads a document aloud, and nothing shows one on a\n * page.\n *\n * Hand over the prose rather than your own summary of it. The host stores it,\n * so a better extraction later costs no request to your upstream, and\n * `sourceQuote` on the claims it produces has to be a span that really occurs\n * in `text` or the claim is dropped.\n *\n * Plain text, not HTML and not wiki markup. Strip the furniture (navigation,\n * licence footers, reference markers) the way a reader would ignore it.\n */\nexport interface SourceDocument {\n /** Where this text can be read by a person. Becomes the claim's citation, so it must be public. */\n url: string;\n /** The document's own title, e.g. the article name. */\n title: string;\n /** The prose, as plain text. */\n text: string;\n /** ISO-8601 instant. Never a `Date`. */\n retrievedAt: string;\n}\n\n/**\n * How an enrichment plugin is asked to identify an artist.\n *\n * Two identifiers, and the difference between them matters. `mbid` is the one\n * id that crosses providers, so a plugin that is not MusicBrainz can still use\n * it (Last.fm takes one directly) or ignore it and match on `name`.\n * `providerRef` is this plugin's *own* last id for this artist, handed back so\n * a second pass is a lookup rather than another search. Neither is promised:\n * the first time anything asks about an artist, all there is is a name.\n */\nexport interface ArtistRef {\n /** Canonical artist name, as the catalog holds it. */\n name: string;\n /** MusicBrainz artist id, when the catalog has resolved one. */\n mbid?: string;\n /** The id this plugin itself used last time it answered about this artist. */\n providerRef?: string;\n}\n\n/** The album equivalent of {@link ArtistRef}. `mbid` is a MusicBrainz release-group id. */\nexport interface AlbumRef {\n name: string;\n /** The album's artist, because a title alone does not identify a record. */\n artist: string;\n /** MusicBrainz release-group id, when the catalog has resolved one. */\n mbid?: string;\n providerRef?: string;\n}\n\n/**\n * The union of facts enrichment can contribute. Every field is optional: a\n * plugin returns a `Partial<TrackEnrichment>` containing only what it knows.\n */\nexport interface TrackEnrichment {\n /**\n * Your own id for this thing, so the next pass can be a lookup rather than\n * another search. The mirror of the `providerRef` on the ref you were\n * handed: the host stores it against your plugin and gives it back to you,\n * and only to you.\n *\n * Say it outright whenever you have one. It is not identity and it is not a\n * claim about anyone else's ids — put those in {@link externalIds}, in\n * whatever order suits you.\n */\n providerRef: string;\n\n /** Canonical artist name, if the source has a better spelling than the provider. */\n artist: string;\n title: string;\n album: string;\n\n /** Original release year of the recording. */\n year: number;\n /** ISO-8601 date string (`YYYY-MM-DD` or `YYYY`). Never a `Date`. */\n releaseDate: string;\n\n genres: string[];\n moods: string[];\n\n /** Free-text background: label, session players, chart history. DJ patter fodder. */\n biography: string;\n /** Short trivia lines, each independently speakable. */\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n\n /** Beats per minute. */\n bpm: number;\n /** Musical key, e.g. `A minor`. */\n musicalKey: string;\n\n label: string;\n isrc: string;\n\n artworkUrl: string;\n\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * What enrichment can say about an artist rather than a recording.\n *\n * Asked once per artist instead of once per track, which is the whole point of\n * it being a separate method: a rotation revisits the same few hundred artists\n * constantly, and an artist's background changes on a scale of years.\n */\nexport interface ArtistEnrichment {\n /** Your own id for this artist. See {@link TrackEnrichment.providerRef}. */\n providerRef: string;\n\n /** Canonical artist name, if the source has a better spelling than the provider. */\n name: string;\n /** Free-text background. DJ patter fodder. */\n biography: string;\n /** Short trivia lines, each independently speakable. */\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n genres: string[];\n imageUrl: string;\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * What enrichment can say about a record rather than a recording.\n *\n * The label, the pressing and the cover belong to the release, not to any one\n * track on it, so they are asked for once per album. A track's own\n * `TrackEnrichment.label` is still meaningful for a single that was never on a\n * record, or for a compilation whose tracks were licensed separately.\n */\nexport interface AlbumEnrichment {\n /** Your own id for this record. See {@link TrackEnrichment.providerRef}. */\n providerRef: string;\n\n name: string;\n /** Canonical artist name for the album, which is not always the track's. */\n artist: string;\n /** Release year of this record, as opposed to of any recording on it. */\n year: number;\n /** ISO-8601 date string (`YYYY-MM-DD` or `YYYY`). Never a `Date`. */\n releaseDate: string;\n label: string;\n genres: string[];\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n artworkUrl: string;\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * Implemented by an `enrichment` plugin.\n */\nexport interface EnrichmentProvider {\n /**\n * Lower runs first and wins conflicts on merge. Use 100 for a canonical\n * source (MusicBrainz), 500 for a supplementary one, 900 for a guess.\n */\n priority: number;\n\n /** Which of the {@link TrackRef} fields this plugin can actually match on. */\n matchKeys: EnrichmentMatchKey[];\n\n /**\n * How many refs this plugin will take in one {@link enrichTracks} call.\n *\n * The host chunks to it, so a batch call is a small fixed number of upstream\n * round trips and can be given a deadline that means something. Ignored by a\n * plugin that does not implement `enrichTracks`.\n */\n maxBatchSize?: number;\n\n /** Return only the fields you actually resolved. Return `{}` on no match. */\n enrichTrack(ref: TrackRef): Promise<Partial<TrackEnrichment>>;\n\n /**\n * The same question as {@link enrichTrack}, asked about several tracks at\n * once. Optional, the way {@link enrichArtist} is: the host loops\n * `enrichTrack` for any plugin that does not write it, so implementing it is\n * an optimisation and never a requirement.\n *\n * Implement it only where the upstream can genuinely answer about many at\n * once. A source paced at a request per second is the case this exists for:\n * one query that identifies twenty-five tracks costs a second, where\n * twenty-five `enrichTrack` calls cost twenty-five.\n *\n * Index-aligned with `refs`: entry `i` is the answer about `refs[i]`, the\n * returned array is the same length, and `{}` at a position is a miss\n * exactly as it is for `enrichTrack`. A source that could only account for\n * some of the batch returns `{}` for the rest rather than a short array.\n */\n enrichTracks?(refs: TrackRef[]): Promise<Partial<TrackEnrichment>[]>;\n\n /**\n * Optional, the way a music provider's `resolveStreamUrl` is: a source\n * that only knows recordings is still a valid enrichment plugin, and the\n * host asks nothing of a plugin that did not write the method.\n *\n * Implement it for anything that belongs to the artist rather than to one\n * of their recordings. The host asks once per artist, so the same answer\n * covers every track they appear on.\n */\n enrichArtist?(ref: ArtistRef): Promise<Partial<ArtistEnrichment>>;\n\n /** The album equivalent of {@link enrichArtist}, asked once per record. */\n enrichAlbum?(ref: AlbumRef): Promise<Partial<AlbumEnrichment>>;\n}\n","/**\n * The one error shape that means the same thing on both sides of the plugin\n * boundary.\n *\n * Deliberately NOT a subclass of the API's `ServerkitError`: that class lives\n * in `@maroonedsoftware/errors`, a server dependency, and inheriting from it\n * here would pull the host's framework into every plugin's dependency tree,\n * which is exactly what this package exists to avoid. So a plugin says what\n * went wrong in its own vocabulary and the host decides what that means over\n * HTTP (see `plugin.error.http.ts` in the API).\n *\n * The classification fields (`code`, `retryable`, `retryAfterMs`,\n * `upstreamStatus`, `message`) are all JSON-safe on purpose: nothing here\n * crosses the boundary as a live object today, but the day plugins move out of\n * process, what has to change is how this is transported, not what it says.\n * `cause` is the exception, and is in-process debugging detail only.\n */\n\n/**\n * Why a plugin call failed, in terms the host can act on.\n *\n * These are semantic, not HTTP: an upstream's status code is a diagnostic\n * (`upstreamStatus`), never the answer to what this API should respond. A\n * provider's 404 and \"no plugin by that id\" are not the same 404.\n */\nexport type PluginErrorCode =\n /** Credentials are missing, expired or rejected. The operator has to reauthorize. */\n | 'auth'\n /** The plugin's stored settings are wrong or incomplete. The operator has to fix the form. */\n | 'config'\n /** The upstream has no such resource. Often not an error at all to the caller. */\n | 'not_found'\n /**\n * The upstream understood, and refused for this specific resource. Distinct\n * from `auth`: the credentials are fine and the next call for something\n * else will succeed.\n */\n | 'forbidden'\n /** The upstream is throttling. Honour `retryAfterMs` when it is set. */\n | 'rate_limited'\n /** The call did not finish in time. */\n | 'timeout'\n /** The plugin or its upstream is temporarily out of service. */\n | 'unavailable'\n /** The plugin does not implement what was asked of it. */\n | 'unsupported'\n /** The upstream answered, and what it said was a failure. */\n | 'upstream'\n /** Anything else, including a bug in the plugin. */\n | 'internal';\n\n/** Every {@link PluginErrorCode}, for validating a code that arrived from third-party code. */\nexport const PLUGIN_ERROR_CODES = [\n 'auth',\n 'config',\n 'not_found',\n 'forbidden',\n 'rate_limited',\n 'timeout',\n 'unavailable',\n 'unsupported',\n 'upstream',\n 'internal',\n] as const;\n\n/**\n * Codes that describe the thing that was asked for rather than the health of\n * the plugin that was asked.\n *\n * This is a separate question from {@link RETRYABLE_BY_CODE}, and conflating\n * the two is a trap. \"Retrying will not help\" and \"this plugin is sick\" feel\n * like the same statement and are not: asking Spotify for a playlist you do\n * not own is refused every time, forever, while the connection it was asked\n * over is in perfect health. A host that counts those refusals as failures\n * eventually quarantines a working plugin for correctly answering the\n * question it was asked.\n *\n * So a resource-scoped failure is reported to the caller and otherwise\n * forgotten: it neither trips a circuit breaker nor clears one, because it is\n * not evidence in either direction.\n */\nconst RESOURCE_SCOPED_CODES: ReadonlySet<PluginErrorCode> = new Set(['not_found', 'forbidden', 'unsupported']);\n\n/**\n * Whether this failure is about the requested resource rather than the plugin.\n *\n * See {@link RESOURCE_SCOPED_CODES}. Hosts use this to decide whether a\n * failure counts against a plugin's health.\n */\nexport function isResourceScopedCode(code: PluginErrorCode): boolean {\n return RESOURCE_SCOPED_CODES.has(code);\n}\n\n/**\n * Whether repeating the identical call could plausibly succeed.\n *\n * `internal` is listed as retryable even though a plugin bug will not fix\n * itself: it is the bucket every unclassified throw lands in, and the host\n * uses this flag to decide whether to quarantine a plugin on the spot. Being\n * wrong in the lenient direction costs a couple of retries; being wrong in the\n * strict direction quarantines a working plugin over one bad response.\n */\nconst RETRYABLE_BY_CODE: Record<PluginErrorCode, boolean> = {\n auth: false,\n config: false,\n not_found: false,\n forbidden: false,\n unsupported: false,\n rate_limited: true,\n timeout: true,\n unavailable: true,\n upstream: true,\n internal: true,\n};\n\n/**\n * Marker that survives a plugin carrying its own copy of this module.\n *\n * `Symbol.for` reads from the global symbol registry, which is shared by every\n * realm in the agent, so a second copy of this file computes the *identical*\n * symbol rather than a private one. That is the whole trick: `instanceof`\n * compares class identity and a second copy has its own class, while this\n * compares a value two copies independently agree on.\n *\n * A symbol rather than a string property because it then stays out of\n * `JSON.stringify`, `Object.keys` and log output, and because a plain object\n * decoded off the wire cannot carry one by accident the way a well-guessed\n * string field could.\n *\n * Only {@link toPluginError} reads it. See the note on {@link isPluginError}\n * for why recognition and adoption are deliberately different questions.\n */\nconst PLUGIN_ERROR_BRAND = Symbol.for('deadair.plugin-error/v1');\n\n/**\n * A failure a plugin can describe well enough for the host to answer properly.\n *\n * Throwing a bare `Error` stays perfectly legal; the host treats it as\n * `internal` and behaves exactly as it did before this class existed. Reach\n * for `PluginError` when the caller can do something different with the answer:\n * reauthorize, wait, fix a setting, or give up.\n *\n * The classification is applied with the `with*` builders rather than through\n * the constructor, so a subclass only has to forward `(message, options)` to\n * `super` and can then say what it means on its own terms:\n *\n * ```ts\n * throw new PluginError('slow down').withCode('rate_limited').withUpstreamStatus(429).withRetry(30_000);\n * ```\n *\n * Call {@link withCode} first: it resets `retryable` to the default for the\n * code, so a later `withCode` would undo an earlier {@link withRetry}.\n */\nexport class PluginError extends Error {\n /** @internal Recognition marker for a foreign copy. See {@link PLUGIN_ERROR_BRAND}. */\n readonly [PLUGIN_ERROR_BRAND] = true;\n\n /** What went wrong, in host vocabulary. Defaults to `internal`; set it with {@link withCode}. */\n code: PluginErrorCode = 'internal';\n /** Whether repeating the call could plausibly succeed. See {@link RETRYABLE_BY_CODE}. */\n retryable: boolean = RETRYABLE_BY_CODE.internal;\n /** How long to wait before retrying, when the upstream said so (`Retry-After`). */\n retryAfterMs?: number;\n /**\n * The upstream's HTTP status, for logs and for plugin-internal branching.\n * Diagnostic only: the host never forwards it as its own response status.\n */\n upstreamStatus?: number;\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n\n // Restore the prototype to the actual class used with `new` (workaround\n // for the historic Error-subclass instanceof bug in transpilers / older\n // V8). Using `new.target.prototype` means subclasses get correct `instanceof`\n // behaviour without each one having to replicate this line.\n Object.setPrototypeOf(this, new.target.prototype);\n this.name = 'PluginError';\n }\n\n /** Classifies the failure, and resets `retryable` to the default for that code. */\n withCode(code: PluginErrorCode) {\n this.code = code;\n this.retryable = RETRYABLE_BY_CODE[code];\n return this;\n }\n\n /**\n * Attaches the upstream's retry advice. Saying \"wait this long and try\n * again\" is itself a statement that a retry is worth making, so this marks\n * the failure retryable regardless of what the code defaults to.\n */\n withRetry(retryAfterMs: number) {\n this.retryable = true;\n this.retryAfterMs = retryAfterMs;\n return this;\n }\n\n withUpstreamStatus(upstreamStatus: number) {\n this.upstreamStatus = upstreamStatus;\n return this;\n }\n}\n\n/**\n * Whether `value` is one of ours: an error this copy of the module built.\n *\n * Deliberately `instanceof`, and deliberately NOT the same question\n * {@link toPluginError} answers. Almost every caller asking this is host code\n * downstream of the invoker, where the error has already been adopted and the\n * honest question is \"did we make this\", to which `instanceof` is the exact,\n * unforgeable answer. It also keeps subclasses (`SpotifyRequestError`) working\n * for the plugin's own branching, via the constructor's `new.target` fix-up.\n *\n * Tolerating a foreign copy is the boundary's job, and the boundary is one\n * function wide. Making this guard structural instead would spread that\n * laxness across every call site that only ever sees host-built errors.\n */\nexport const isPluginError = (error: unknown): error is PluginError => {\n return error instanceof PluginError;\n};\n\n/**\n * Whether `value` is a `PluginError` from a *different* copy of this module.\n *\n * See {@link PLUGIN_ERROR_BRAND}. The brand proves the shape, not that the\n * other copy agreed with this one about which codes exist, so\n * {@link toPluginError} still validates every field it reads.\n */\nfunction isForeignPluginError(value: unknown): value is Partial<PluginError> {\n if (typeof value !== 'object' || value === null) return false;\n return (value as Partial<PluginError>)[PLUGIN_ERROR_BRAND] === true;\n}\n\n/**\n * Whatever a plugin threw, as a {@link PluginError} this copy owns.\n *\n * This is the boundary function, and the only place tolerant recognition\n * belongs: the host funnels every call into plugin code through one door\n * (`PluginInvoker.invoke`), so a foreign error is adopted exactly once and\n * everything downstream deals only with errors the host itself constructed.\n *\n * Three cases, in order of how much is trusted:\n *\n * 1. Ours: passed straight through, keeping its identity so a `catch` further\n * up can still recognize the specific subclass that was thrown.\n * 2. Branded but from another copy: rebuilt here, field by field, with the\n * code checked against {@link PLUGIN_ERROR_CODES}. An unrecognized code\n * degrades to `fallback` rather than leaking a made-up string into the\n * host's HTTP mapping, and a non-numeric `retryAfterMs` is dropped rather\n * than turned into a `Retry-After` header of `NaN`.\n * 3. Anything else: adopted under `fallback`, original kept as the `cause`.\n *\n * The rebuild in case 2 is the same operation that will be needed when plugins\n * move out of process and a failure arrives as JSON rather than as a live\n * object: what changes then is how it is transported, not what it says.\n */\nexport function toPluginError(error: unknown, fallback: PluginErrorCode = 'internal'): PluginError {\n if (isPluginError(error)) return error;\n\n if (isForeignPluginError(error)) {\n const known = typeof error.code === 'string' && (PLUGIN_ERROR_CODES as readonly string[]).includes(error.code);\n const message = typeof error.message === 'string' ? error.message : String(error);\n const adopted = new PluginError(message, { cause: error }).withCode(known ? (error.code as PluginErrorCode) : fallback);\n\n if (typeof error.retryAfterMs === 'number' && Number.isFinite(error.retryAfterMs)) adopted.withRetry(error.retryAfterMs);\n if (typeof error.upstreamStatus === 'number' && Number.isFinite(error.upstreamStatus)) adopted.withUpstreamStatus(error.upstreamStatus);\n\n return adopted;\n }\n\n const message = error instanceof Error ? error.message : String(error);\n return new PluginError(message, { cause: error }).withCode(fallback);\n}\n\n/**\n * A caught `unknown` as a sentence, for a message a person reads.\n *\n * `catch` binds `unknown`, so every plugin reporting a failure narrows it before\n * it can say anything — five sites here did, four of them inline. The fallback\n * is not padding: a rejected fetch, a thrown string and an aborted signal all\n * arrive here and only some of them are `Error`.\n *\n * This is NOT error handling and is not a substitute for {@link toPluginError}.\n * It produces a string for a log line or a `testConnection` result, deliberately\n * losing the code, the cause and the retry advice. Anything deciding what to DO\n * about a failure wants the error itself.\n */\nexport const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n","/**\n * The `llm` capability. A language-model plugin takes a conversation and answers\n * with words: the line a DJ says, the running order a set generator asked for,\n * the copy for a sponsor read.\n *\n * ## This is a transport, not a writer\n *\n * Nothing here knows what a break is. That is deliberate and it is the one\n * constraint worth defending: the previous station had five things that produced\n * a script (a talk break, a sign-on, a news bulletin, a DJ set, a two-voice\n * dialogue) and every one of them was messages in and text out. A capability\n * shaped around any single one of them has to be reshaped for the next.\n *\n * So: {@link LlmMessage}s in, text out, and everything about WHAT to say lives\n * on the station's side of the fence.\n *\n * ## The words come back as a stream\n *\n * {@link LlmPluginInstance.generate} answers with a handle carrying a stream, the\n * way `speak()` does, and for a stronger reason than memory. The host serializes\n * generations through a single slot, and it holds that slot until the words stop\n * arriving rather than until the call resolves. On a local model, releasing early\n * lets two generations overlap and both of them get slower.\n *\n * Read {@link LlmHandle.text} to the end, or `cancel()` it. Then await\n * {@link LlmHandle.result}, which is where the tool calls, the usage and the\n * reason it stopped are. {@link collectGeneration} does both for a caller that\n * only wants the answer.\n *\n * ## The model is chosen per call\n *\n * {@link LlmRequest.model} overrides whatever the plugin has configured, because\n * a station wants a big model for a show and a small one for a station ident, and\n * one plugin holds exactly one config row (`plugin_configs.plugin_id` is a\n * primary key). Absent means \"whatever you are set up with\", which is the\n * ordinary case.\n *\n * ## Tools are declared here and executed by the host\n *\n * A request may carry {@link LlmToolDeclaration}s. What comes back is\n * {@link LlmToolCall}s: data, not invocations. The host runs the tool and sends\n * the result back as another message.\n *\n * That split is not ceremony. A callback crossing this boundary would be a\n * function in a payload, and it would put station code inside a plugin, which is\n * the wrong side of the fence for deciding what the station is allowed to do.\n * Every shape in this file except {@link LlmHandle} is JSON-safe, and the one\n * exception carries a stream on purpose.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\nimport { PluginError } from '../plugin.error.js';\n\n/**\n * How hard a reasoning model should think before answering.\n *\n * Sent as `reasoning_effort`, and **only when the caller asked for it** — which is\n * a hint, not a guarantee it reaches the server. A plugin may hold its own\n * setting that overrides this, forwards it unchanged, or refuses to send it at\n * all, and a plugin that has seen a 400 naming the field drops it for its own\n * lifetime regardless of what a caller asks for afterward. Leave it unset for\n * anything that is not a reasoning model: the field means nothing to a plain\n * model and a strict OpenAI-compatible server answers 400 rather than ignoring\n * it.\n */\nexport type LlmReasoningEffort = 'low' | 'medium' | 'high';\n\n/** Why a generation stopped. `tool-calls` is the one the host's loop acts on. */\nexport type LlmFinishReason = 'stop' | 'length' | 'tool-calls' | 'content-filter' | 'error' | 'other';\n\n/** One turn of the conversation. */\nexport interface LlmMessage {\n role: 'system' | 'user' | 'assistant' | 'tool';\n\n /**\n * The words. Empty is legitimate on an `assistant` turn that did nothing but\n * ask for a tool.\n */\n content: string;\n\n /**\n * What this `assistant` turn asked for, when it asked for tools.\n *\n * The host replays it verbatim on the next call, because a model that cannot\n * see its own tool call has no idea what the `tool` message after it is\n * answering.\n */\n toolCalls?: LlmToolCall[];\n\n /** Which call this `tool` turn answers. Absent on every other role. */\n toolCallId?: string;\n\n /**\n * What the provider signed on this `assistant` turn, quoted back verbatim.\n * Absent on every other role, and absent from a provider that signs nothing.\n *\n * Opaque to the host, which is the point: it is the plugin's own\n * {@link LlmResult.providerState} handed straight back, so a provider that\n * refuses a turn missing its own signature gets one that has it. See\n * {@link LlmResult.providerState} for what puts it there.\n */\n providerState?: Record<string, unknown>;\n}\n\n/** A tool the model may ask for. */\nexport interface LlmToolDeclaration {\n /** How the model names it when calling. */\n name: string;\n\n /**\n * What it does, written for the model rather than for a developer. This is\n * the entire basis on which it decides whether to call the thing, so \"current\n * conditions and today's high and low for a place\" beats \"weather lookup\".\n */\n description: string;\n\n /**\n * JSON Schema for the arguments, as a plain object.\n *\n * Not a zod schema: this is a payload that is sent, and a schema instance is\n * a class. Convert on the way in if that is what you hold.\n */\n parameters: Record<string, unknown>;\n}\n\n/** The model asking for a tool. Data, not an invocation. */\nexport interface LlmToolCall {\n /** The model's own id for this call, quoted back on {@link LlmMessage.toolCallId}. */\n id: string;\n\n /** Which declaration it wants, by {@link LlmToolDeclaration.name}. */\n name: string;\n\n /**\n * The arguments, already parsed out of the JSON the model produced.\n *\n * Unvalidated against the declared schema: the model is perfectly capable of\n * inventing a field or omitting a required one, and the host checks before\n * running anything.\n */\n arguments: Record<string, unknown>;\n}\n\n/** What one generation cost. Absent fields are ones the provider did not report. */\nexport interface LlmUsage {\n inputTokens?: number;\n outputTokens?: number;\n totalTokens?: number;\n\n /**\n * How much of {@link outputTokens} went on thinking rather than on the answer.\n *\n * Separate because the two failures underneath one `outputTokens` figure are\n * opposite: a model that wrote a long answer and one that spent its whole\n * allowance reasoning and emitted nothing both finish on `length` at the same\n * total, and only this tells them apart. A caller that logs a token count\n * without it cannot answer \"where did the allowance go\" after the fact, which\n * is the question a zero-pick run actually raises.\n *\n * Reported by the provider, so absent on plenty of them — see\n * {@link reasoningChars} for what to fall back on.\n */\n reasoningTokens?: number;\n\n /**\n * The length of the reasoning text, in characters.\n *\n * MEASURED rather than reported, and here for one reason: an\n * OpenAI-compatible server that streams reasoning without counting it leaves\n * {@link reasoningTokens} undefined, and a station running against one would\n * otherwise learn nothing from either field. Characters are a poor unit and\n * an honest one — roughly four to the token — so this answers the shape of\n * the question (\"all of it\" versus \"none of it\") where the exact figure is\n * not on offer.\n */\n reasoningChars?: number;\n}\n\n/** One conversation to continue. */\nexport interface LlmRequest {\n /**\n * The conversation so far, oldest first, with the system prompt as the first\n * turn where there is one.\n */\n messages: LlmMessage[];\n\n /** Which model, or absent for the plugin's configured one. */\n model?: string;\n\n /** Sampling temperature, or absent for the plugin's configured one. */\n temperature?: number;\n\n /** A ceiling on the answer, in tokens. Absent means the provider's own. */\n maxOutputTokens?: number;\n\n /** See {@link LlmReasoningEffort}. Absent means send nothing at all. */\n reasoningEffort?: LlmReasoningEffort;\n\n /**\n * What the model may call.\n *\n * Only sent to a model that says it can (see {@link LlmModelInfo.tools}), so\n * a plugin receiving this has already been told the model supports it. If it\n * does not, answer `unsupported` rather than dropping them silently: a break\n * written without the facts a tool would have supplied is worse than one that\n * fell back to the deterministic writer.\n */\n tools?: LlmToolDeclaration[];\n}\n\n/** Everything about a finished generation except the words as they arrived. */\nexport interface LlmResult {\n /** The whole answer, accumulated. Empty when the model only asked for tools. */\n text: string;\n\n /** What it asked for. Empty on an ordinary answer. */\n toolCalls: LlmToolCall[];\n\n /** What it cost, where the provider said. */\n usage?: LlmUsage;\n\n finishReason: LlmFinishReason;\n\n /**\n * Whatever this provider SIGNED on this turn, in the plugin's own shape, for\n * the host to hand back on the `assistant` message it builds out of this\n * result. Absent when the provider signed nothing, which is every\n * OpenAI-compatible server.\n *\n * The host never reads it. It exists because two providers refuse a tool\n * round trip whose earlier turns arrive stripped: Anthropic will not accept a\n * turn whose thinking block and its signature are missing, and Gemini wants\n * its thought signatures back on the function calls it made. Both are facts\n * about a wire protocol rather than about a conversation, so the station's\n * boundary carries them without describing them.\n *\n * JSON-safe like everything else here: it is stored in a transcript and sent\n * back across the boundary, so no class instances and no functions.\n */\n providerState?: Record<string, unknown>;\n}\n\n/**\n * A generation in flight.\n *\n * Carries a live stream, which is why it is classified as a live-object type in\n * `boundary.json.safe.ts` rather than as a payload. {@link LlmResult} is the\n * payload, and it is JSON-safe.\n */\nexport interface LlmHandle {\n /**\n * The answer as it arrives.\n *\n * The host reads it to the end or cancels it, and either one releases what is\n * underneath. **Cancelling has to actually stop the generation**, which is a\n * requirement on the PLUGIN rather than something the platform can arrange:\n * the host has no other handle on the work once `generate` has returned.\n *\n * The trap that makes this worth spelling out is that forwarding a provider's\n * stream does NOT satisfy it by itself. A plugin that hands over one branch of\n * a tee, or whose {@link result} keeps reading the source, has given the host\n * a stream it can close and a generation it cannot stop — and the invocation\n * signal is no help, because that is disposed the moment `generate` resolves\n * and a generation legitimately outlives the call that started it. So a plugin\n * owns an abort of its own, for as long as the words are still arriving.\n */\n text: ReadableStream<string>;\n\n /**\n * Settles once the generation is done.\n *\n * **Read {@link text} first.** A provider stream that nobody is draining\n * applies backpressure, so awaiting this without consuming the words is how a\n * caller waits forever. {@link collectGeneration} exists so that ordering is\n * not something each caller has to remember.\n */\n result: Promise<LlmResult>;\n}\n\n/** One model this plugin can be asked for. */\nexport interface LlmModelInfo {\n /** The id to pass back as {@link LlmRequest.model}. */\n id: string;\n\n /** What the console calls it. Absent means show the id. */\n label?: string;\n\n /**\n * Whether this model can be given {@link LlmRequest.tools}.\n *\n * Per MODEL, not per server, which is why it lives here rather than on the\n * plugin: one endpoint commonly serves both a model that can call tools and\n * one that cannot, and asking the wrong one is a failed generation rather\n * than a degraded answer.\n */\n tools: boolean;\n\n /**\n * Whether this is the model a request with no {@link LlmRequest.model} gets.\n *\n * Set it if you have one. Without it the host cannot tell which of the models\n * you listed an unnamed request will actually reach, so it has to assume the\n * worst one and will never send tools unless a caller names a model itself —\n * which gets steadily more likely to be wrong the more models your server has.\n */\n default?: boolean;\n}\n\n/** A plugin that can produce words. */\nexport interface LlmPluginInstance extends PluginLifecycle {\n /**\n * Continue the conversation.\n *\n * May return before any words exist, because the handle carries a stream and\n * not the text, so a model that thinks for ten seconds shows up as a slow\n * first chunk rather than as a slow `generate`.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no server address, no model), `unsupported` when asked for tools the\n * named model cannot do, `upstream` when the provider refused, `timeout`\n * when it did not answer, `rate_limited` when it said to wait.\n */\n generate(request: LlmRequest): Promise<LlmHandle>;\n\n /**\n * The models this plugin can be asked for, for a console drawing a list and\n * for the host deciding whether it may send tools.\n *\n * Optional, like `listVoices` on the speech capability. But note what absent\n * costs: the host has no way to learn that any model here supports tools, so\n * it sends none. A plugin that wants tool calling has to describe itself.\n */\n listModels?(): Promise<LlmModelInfo[]>;\n}\n\n/**\n * Drain a handle and answer with the finished result.\n *\n * The ordinary way to use {@link LlmPluginInstance.generate} when the caller\n * wants the answer rather than the words as they arrive. Streaming still happens\n * underneath, so the host's slot is still released at the right moment; what this\n * removes is the chance of awaiting {@link LlmHandle.result} without draining\n * {@link LlmHandle.text} first.\n *\n * A free function rather than a method, following `jsonBody` and `tryJsonBody`:\n * it keeps the handle the platform's own shape, and it is nothing a plugin should\n * have to implement.\n *\n * Pass a `signal` to stop early. The drain is raced against it and the stream is\n * cancelled, which — per {@link LlmHandle.text} — is what asks the plugin to stop\n * generating. Without one this waits for the model however long it takes, which is\n * right for a caller that has nothing better to do and wrong for one holding a\n * slot something with a deadline is queued for.\n */\nexport async function collectGeneration(handle: LlmHandle, signal?: AbortSignal): Promise<LlmResult> {\n const reader = handle.text.getReader();\n // Attached before anything can reject, and for that alone. A cancelled generation settles\n // `result` by rejecting, and on the abort path below nobody ever awaits it — so without a\n // handler already on it the process takes an unhandled rejection for a stop the host asked for.\n void handle.result.catch(() => undefined);\n\n try {\n while (true) {\n if (signal?.aborted) break;\n\n // Raced rather than awaited, because a read that is already waiting on the provider\n // does not come back when the signal fires. The loser is dropped, and `cancel` below\n // is what actually ends it.\n const chunk = await Promise.race([reader.read(), aborted(signal)]);\n if (chunk === ABORTED) break;\n\n // Read for the backpressure, not for the text: `result.text` is the accumulated\n // answer and the authority on it. A plugin forwarding a provider stream and a\n // plugin buffering one both satisfy that, and only the first would agree with\n // whatever this loop concatenated.\n if (chunk.done) break;\n }\n } finally {\n if (signal?.aborted) {\n // NOT awaited, and the lock is left held. A read is still outstanding — that is the\n // whole situation being escaped — and `cancel()` does not settle until the source's\n // pending pull does, so awaiting it here would wait out the generation this is\n // cancelling. The side effect that reaches the plugin's abort runs synchronously\n // inside the stream's own cancel algorithm, which is the part that matters.\n void reader.cancel().catch(() => undefined);\n } else {\n reader.releaseLock();\n }\n }\n\n if (signal?.aborted) {\n // Deliberately NOT awaiting `settled`. Attaching the handler is what prevents the unhandled\n // rejection, and waiting for it would put this back where it started: a plugin whose result\n // only settles when the generation ends would hold the caller here for exactly as long as\n // the generation it just cancelled.\n throw new PluginError('the generation was stopped before it finished').withCode('unavailable');\n }\n\n return handle.result;\n}\n\n/** The sentinel the race below resolves to, so an abort is told apart from a chunk. */\nconst ABORTED = Symbol('aborted');\n\n/** A promise that settles when the signal fires, and never otherwise. */\nfunction aborted(signal: AbortSignal | undefined): Promise<typeof ABORTED> {\n if (signal === undefined) return new Promise<typeof ABORTED>(() => undefined);\n if (signal.aborted) return Promise.resolve(ABORTED);\n\n return new Promise<typeof ABORTED>(resolve => signal.addEventListener('abort', () => resolve(ABORTED), { once: true }));\n}\n","/**\n * The `speech` capability. A speech plugin takes a line of text and answers\n * with audio: the station saying its own name, reading a back-announce, or\n * delivering a whole talk break.\n *\n * ## The audio comes back as a stream, not as a value\n *\n * {@link SpeechPluginInstance.speak} returns a stream rather than bytes, and\n * the usual implementation is to hand back the `host.fetch` body of the engine\n * call unchanged. So the audio is never held whole anywhere, and a long script\n * costs a chunk of memory rather than a file of it.\n *\n * Cancellation is the host's `cancel()` on that stream, and forwarding a\n * `host.fetch` body propagates it to the socket for you. There is nothing to\n * implement.\n *\n * ## A voice is a name the station chose, not one your engine knows\n *\n * {@link SpeechRequest.voice} is an opaque id from the station's side of the\n * fence — `host`, `newsreader` — and mapping it to whatever your engine actually\n * takes is your job, out of your own config. The host never reads it, never\n * validates it, and never stores anything engine-specific.\n *\n * The indirection is the point, and it was paid for once already: it is what\n * lets the same station voice be a named preset on one engine and a cloned\n * reference clip on another, so changing engine does not rewrite every persona.\n * Keep engine-specific tuning (an expressiveness dial, a similarity weight)\n * inside your own config where it belongs, rather than asking the host to carry\n * knobs only you understand.\n *\n * ## A delivery is a word the station chose, too\n *\n * The one thing about HOW a line is read that the host does carry is\n * {@link SpeechRequest.delivery}: `hushed` or `frantic`, in the station's words\n * and never as a number. That is the same bargain as a voice and a cue. The host\n * says what it wants in a vocabulary every engine can be asked in, and each plugin\n * translates it into whatever its engine has, whether that is an expressiveness\n * dial, a speed, a style preset or nothing at all. The numbers stay in your config.\n *\n * Every shape here is JSON-safe.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\n\n/**\n * The things a presenter DOES that are not words, as the station names them.\n *\n * A cue rides inside {@link SpeechRequest.text} rather than beside it, written\n * `[laugh]`, which is why nothing in this interface carries one. That is not a\n * shortcut: a laugh happens at a place in a sentence, and a field would have to\n * invent a way to say where.\n *\n * The vocabulary is the STATION's and mapping it is yours, exactly as for\n * {@link SpeechVoice}. A laugh is something a presenter does; whether your engine\n * spells it `[laugh]`, `<laugh>` or not at all is engine business, and the host\n * never learns which.\n *\n * ## Eight, and the second four are not for the presenter\n *\n * This was four, and its stated reason for stopping there was that \"a cough or a\n * sniff reads as illness rather than as delivery\". That is right about somebody\n * being paid to talk and exactly wrong about somebody on the end of a telephone,\n * where the throat-clear IS the realism — so the vocabulary is wider now and WHO\n * may use which is the host's business rather than this list's.\n *\n * The host keeps the first four. A caller in a production may use all eight. Both\n * sets live app-side, because they are permissions rather than capabilities, and\n * a plugin has no way to know which of its speakers is which.\n *\n * **Widening this list without narrowing the offer is how the presenter starts\n * coughing.** Whatever reads a script back has to be told which of these were\n * actually on offer to the person who wrote it, rather than reaching for the whole\n * vocabulary.\n *\n * Still chosen rather than copied from an engine: `shush` is one this list does\n * not take, because shushing is aimed AT somebody in the room. That is a piece of\n * business rather than a way of delivering a line.\n *\n * **Answer {@link SpeechPluginInstance.listCues} honestly and the rest is free.**\n * The host strips every cue you do not claim before it calls {@link\n * SpeechPluginInstance.speak}, so a plugin that implements nothing here never sees\n * one, and the failure where an engine READS the word \"laugh\" out loud cannot\n * happen. Claiming one you cannot perform is the only way to break that.\n */\nexport const SPEECH_CUES = ['laugh', 'chuckle', 'sigh', 'gasp', 'cough', 'clear throat', 'sniff', 'groan'] as const;\n\n/** One of {@link SPEECH_CUES}. */\nexport type SpeechCue = (typeof SPEECH_CUES)[number];\n\n/** Every cue written into a script, in the order they appear, with repeats. */\nexport function cuesIn(text: string): SpeechCue[] {\n return [...text.matchAll(cuePattern())].map(match => match[1]!.toLowerCase() as SpeechCue);\n}\n\n/**\n * The same text with cues removed, or with only some of them kept.\n *\n * `keep` is the set to LEAVE, so the default of none is \"take them all out\" — the\n * safe direction, and the one an engine that has never heard of a cue wants. A\n * removal closes the space it leaves behind, because `word [laugh] word` would\n * otherwise render with a double space that {@link SPEECH_CUES}' own consumers\n * would have to know to tidy.\n *\n * Only the four are touched. Anything else in brackets is somebody else's problem\n * and stays exactly as it arrived: this is not a bracket stripper.\n */\nexport function withoutCues(text: string, keep: Iterable<SpeechCue> = []): string {\n const kept = new Set<string>([...keep]);\n\n return text\n .replace(cuePattern(), (match, cue: string) => (kept.has(cue.toLowerCase()) ? match : ' '))\n .replace(/[^\\S\\n]{2,}/g, ' ')\n .replace(/[^\\S\\n]+([.,!?;:])/g, '$1')\n .trim();\n}\n\n/**\n * A fresh matcher every call, because a `g` flag carries `lastIndex` between them.\n *\n * **Longest form first**, which is `applyPronunciations`' rule one layer up and became\n * load-bearing the moment `clear throat` joined the list: an alternation takes the\n * earliest branch that matches at a position, so a shorter cue that is a prefix of a\n * longer one would claim it and leave the rest as text an engine reads out.\n */\nconst cuePattern = (): RegExp => new RegExp(`\\\\[(${[...SPEECH_CUES].sort((left, right) => right.length - left.length).join('|')})\\\\]`, 'gi');\n\n/**\n * How a whole line is read, as the station names it.\n *\n * Two, and the middle is deliberately not one of them: a request with no delivery is the voice's own\n * ordinary reading, which is what nearly every line should be. A third word for \"ordinary\" would be a\n * second way to ask for nothing, and would key a second cached preview of identical audio.\n *\n * The vocabulary is the STATION's and translating it is yours, exactly as for {@link SPEECH_CUES}.\n * `hushed` is quieter, slower, closer to the microphone; `frantic` is urgent, faster, barely holding\n * on. Whether your engine gets there with an expressiveness dial, a speed, a style token or a\n * different reference clip is engine business, and the host never learns which.\n *\n * ## Why a word and not a number\n *\n * A number is a promise about one engine's scale. `exaggeration: 0.9` means something to one family of\n * models and nothing to the next, so a station that asked for it would be tied to the engine it was\n * built against, which is the thing the voice indirection exists to prevent. A word survives a change\n * of engine. The numbers it becomes live in each plugin's own config, beside the voice they tune.\n *\n * ## Claim only what you can perform\n *\n * {@link SpeechPluginInstance.listDeliveries} says which of these your engine can do RIGHT NOW, and the\n * host drops any delivery you did not claim before it calls {@link SpeechPluginInstance.speak}. So a\n * plugin that implements nothing here never sees one, and the writer is never offered a delivery the\n * engine would ignore. Claiming one you cannot perform is the only way to break it.\n */\nexport const SPEECH_DELIVERIES = ['hushed', 'frantic'] as const;\n\n/** One of {@link SPEECH_DELIVERIES}. */\nexport type SpeechDelivery = (typeof SPEECH_DELIVERIES)[number];\n\n/** Whether a value is one of {@link SPEECH_DELIVERIES}, exactly as written. */\nexport function isSpeechDelivery(value: unknown): value is SpeechDelivery {\n return typeof value === 'string' && (SPEECH_DELIVERIES as readonly string[]).includes(value);\n}\n\n/** One thing to say. */\nexport interface SpeechRequest {\n /**\n * The words, already final. Nothing downstream rewrites them, so any\n * pronunciation fixing your engine needs is yours to apply.\n */\n text: string;\n\n /**\n * Which station voice to use, or absent for this plugin's default.\n *\n * Opaque, and a name the operator chose. An id you have no mapping for\n * should fall back to your default rather than fail: a missing voice is\n * worth a `logger.warn` and a rendered line, not a silent station.\n */\n voice?: string;\n\n /**\n * The audio format the caller would prefer, as a bare extension (`mp3`).\n *\n * A hint, and the weakest thing in this interface: answer with whatever you\n * actually produced in {@link SpeechHandle.mime} and the host will believe\n * that instead. Ignore it entirely if your engine emits one format.\n */\n format?: string;\n\n /**\n * How to read the whole line, or absent for the voice's own ordinary reading.\n *\n * Only ever one you claimed through {@link SpeechPluginInstance.listDeliveries}: the host drops the\n * rest before this call. Translate it into your engine's own controls, relative to whatever the\n * voice already sounds like, so a voice that is intense at rest is still more intense than its\n * neighbours when hushed. See {@link SPEECH_DELIVERIES}.\n */\n delivery?: SpeechDelivery;\n}\n\n/** The audio for one {@link SpeechPluginInstance.speak}, and what it is. */\nexport interface SpeechHandle {\n /**\n * What the bytes ARE, as a media type (`audio/mpeg`).\n *\n * Load-bearing rather than decoration: it is what the host stores the audio\n * under and what it later serves, and both consumers of station audio (a\n * browser's `<audio>`, which does not sniff, and the playout engine, which\n * picks its decoder from the content type) go by that header rather than by\n * the bytes. A wav announced as `audio/mpeg` fails as silence rather than\n * as an error anybody sees.\n */\n mime: string;\n\n /**\n * The audio.\n *\n * The host reads it to the end or cancels it, and either one releases\n * whatever is underneath. Usually a `host.fetch` body forwarded unchanged,\n * which is what makes that true for free.\n */\n audio: ReadableStream<Uint8Array>;\n}\n\n/** One voice this plugin can be asked for. */\nexport interface SpeechVoice {\n /** The id to pass back as {@link SpeechRequest.voice}. */\n id: string;\n\n /** What the console calls it. */\n label: string;\n\n /** Anything worth knowing when choosing between them: an accent, a register. */\n description?: string;\n\n /**\n * An opaque token that changes when what this voice SOUNDS LIKE changes.\n *\n * The host does not interpret it, parse it, store it or show it. It uses it\n * for one thing: keying the cached preview at `GET /voices/{id}/sample`, so\n * that remapping `host` from one engine voice to another — or nudging its\n * speed, or swapping its reference clip — mints a new key and the next\n * preview renders instead of playing the old voice back.\n *\n * That was already the claim `VoiceSampleStore` made in its own doc comment\n * and it was not true: the key held the STATION voice id, which is exactly\n * the part that does not change when an operator edits the mapping under it.\n * The fence is intact because this stays opaque — whatever string identifies\n * a rendering to you is the right value, and `engineVoice@speed` is a fine\n * one.\n *\n * Absent is normal. A plugin that omits it keys previews as it always did,\n * which is correct for one whose voices cannot be reconfigured.\n */\n spec?: string;\n}\n\n/** A plugin that can speak. */\nexport interface SpeechPluginInstance extends PluginLifecycle {\n /**\n * Start turning `request.text` into audio.\n *\n * May return before any audio exists, because the handle carries a stream\n * and not the bytes, so a slow engine shows up as a slow first chunk rather\n * than as a slow `speak`.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no server address), `upstream` when the engine refused or answered with\n * something that is not audio, `timeout` when it did not answer at all.\n */\n speak(request: SpeechRequest): Promise<SpeechHandle>;\n\n /**\n * The voices this plugin can be asked for, for a console that has to draw a\n * list.\n *\n * Optional, because a plugin with exactly one voice is a legitimate thing to\n * be and should not have to describe it. Absent is normal, not broken.\n */\n listVoices?(): Promise<SpeechVoice[]>;\n\n /**\n * Which of {@link SPEECH_CUES} this plugin can perform RIGHT NOW.\n *\n * Optional, and absent means none: an engine that only reads words is the\n * ordinary case and should not have to say so. That default is what makes this\n * safe to add — the host strips what you do not claim, so silence costs a\n * plugin nothing and risks nothing.\n *\n * **Answer from what the engine currently IS, not from what this plugin was\n * built against.** On the engine this was written for, cues belong to the\n * loaded MODEL rather than to the server, so swapping the model takes them away\n * with no plugin change involved — which is exactly the case a manifest flag\n * would get wrong, and it would get it wrong by having the station perform to\n * an engine that reads the word out.\n *\n * Called on the path that writes a break as well as the one that speaks it, so\n * keep it cheap and answer empty rather than throwing when the engine cannot be\n * reached: a station that cannot ask should write a script with no cues in it,\n * not fail to write one.\n */\n listCues?(): Promise<readonly SpeechCue[]>;\n\n /**\n * Which of {@link SPEECH_DELIVERIES} this plugin can perform RIGHT NOW.\n *\n * Optional, and absent means none, for the reason {@link listCues} gives: most engines have no\n * such control, and saying nothing costs a plugin nothing because the host drops what you do not\n * claim.\n *\n * The same three rules as for cues, too. Answer from what the engine currently IS, since on some\n * engines the control belongs to the loaded model rather than to the server. Keep it cheap,\n * because it is asked on the path that writes a break. And answer empty rather than throwing when\n * the engine cannot be reached, because a station that cannot ask should write an ordinary\n * reading rather than fail to write one.\n */\n listDeliveries?(): Promise<readonly SpeechDelivery[]>;\n}\n","import type { HostFetchMethod } from './plugin.host.js';\nimport type { PluginErrorCode } from './plugin.error.js';\n\n/**\n * The pieces every plugin that talks to an HTTP upstream was writing for itself.\n *\n * Four clients here wrap `host.fetch` the same way — check the status, turn it\n * into a {@link PluginErrorCode}, pull the upstream's own sentence out of the\n * body, and throw something carrying both. The WRAPPER is not shared, because\n * each one throws its own error type and each branches on its own service's\n * quirks. What is shared is underneath it: the rows of the status ladder that\n * mean the same thing everywhere, the truncation, the `Retry-After` parse, and\n * the defensive dig into a JSON error body.\n *\n * Deliberately not here: retry and pacing. A plugin declares its rate limit on\n * its manifest and `host.fetch` applies it, so no plugin implements a backoff\n * and none should start.\n */\n\n/** How long an upstream's own sentence may be before it is cut. */\nconst MAX_UPSTREAM_MESSAGE = 200;\n\n/**\n * An upstream's sentence, bounded.\n *\n * An error body is untrusted text that ends up in a log line and on a settings\n * card, so its length is not the upstream's decision to make. Three clients cut\n * it at the same 200 characters with the same ellipsis.\n */\nexport function truncateUpstreamMessage(message: string): string {\n return message.length > MAX_UPSTREAM_MESSAGE ? `${message.slice(0, MAX_UPSTREAM_MESSAGE)}…` : message;\n}\n\n/**\n * A string field out of a JSON error body, or `undefined`.\n *\n * Defensive on purpose, and in a specific way: the body is whatever the edge\n * happened to send — an HTML page from a proxy, an empty 429, a truncated\n * response — so a failed parse has to read as \"said nothing\" rather than throw\n * inside the code that was already handling a failure. An empty string is also\n * nothing, since it would otherwise print as a blank reason.\n *\n * @param body - The raw response body. `undefined` when it was never read.\n * @param pick - Reaches the field. Runs on `unknown`, so it casts; anything it\n * returns that is not a non-empty string is discarded.\n */\nexport function upstreamField(body: string | undefined, pick: (parsed: unknown) => unknown): string | undefined {\n if (!body) return undefined;\n\n let value: unknown;\n try {\n value = pick(JSON.parse(body));\n } catch {\n return undefined;\n }\n\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\n/**\n * `Retry-After` in whole seconds, as milliseconds.\n *\n * Only the seconds form is read. The HTTP-date form is legal and no upstream\n * here sends it, and guessing wrong would tell the host to sit out a wait that\n * was never asked for. Anything unparseable or negative means \"no advice\n * given\", which is different from \"wait zero\".\n *\n * Takes `null` as well as `undefined` because one caller reads it off a\n * `Headers` (which answers `null`) and another off a record (which answers\n * `undefined`).\n */\nexport function retryAfterMs(header: string | null | undefined): number | undefined {\n if (header === null || header === undefined) return undefined;\n\n const seconds = Number(header.trim());\n if (!Number.isFinite(seconds) || seconds < 0) return undefined;\n\n return seconds * 1000;\n}\n\n/**\n * The rows of the status ladder that mean the same thing at every upstream.\n *\n * A plugin layers its own service's readings in FRONT of this rather than\n * replacing it, because the deviations are the interesting part and each one is\n * a decision worth writing down beside the plugin it belongs to. Spotify reads\n * 401 as `auth` and 403 as `forbidden`; MusicBrainz reads a 503 carrying a\n * `Retry-After` as `rate_limited`; the two token-authenticated services read\n * 401 as `config`, because a pasted token is a setting. None of those belongs\n * here.\n *\n * What does belong here is the part nobody disagrees about: a 404 is a missing\n * thing, a 429 is going too fast, any other 5xx is the upstream being down, and\n * everything else is the upstream saying something unhelpful.\n */\nexport function pluginCodeForStatus(status: number): PluginErrorCode {\n if (status === 404) return 'not_found';\n if (status === 429) return 'rate_limited';\n if (status >= 500) return 'unavailable';\n\n return 'upstream';\n}\n\n/**\n * A one-line summary of a failed response, for a message a human reads.\n *\n * The status is always there; the other two often are not, and an upstream that\n * sent neither should not produce a line with holes in it.\n */\nexport function upstreamDetail(status: number, statusText?: string, reason?: string): string {\n return [`HTTP ${status}`, statusText, reason].filter(part => part).join(' ');\n}\n\n/** The methods `host.fetch` will carry. */\nexport const HOST_FETCH_METHODS: readonly HostFetchMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];\n\n/**\n * A method name as one `host.fetch` accepts, or `undefined` if it is not one.\n *\n * Answers rather than throws, because the two callers refuse in different words\n * and to different audiences — one is telling a plugin author that the AI SDK\n * asked for something impossible, the other is a bridge reporting its own bug —\n * and a shared throw would have flattened both into one message.\n */\nexport function hostFetchMethod(method: string | undefined): HostFetchMethod | undefined {\n const upper = (method ?? 'GET').toUpperCase();\n\n return HOST_FETCH_METHODS.find(candidate => candidate === upper);\n}\n\n/**\n * Headers as the plain lower-cased record `HostFetchInit.headers` takes.\n *\n * A `Headers` instance has already lower-cased its names and joined repeats,\n * which is the behaviour to want: the host's header handling carries one value\n * per name. Lower-casing again is free and makes the guarantee local.\n */\nexport function headersToRecord(headers: Headers): Record<string, string> {\n const record: Record<string, string> = {};\n\n headers.forEach((value, name) => {\n record[name.toLowerCase()] = value;\n });\n\n return record;\n}\n","/**\n * Somebody else's markup as something a person could be read aloud.\n *\n * Lifted out of `feed.parse.ts` when `article.parse.ts` needed the same two\n * steps, and shared rather than copied for the reason `match.text.ts` gives\n * about its own two: a difference between the copies would have one reader\n * hand a model `&` while the other did not, and nobody would notice until\n * a voice said it on air.\n *\n * Neither function knows anything about feeds or articles. What is here is the\n * part that is true of any publisher's text: tags are not words, entities are\n * not punctuation, and a paragraph's worth of whitespace is one space.\n */\n\n/**\n * The XML five, plus the typography a publisher actually writes, plus numeric\n * escapes.\n *\n * Still not the whole HTML entity table, and deliberately: a named entity this\n * does not know is left exactly as it was, which reads as the publisher's own\n * text rather than as a hole. What earns a row here is what turns up in prose —\n * quotes, dashes, an ellipsis — because those are the ones a voice would\n * otherwise read out as \"and r s q u o\".\n */\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n ndash: '–',\n mdash: '—',\n hellip: '…',\n lsquo: '‘',\n rsquo: '’',\n ldquo: '“',\n rdquo: '”',\n};\n\nexport function decodeEntities(value: string): string {\n return value.replace(/&(#x?[0-9a-f]+|\\w+);/gi, (whole, body: string) => {\n if (body.startsWith('#')) {\n const code = body[1]?.toLowerCase() === 'x' ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;\n }\n\n return NAMED_ENTITIES[body.toLowerCase()] ?? whole;\n });\n}\n\n/**\n * Markup as plain text, capped.\n *\n * Tags out, entities decoded, whitespace collapsed, length bounded.\n *\n * Entities are decoded AFTER the tags come out, and that order matters: an\n * escaped `<script>` would otherwise be turned into a tag by the decode\n * and then survive the strip.\n *\n * @param maxChars - Where to cut. Absent means keep whatever there is, which is\n * what a caller that has already bounded the input wants.\n */\nexport function plainText(raw: string, maxChars?: number): string | undefined {\n const stripped = decodeEntities(raw.replace(/<[^>]*>/g, ' '))\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (stripped.length === 0) return undefined;\n return maxChars === undefined ? stripped : truncateWords(stripped, maxChars);\n}\n\n/**\n * Text cut at a word boundary where there is one nearby, with an ellipsis.\n *\n * A summary that ends mid-word reads as a broken feed rather than as a\n * truncation, which is the difference between a listener hearing a station\n * quoting a publisher and one hearing a station malfunction.\n */\nexport function truncateWords(value: string, maxChars: number): string {\n if (value.length <= maxChars) return value;\n\n const cut = value.slice(0, maxChars);\n const lastSpace = cut.lastIndexOf(' ');\n return `${(lastSpace > maxChars - 40 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;\n}\n\n/**\n * Where one sentence ends and the next begins.\n *\n * A terminator, any closing quote or bracket after it, whitespace, and then\n * something that starts a sentence. The lookahead is what does the work: a\n * full stop followed by a digit or a lowercase letter is an abbreviation or a\n * decimal, not an ending. Measured on a real wire story — \"Saturday, Aug. 15,\n * 2026\" was being read as a complete sentence and a bulletin said it out loud.\n */\nconst SENTENCE_END = /[.!?][\"'”’)\\]]*\\s+(?=[\"'“‘(]?[A-Z0-9])/g;\n\n/**\n * The abbreviations the lookahead cannot catch, because a name follows them and\n * a name is capitalised.\n *\n * Short and deliberately not a gazetteer: every entry here is one that turns up\n * in news copy, and the cost of a miss is one sentence cut early rather than\n * anything false. Matched with the full stop already consumed.\n */\nconst ABBREVIATIONS =\n /\\b(mr|mrs|ms|dr|prof|rev|st|jr|sr|gov|sen|rep|lt|sgt|col|gen|capt|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec|no|vs|approx|est|inc|ltd|co|dept|univ|u\\.s|u\\.k)$/i;\n\n/** Whether the stop at `at` really ends a sentence, or belongs to an abbreviation. */\nconst endsSentence = (value: string, at: number): boolean => !ABBREVIATIONS.test(value.slice(Math.max(0, at - 12), at));\n\n/**\n * The first sentence of a passage, as published, or the whole thing when it is\n * one sentence.\n *\n * Here rather than in a caller because both consumers of prose need it and\n * neither should be splitting sentences by hand: a station reads the opening\n * line of a story aloud, and a truncation cuts at the last one that fits.\n */\nexport function firstSentence(value: string): string {\n const text = value.trim();\n\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(text); match !== null; match = SENTENCE_END.exec(text)) {\n const stop = match.index;\n if (endsSentence(text, stop)) return text.slice(0, stop + 1).trim();\n }\n\n return text;\n}\n\n/**\n * Text cut at the last SENTENCE that fits, with no ellipsis.\n *\n * The other kind of cut, and the one that belongs on anything a voice will read\n * or a model will be told is the story: half a sentence is something to finish,\n * and a model handed one finishes it out of its own head — which is exactly the\n * failure a bulletin cannot have. Falls back to {@link truncateWords} when the\n * first sentence is already longer than the cap, because a hard stop mid-clause\n * is still better than three paragraphs.\n */\nexport function truncateSentences(value: string, maxChars: number): string {\n if (value.length <= maxChars) return value;\n\n let lastStop = -1;\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(value); match !== null; match = SENTENCE_END.exec(value)) {\n if (match.index >= maxChars) break;\n if (endsSentence(value, match.index)) lastStop = match.index;\n }\n\n if (lastStop <= 0) return truncateWords(value, maxChars);\n return value.slice(0, lastStop + 1).trimEnd();\n}\n\n/** How many words a passage is, on the one definition every caller here uses. */\nconst wordsIn = (value: string): number => value.split(/\\s+/).filter(Boolean).length;\n\n/**\n * The closing quotes and brackets a boundary swallowed, so a cut keeps them.\n *\n * {@link SENTENCE_END} matches the terminator, then any closers, then the\n * whitespace before the next sentence. Cutting at the terminator alone leaves\n * the closer at the head of the half that was thrown away, which turns `as\n * \"love.\"` into `as \"love.` — an unbalanced quote in something a voice reads.\n */\nconst closersIn = (boundary: string): string => boundary.slice(1).trimEnd();\n\n/**\n * The longest run of WHOLE sentences within a word count, or nothing.\n *\n * The word-counted sibling of {@link truncateSentences}, and the difference\n * between them is the fallback rather than the unit. That one cuts mid-clause\n * when the first sentence is already too long, because its caller would rather\n * have a hard stop than three paragraphs. This one answers `undefined`, because\n * its caller is choosing between a script and something else it can say\n * instead, and half a sentence read aloud is worse than either.\n *\n * A passage already within the count comes back trimmed and otherwise\n * untouched, so a caller can put this in front of everything rather than\n * branching on the length itself.\n */\nexport function sentencesWithin(value: string, maxWords: number): string | undefined {\n const text = value.trim();\n if (wordsIn(text) <= maxWords) return text;\n\n let fits: string | undefined;\n\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(text); match !== null; match = SENTENCE_END.exec(text)) {\n if (!endsSentence(text, match.index)) continue;\n\n const ending = text.slice(0, match.index + 1 + closersIn(match[0]).length);\n if (wordsIn(ending) > maxWords) break;\n fits = ending;\n }\n\n return fits;\n}\n","import { PluginError } from './plugin.error.js';\nimport { pluginCodeForStatus, retryAfterMs, upstreamDetail } from './plugin.http.js';\nimport { plainText, truncateSentences } from './html.text.js';\nimport type { HostFetchInit, PluginHost } from './plugin.host.js';\n\n/**\n * The story behind a headline, as the paragraphs somebody else published.\n *\n * The sibling of `feed.parse.ts`, here for the same reason and with the same\n * split. A feed is a summary of a publication and frequently not a summary of\n * anything else: measured against the station's own configured feed, every\n * item's `description` was one sentence restating its title and its\n * `content:encoded` was that same sentence wrapped in a `<p>`. So a station\n * that wants to say what HAPPENED has to read the page the entry points at,\n * and every plugin that reads a feed will want this the moment it wants more\n * than titles.\n *\n * ## It extracts and it does not summarise\n *\n * Everything that comes out of here is the publisher's own prose, in the\n * publisher's own order, with the furniture removed. Nothing is rewritten,\n * nothing is joined, and no sentence is composed. That is the same boundary\n * `plugins/wikipedia` keeps by handing the host an article verbatim: only the\n * host can check a claim against the text it came from, and prose that has been\n * through a plugin's own paraphrase is prose nothing can check.\n *\n * The one thing removed that is not furniture is a paragraph announcing itself as an advertisement\n * — see {@link SPONSOR_OPENERS}. It is still a cut rather than a rewrite: what is left is the\n * publisher's own prose, and what went is prose the publisher was paid for.\n *\n * ## Wrong in one direction on purpose\n *\n * A page that cannot be read answers `undefined` rather than a guess. Every\n * caller's fallback is the entry's own words, which is a real answer, whereas\n * navigation copy and cookie banners scraped off a template are noise a voice\n * would read out as news. So the extraction is deliberately conservative: a\n * block that is not clearly a paragraph of prose is dropped, and a page that\n * yields nothing is a page with nothing on it as far as anything here is\n * concerned.\n */\n\n/**\n * How much of an article is kept.\n *\n * Sized to what a bulletin can use rather than to the article: the destination\n * is a model's context beside a persona sheet and a set of content rules, and a\n * writer that only ever reads out one sentence per story does not need three\n * thousand words to find it. Cut on a sentence boundary\n * ({@link truncateSentences}), because half a sentence is something a model\n * finishes out of its own head.\n */\nexport const ARTICLE_MAX_CHARS = 2_000;\n\n/**\n * Shortest a block of text may be and still be taken for a paragraph of prose.\n *\n * The whole filter, and it does most of the work. A page's non-prose blocks are\n * short and its prose is not: bylines, timestamps, share prompts, tags, \"hide\n * caption\", and the cookie line are all under this, and a sentence of reporting\n * is comfortably over it. Every alternative worth having (a readability score, a\n * text-to-link-density ratio) is a bigger thing to be wrong in ways that are\n * harder to see.\n */\nconst MIN_PARAGRAPH_CHARS = 60;\n\n/** Everything whose text is never the story, taken out with its contents. */\nconst FURNITURE = /<(script|style|noscript|template|svg|figure|figcaption|aside|nav|header|footer|form)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi;\n\n/**\n * The same thing again, for publishers who mark furniture with a CLASS rather\n * than with an element.\n *\n * Not optional polish. Measured on a real wire story: the photo caption and its\n * credit sit in `<div class=\"credit-caption\">…<p>…</p>`, which is an ordinary\n * paragraph by every structural test, and the station read \"A view of the rising\n * water levels at the Wainaku Street Bridge in Hilo, Saturday, Aug. 15\" out loud\n * as its first item of news.\n *\n * A heuristic over somebody else's markup, and it is allowed to be one because\n * it can only ever cut: the vocabulary is small, every word in it names\n * something that is furniture on any site that uses the word at all, and a false\n * positive costs one paragraph out of a story that has others. Non-greedy to the\n * first matching close tag, which on nested markup ends EARLY — that is the safe\n * direction, since the text being removed is in the innermost element.\n */\nconst FURNITURE_CLASSES =\n /<(div|section|span|p|ul|ol)\\b[^>]*(?:class|id|aria-label)=\"[^\"]*\\b(caption|credit|byline|promo|newsletter|related|recirc|sidebar|share|social|advert|subscribe|paywall|tags?)\\b[^\"]*\"[^>]*>[\\s\\S]*?<\\/\\1>/gi;\n\n/**\n * The apparatus a reference-heavy page leaves in its own prose.\n *\n * Measured against thirty documents an enrichment walk collected off Wikipedia:\n * twenty-eight carried `[ 1 ]`-style markers and four carried raw `{{cite web\n * |title=… |url=… |publisher=[[BBC]] |access-date=…}}` templates, 2.1% of every\n * character stored. Both survive {@link FURNITURE} because neither is an\n * element — the markers are text a renderer put inside the paragraph, and the\n * templates are source that reached the page unrendered.\n *\n * This is the one kind of noise where the paragraph-length filter is no help,\n * because a citation template is long. It is also the kind that matters most:\n * what reads these is a claim extractor that checks a quoted span really occurs\n * in the text, so a span quoting `{{cite web |title=How to say: Bowie` passes\n * that check and can be read out on air. The file header's promise to remove\n * \"reference markers\" was not implemented until this existed.\n *\n * Deliberately narrow. A bracketed NUMBER and a small closed vocabulary, never a\n * bracketed word in general: `[sic]` is the author's and belongs in a quotation.\n *\n * Each entry carries its own replacement, which is not ceremony: three of these\n * delete and one KEEPS what it matched. A shared `'$1'` would have written the\n * literal characters `$1` into the prose for every pattern with no group, which\n * is the same class of bug as the markup being removed.\n */\nconst REFERENCE_APPARATUS: readonly (readonly [RegExp, string])[] = [\n // `{{cite web |…}}`, `{{efn|…}}`. One level and non-nesting, which is the\n // safe direction: a nested template leaves its outer braces behind as two\n // characters rather than eating the sentence after it.\n [/\\{\\{[^{}]*\\}\\}/g, ''],\n // `[ 1 ]`, `[12]`.\n [/\\[\\s*\\d+\\s*\\]/g, ''],\n // `[ citation needed ]`, `[ edit ]`.\n [/\\[\\s*(?:citation needed|clarification needed|edit|note \\d+|update)\\s*\\]/gi, ''],\n // `[[BBC]]` and `[[David Bowie|Bowie]]`: the only one that keeps its match,\n // because what is inside is the words a reader sees rather than apparatus.\n [/\\[\\[(?:[^[\\]|]*\\|)?([^[\\]|]*)\\]\\]/g, '$1'],\n];\n\n/**\n * How a paragraph announces that it is somebody's advertisement.\n *\n * A newsletter carries its sponsor inside its own prose rather than in a block anything structural\n * can find: no class, no element, just a paragraph that opens \"A message from …\" or \"Presented by\n * …\" between two paragraphs of reporting. {@link FURNITURE_CLASSES} cannot see it, the length\n * filter passes it, and what a bulletin then reads out is an advertisement in the voice it has just\n * established as the one that reports facts. Measured on the station's own feeds: an aired bulletin\n * opened with a shop's discount code and the affiliate disclosure that came with it.\n *\n * Anchored at the START and nowhere else, which is the whole safety property. \"The campaign was\n * paid for by donors\" is reporting and is left alone; a paragraph that BEGINS \"Paid for by\" is the\n * disclosure itself. The vocabulary is closed and every entry names something that is an\n * advertisement wherever it appears, so this can only ever cut, which is {@link FURNITURE_CLASSES}'\n * argument one level down — and a false positive costs one paragraph of a story that has others.\n */\nconst SPONSOR_OPENERS: readonly string[] = [\n 'a message from',\n 'a note from our sponsor',\n 'advertisement',\n 'advertorial',\n 'paid content',\n 'paid for by',\n 'paid partnership',\n 'presented by',\n 'promoted by',\n 'sponsored',\n 'sponsored by',\n 'sponsored content',\n 'sponsored survey',\n 'support for this',\n 'support comes from',\n 'this post is sponsored',\n];\n\n/**\n * Whether a paragraph opens by announcing itself as an advertisement.\n *\n * The opener has to be FOLLOWED by a separator rather than merely be a prefix, or \"Sponsored\" would\n * match \"Sponsorship deals collapsed\" and \"Advertisement\" would match \"Advertisements for the\n * scheme ran for a month\" — both of which are stories about advertising rather than advertisements.\n * A colon, a dash, a space or the end of the paragraph are what an announcement actually looks like.\n */\nfunction opensWithASponsor(text: string): boolean {\n const said = text.toLowerCase();\n\n return SPONSOR_OPENERS.some(opener => {\n if (!said.startsWith(opener)) return false;\n\n const next = said.charAt(opener.length);\n return next === '' || /[\\s:;,.\\u2013\\u2014-]/.test(next);\n });\n}\n\n/** The containers a publisher marks the story with, in the order they are worth trusting. */\nconst CONTAINERS = [/<article\\b[^>]*>([\\s\\S]*?)<\\/article>/i, /<main\\b[^>]*>([\\s\\S]*?)<\\/main>/i];\n\n/** A paragraph, which is the only block this reads. See {@link MIN_PARAGRAPH_CHARS}. */\nconst PARAGRAPH = /<p\\b[^>]*>([\\s\\S]*?)<\\/p>/gi;\n\n/**\n * An article page as plain text, or `undefined` when it does not carry one.\n *\n * Pure, and that is what makes it testable against saved pages with no host in\n * the way — `parseFeed`'s split, for `parseFeed`'s reason.\n *\n * @param html - The page, as served.\n * @param maxChars - Where to cut. See {@link ARTICLE_MAX_CHARS}.\n */\nexport function extractArticle(html: string, maxChars: number = ARTICLE_MAX_CHARS): string | undefined {\n // Furniture first, and before the container is picked: a `<figure>` inside\n // the article is exactly the case this is for, and its caption is a full\n // sentence that would otherwise pass every test below.\n const cleaned = html.replace(FURNITURE, ' ').replace(FURNITURE_CLASSES, ' ');\n\n // A publisher that marked the story is trusted about where it is. One that\n // did not gets the whole document, which is safe only because the paragraph\n // filter is what actually decides — a template's own blocks do not survive\n // it.\n const body = CONTAINERS.map(pattern => pattern.exec(cleaned)?.[1]).find(found => found !== undefined) ?? cleaned;\n\n const paragraphs: string[] = [];\n for (const match of body.matchAll(PARAGRAPH)) {\n // Apparatus BEFORE the length test, so a paragraph that is mostly\n // citation is measured on the prose it actually has left. After\n // `plainText`, because a template can carry a `<` in an attribute and\n // stripping tags first is what makes the braces the outermost thing.\n const text = withoutApparatus(plainText(match[1] ?? ''));\n if (text === undefined || text.length < MIN_PARAGRAPH_CHARS) continue;\n\n // After the length test rather than before it, because this is the more expensive check and\n // most of what it would run over has already been dropped for being too short to be prose.\n if (opensWithASponsor(text)) continue;\n\n // A page that repeats its own standfirst inside the body is ordinary,\n // and reading it twice is not.\n if (!paragraphs.includes(text)) paragraphs.push(text);\n }\n\n if (paragraphs.length === 0) return undefined;\n return truncateSentences(paragraphs.join(' '), maxChars);\n}\n\n/**\n * A paragraph with its {@link REFERENCE_APPARATUS} taken out, or `undefined`\n * when there was nothing else in it.\n *\n * The whitespace pass afterwards is not tidiness: removing `[ 1 ]` from\n * `the album [ 1 ] sold` leaves two spaces, and removing a template that stood\n * alone leaves a paragraph of spaces that would otherwise be measured as\n * sixty characters of prose. The space before a comma or a full stop is the\n * same problem one punctuation mark along, and it is what a voice would read\n * as a pause in the wrong place.\n */\nfunction withoutApparatus(text: string | undefined): string | undefined {\n if (text === undefined) return undefined;\n\n let stripped = text;\n for (const [pattern, replacement] of REFERENCE_APPARATUS) stripped = stripped.replace(pattern, replacement);\n\n const tidied = stripped\n .replace(/\\s+/g, ' ')\n .replace(/\\s+([,.;:!?])/g, '$1')\n .trim();\n\n return tidied.length === 0 ? undefined : tidied;\n}\n\n/**\n * An article off the network, as plain text.\n *\n * Everything about the request except the status check is `host.fetch`'s, and\n * the status ladder is `plugin.http.ts`'s unmodified: `fetchFeed`'s shape, for\n * `fetchFeed`'s reasons.\n *\n * The one thing this adds is the content-type check, which is not fussiness. An\n * entry legitimately links to a PDF, an audio file or a video page, and a\n * megabyte of binary put through a tag stripper produces a long string of\n * plausible-looking rubbish rather than an error — which a bulletin would then\n * read out.\n *\n * `options.maxChars` is separate from `init` because the two are addressed to\n * different parties: everything in `init` is the host's business and this is the\n * parser's. The default is {@link ARTICLE_MAX_CHARS}, which is sized for a\n * bulletin — a writer that reads one sentence per story out of a model's context\n * has no use for three thousand words. A plugin contributing an enrichment\n * `SourceDocument` wants far more of the page, because what reads that is a\n * claim extractor rather than a presenter.\n */\nexport async function fetchArticle(\n host: PluginHost,\n url: string,\n init?: HostFetchInit,\n options?: { maxChars?: number },\n): Promise<string | undefined> {\n const response = await host.fetch(url, init);\n\n if (!response.ok) {\n // The body is an error page nobody wants quoted, and reading it costs a\n // round trip against the same budget a retry would want.\n await response.body?.cancel();\n\n const error = new PluginError(`article request failed: ${upstreamDetail(response.status, response.statusText)}`).withCode(\n pluginCodeForStatus(response.status),\n );\n error.upstreamStatus = response.status;\n\n const advice = retryAfterMs(response.headers.get('retry-after'));\n if (advice !== undefined) error.withRetry(advice);\n\n throw error;\n }\n\n const contentType = response.headers.get('content-type') ?? '';\n if (!/\\b(text\\/html|application\\/xhtml\\+xml)\\b/i.test(contentType)) {\n await response.body?.cancel();\n return undefined;\n }\n\n return extractArticle(await response.text(), options?.maxChars);\n}\n","import type { ChartsProvider } from './capabilities/charts.js';\nimport type { EnrichmentProvider } from './capabilities/enrichment.js';\nimport type { MusicProviderCatalog, MusicProviderOAuth, MusicProviderSteer, MusicProviderStream } from './capabilities/music.provider.js';\nimport type { NewsProvider } from './capabilities/news.js';\nimport type { PodcastProvider } from './capabilities/podcast.js';\nimport type { ScrobbleProvider } from './capabilities/scrobble.js';\nimport type { SearchProvider } from './capabilities/search.js';\nimport type { SimilarityProvider } from './capabilities/similarity.js';\nimport type { WeatherProvider } from './capabilities/weather.js';\nimport type { PluginManifest } from './plugin.manifest.js';\nimport type { PluginLifecycle } from './plugin.lifecycle.js';\n\n/**\n * A plugin instance: the lifecycle, plus whatever capability interfaces the\n * plugin declared in `manifest.capabilities`.\n */\nexport type PluginInstance = PluginLifecycle;\n\n/**\n * Builds a fresh plugin instance. The host calls this once per configured\n * installation, then calls `init(host)` on the result. Do no I/O here: keep\n * the constructor cheap and put setup in `init`.\n */\nexport type PluginFactory<TInstance extends PluginInstance = PluginInstance> = () => TInstance;\n\n/** What a plugin package default-exports. */\nexport interface DeadairPlugin<TInstance extends PluginInstance = PluginInstance> {\n manifest: PluginManifest;\n factory: PluginFactory<TInstance>;\n}\n\n/**\n * Instance shape for a `music-provider` plugin. Every capability method is\n * optional: implement the subset you declared in `manifest.capabilities`.\n */\nexport type MusicProviderPluginInstance = PluginLifecycle &\n Partial<MusicProviderCatalog> &\n Partial<MusicProviderStream> &\n Partial<MusicProviderSteer> &\n Partial<MusicProviderOAuth>;\n\n/** Instance shape for an `enrichment` plugin. */\nexport type EnrichmentPluginInstance = PluginLifecycle & EnrichmentProvider;\n\n/** Instance shape for a `charts` plugin. */\nexport type ChartsPluginInstance = PluginLifecycle & ChartsProvider;\n\n/** Instance shape for a `news` plugin. */\nexport type NewsPluginInstance = PluginLifecycle & NewsProvider;\n\n/** Instance shape for a `podcast` plugin. `searchShows` stays optional, as it is on the capability. */\nexport type PodcastPluginInstance = PluginLifecycle & PodcastProvider;\n\n/** Instance shape for a `similarity` plugin. */\nexport type SimilarityPluginInstance = PluginLifecycle & SimilarityProvider;\n\n/** Instance shape for a `search` plugin. */\nexport type SearchPluginInstance = PluginLifecycle & SearchProvider;\n\n/** Instance shape for a `weather` plugin. */\nexport type WeatherPluginInstance = PluginLifecycle & WeatherProvider;\n\n/** Instance shape for a `scrobble` plugin. */\nexport type ScrobblePluginInstance = PluginLifecycle & ScrobbleProvider;\n\n/**\n * Pairs a manifest with its factory and returns the object a plugin package\n * default-exports. Purely a typing helper: it does no validation, because the\n * host validates the manifest with `pluginManifestSchema` at load time.\n *\n * ```ts\n * export default definePlugin(manifest, () => new MyPlugin());\n * ```\n */\nexport function definePlugin<TInstance extends PluginInstance>(\n manifest: PluginManifest,\n factory: PluginFactory<TInstance>,\n): DeadairPlugin<TInstance> {\n return { manifest, factory };\n}\n","import { XMLParser } from 'fast-xml-parser';\nimport { plainText as asPlainText } from './html.text.js';\nimport { PluginError } from './plugin.error.js';\nimport { pluginCodeForStatus, retryAfterMs, upstreamDetail } from './plugin.http.js';\nimport type { HostFetchInit, PluginHost } from './plugin.host.js';\n\n/**\n * A syndicated feed, as one shape, whichever of the three formats it arrived in.\n *\n * The sibling of `plugin.http.ts`, and here for the same reason: every plugin\n * that reads a feed writes the same normalisation, and none of it is that\n * plugin's opinion. RSS 2.0 dates an item with `pubDate` in RFC-822, Atom uses\n * `published` in ISO-8601 and RSS 1.0 uses `dc:date`; a link is an element's\n * text in two of them and an attribute in the third; a title is a bare string\n * until somebody sets `type=\"html\"` on it and it becomes an object. None of\n * that is worth learning twice.\n *\n * ## It parses and it does not fetch\n *\n * {@link parseFeed} takes a string. That is what makes it testable against real\n * documents with no host in the way, and it is the half other plugins will\n * actually reuse. {@link fetchFeed} is the thin convenience on top, and it is\n * thin deliberately: `host.fetch` already carries the allowlist, the pacing and\n * the `Retry-After` back-off, so there is nothing left here but a status check.\n *\n * Caching and conditional GET (`ETag`, `Last-Modified`) are NOT here. They\n * belong to whoever is polling — a plugin knows how often its own feeds are\n * worth asking and this file does not — and putting a cache behind a pure\n * parser would hide it from the one caller that must not be surprised by it.\n *\n * ## A podcast is a feed with an attachment\n *\n * A podcast feed is RSS 2.0 with the iTunes namespace on top, and the part a\n * station needs from it is exactly the part a news reader throws away: the\n * `enclosure`, which is the audio, and `itunes:duration`, which is how long it\n * runs. Both are read here onto {@link FeedItem.enclosure} and\n * {@link FeedItem.durationMs}, and the channel's own description, artwork and\n * language onto {@link ParsedFeed}, because a second plugin reading a podcast\n * would otherwise write the same namespace handling again. Every one of those\n * fields is optional, so a reader that wants none of them (`plugins/rss`) reads\n * exactly what it read before.\n *\n * What an enclosure is NOT is a link. {@link FeedItem.url} is still only ever\n * the page a person reads, and the audio never arrives there: a news reader\n * that followed an enclosure as though it were the story would fetch a\n * sixty-megabyte file to look for paragraphs in it.\n *\n * ## Tolerant in one specific direction\n *\n * A feed is somebody else's file, served by somebody else's edge, and the ways\n * it goes wrong are not interesting: a truncated body, an HTML error page with\n * a 200 on it, an item with no title. So a document that will not parse answers\n * with no items rather than throwing, and an item missing the one field that\n * makes it an item is dropped rather than guessed at. What is NOT tolerated is\n * a bad status, because that is the upstream saying so itself.\n */\n\n/** One entry, whatever the feed called it. */\nexport interface FeedItem {\n /**\n * A stable identifier for this entry, across polls and across restarts.\n *\n * The load-bearing field, and the reason it is never absent. Anything that\n * polls a feed has to tell an arrival from something it has already seen,\n * and the only alternative to an id is comparing timestamps — which fails\n * on a publisher that back-dates, re-dates or omits them.\n *\n * Taken from `guid`, then Atom's `id`, then the link, and only then a hash\n * of the title and date. The fallback is what makes the guarantee hold for\n * a feed that supplies none of the three, and it is a hash rather than an\n * index because a position in a list changes every time the list does.\n */\n id: string;\n title: string;\n /** The entry's own words, as plain text. See {@link FEED_SUMMARY_MAX_CHARS}. */\n summary?: string;\n url?: string;\n /** ISO-8601. Never a `Date`, and absent when the feed gave no readable one. */\n publishedAt?: string;\n author?: string;\n categories?: string[];\n /**\n * The file attached to this entry, which for a podcast is the episode\n * itself. See {@link FeedEnclosure}.\n *\n * Absent for an entry that attaches nothing, which is every entry of an\n * ordinary news feed, and for one whose attachment is not at an http(s)\n * address somebody could fetch.\n */\n enclosure?: FeedEnclosure;\n /**\n * How long the attachment runs, in whole milliseconds, as the PUBLISHER\n * says: `itunes:duration`, written as `HH:MM:SS`, `MM:SS` or bare seconds.\n *\n * A claim rather than a measurement, and absent where the feed made none\n * or made one this cannot read. Nothing here guesses one from the\n * enclosure's size, because a byte count divided by a bitrate nobody\n * stated is a number that looks measured and is not.\n */\n durationMs?: number;\n /** This entry's own artwork, where it has some (`itunes:image`). Always http(s). */\n imageUrl?: string;\n /**\n * Whether the publisher marked this entry explicit (`itunes:explicit`).\n *\n * Absent means the feed did not say, which is not the same as clean: a\n * reader enforcing a clean-only policy has to demand a positive `false`,\n * on `ProviderTrack.advisory`'s argument.\n */\n explicit?: boolean;\n /** `itunes:season`, a positive whole number, when the publisher numbers them. */\n season?: number;\n /** `itunes:episode`, a positive whole number, when the publisher numbers them. */\n episode?: number;\n}\n\n/**\n * A file attached to an entry: RSS 2.0's `<enclosure>` or an Atom\n * `<link rel=\"enclosure\">`.\n *\n * Every part except the address is the publisher's claim and is passed on as\n * one. `type` in particular is whatever the publisher's CMS wrote, and\n * `audio/x-m4a`, `audio/mp3` and an empty string are all ordinary: a reader\n * deciding what it can play reads this and the address's extension together.\n */\nexport interface FeedEnclosure {\n /** Always http(s). An enclosure at any other address is not reported at all. */\n url: string;\n /** The declared media type, lower-cased, e.g. `audio/mpeg`. */\n type?: string;\n /**\n * The declared size in bytes. Absent when the feed wrote nothing, zero, or\n * something that is not a whole number, all three of which are common: a\n * great many feeds write `length=\"0\"` because the element requires the\n * attribute and the publisher did not know.\n */\n lengthBytes?: number;\n}\n\n/** A feed, and what it holds. */\nexport interface ParsedFeed {\n title?: string;\n /** Where the publication itself lives, as opposed to any one entry. */\n homeUrl?: string;\n /** What the publication says about itself, as plain text. See {@link FEED_SUMMARY_MAX_CHARS}. */\n description?: string;\n /** Who publishes it: `itunes:author`, or an Atom feed's own `author`. */\n author?: string;\n /** The publication's artwork: `itunes:image`, or RSS 2.0's `<image><url>`. Always http(s). */\n imageUrl?: string;\n /** The language the publication declares, as written (`en`, `en-us`). */\n language?: string;\n /** The publication's own labels, including iTunes categories. Deduplicated, order kept. */\n categories?: string[];\n /** Whether the publisher marked the whole publication explicit. See {@link FeedItem.explicit}. */\n explicit?: boolean;\n /** Newest first where the feed said, and otherwise in the order it listed them. */\n items: FeedItem[];\n}\n\n/**\n * How much of an entry's own words are kept.\n *\n * A description is written for a browser and its destination here is a model's\n * context and possibly a voice, neither of which wants three paragraphs of a\n * press release. Generous enough to hold a real first paragraph, short enough\n * that twenty of them still leave room to think.\n */\nexport const FEED_SUMMARY_MAX_CHARS = 500;\n\n/**\n * `removeNSPrefix` is what makes `dc:date`, `content:encoded` and `rdf:RDF`\n * readable without a namespace table, and the cost of it is that a feed\n * carrying both `link` and `atom:link` collapses them — which every reader\n * below already handles, because a repeated element arrives as an array.\n *\n * Module-level because it is stateless and building one per document would be\n * the most expensive part of parsing a small feed.\n */\nconst parser = new XMLParser({\n ignoreAttributes: false,\n attributeNamePrefix: '@_',\n removeNSPrefix: true,\n trimValues: true,\n // A guid of `12345` is an id, not a number, and a `<title>2024</title>` is a\n // title. Every field here is read as text, so parsing any of it as a number\n // only produces a value whose `.trim` is missing.\n parseTagValue: false,\n parseAttributeValue: false,\n});\n\n/**\n * A feed document, read.\n *\n * Answers with an empty feed rather than throwing, for anything that is not one\n * — a parse failure, an HTML page, an empty body. The caller's next move is the\n * same in every case, and a plugin fanning out over five feeds must not lose\n * four of them to the fifth.\n */\nexport function parseFeed(xml: string): ParsedFeed {\n let document: unknown;\n try {\n document = parser.parse(xml);\n } catch {\n return { items: [] };\n }\n\n if (!isRecord(document)) return { items: [] };\n\n // RSS 2.0, Atom, RSS 1.0/RDF. The `channel` of an RDF document is a sibling\n // of its items rather than their parent, which is the one structural\n // difference between the three and the reason `items` is looked up in both\n // places rather than only under the channel.\n const rss = record(document.rss);\n const rdf = record(document.RDF);\n const atom = record(document.feed);\n const channel = record(rss?.channel) ?? record(rdf?.channel) ?? atom;\n\n if (channel === undefined) return { items: [] };\n\n const entries = [...asArray(record(rss?.channel)?.item), ...asArray(rdf?.item), ...asArray(atom?.entry)];\n\n const feed: ParsedFeed = { items: entries.flatMap(entry => (isRecord(entry) ? (readItem(entry) ?? []) : [])) };\n\n const title = speakable(channel.title);\n if (title !== undefined) feed.title = title;\n\n const homeUrl = readLink(channel.link);\n if (homeUrl !== undefined) feed.homeUrl = homeUrl;\n\n // `itunes:summary` arrives as `summary` once the prefix is gone, and is the\n // longer of the two where a podcast carries both; `subtitle` is the last\n // resort, being a line rather than a description.\n const description = plainText(channel.description ?? channel.summary ?? channel.subtitle);\n if (description !== undefined) feed.description = description;\n\n const author = readAuthor(channel.author);\n if (author !== undefined) feed.author = author;\n\n const imageUrl = readImage(channel.image);\n if (imageUrl !== undefined) feed.imageUrl = imageUrl;\n\n const language = text(channel.language);\n if (language !== undefined) feed.language = language;\n\n const categories = readCategories(channel.category);\n if (categories.length > 0) feed.categories = categories;\n\n const explicit = readExplicit(channel.explicit);\n if (explicit !== undefined) feed.explicit = explicit;\n\n return feed;\n}\n\n/**\n * A feed off the network.\n *\n * Everything about the request except the status check is `host.fetch`'s: the\n * allowlist, the pacing, the redirect re-checks, the body caps. What is left is\n * the one decision a plugin should not be making differently from its\n * neighbours — which {@link PluginErrorCode} a status means — and that is\n * `plugin.http.ts`'s ladder, unmodified. A service with its own reading of a\n * status layers that in front by calling {@link parseFeed} itself.\n */\nexport async function fetchFeed(host: PluginHost, url: string, init?: HostFetchInit): Promise<ParsedFeed> {\n const response = await host.fetch(url, init);\n\n if (!response.ok) {\n // The body is an error page nobody wants quoted, and reading it costs a\n // round trip against the same budget the retry would want.\n await response.body?.cancel();\n\n const error = new PluginError(`feed request failed: ${upstreamDetail(response.status, response.statusText)}`).withCode(\n pluginCodeForStatus(response.status),\n );\n error.upstreamStatus = response.status;\n\n const advice = retryAfterMs(response.headers.get('retry-after'));\n if (advice !== undefined) error.withRetry(advice);\n\n throw error;\n }\n\n return parseFeed(await response.text());\n}\n\n/**\n * One entry, or nothing when it is not one.\n *\n * A title is the only field an entry cannot be without: everything downstream\n * either names it or reads it aloud, and an entry with no title is one that\n * would air as a pause. Every other field is genuinely optional, including the\n * link, because plenty of feeds carry announcements that point nowhere.\n */\nfunction readItem(entry: Record<string, unknown>): FeedItem | undefined {\n const title = speakable(entry.title);\n if (title === undefined) return undefined;\n\n const url = readLink(entry.link);\n const publishedAt = readDate(entry.pubDate ?? entry.published ?? entry.date ?? entry.updated ?? entry.issued);\n // `content:encoded` is the full article and `description` is usually the\n // teaser, so the teaser is preferred and the article is the fallback: this\n // is a summary field and truncating an article into one produces half a\n // sentence where a publisher had already written a whole one.\n const summary = plainText(entry.description ?? entry.summary ?? entry.encoded ?? entry.content);\n const author = readAuthor(entry.author ?? entry.creator);\n const categories = readCategories(entry.category);\n\n const item: FeedItem = { id: readId(entry, title, publishedAt, url), title };\n\n if (summary !== undefined) item.summary = summary;\n if (url !== undefined) item.url = url;\n if (publishedAt !== undefined) item.publishedAt = publishedAt;\n if (author !== undefined) item.author = author;\n if (categories.length > 0) item.categories = categories;\n\n const enclosure = readEnclosure(entry.enclosure) ?? readEnclosure(entry.link);\n if (enclosure !== undefined) item.enclosure = enclosure;\n\n const durationMs = readDuration(entry.duration);\n if (durationMs !== undefined) item.durationMs = durationMs;\n\n const imageUrl = readImage(entry.image);\n if (imageUrl !== undefined) item.imageUrl = imageUrl;\n\n const explicit = readExplicit(entry.explicit);\n if (explicit !== undefined) item.explicit = explicit;\n\n const season = readOrdinal(entry.season);\n if (season !== undefined) item.season = season;\n\n const episode = readOrdinal(entry.episode);\n if (episode !== undefined) item.episode = episode;\n\n return item;\n}\n\n/** See {@link FeedItem.id} for why the ladder is in this order and why it ends in a hash. */\nfunction readId(entry: Record<string, unknown>, title: string, publishedAt: string | undefined, url: string | undefined): string {\n return text(entry.guid) ?? text(entry.id) ?? url ?? `hash:${hash(`${title}\\u0000${publishedAt ?? ''}`)}`;\n}\n\n/**\n * A link, from either of the two places a feed puts one.\n *\n * RSS writes the URL as the element's text; Atom writes it as an `href`\n * attribute and may write several, distinguished by `rel`. `alternate` (or an\n * absent `rel`, which means `alternate`) is the human-readable page, which is\n * the only one anything here wants — `self` is the feed itself and `enclosure`\n * is an attachment, and returning either would have a reader link a listener\n * back to the XML.\n */\nfunction readLink(value: unknown): string | undefined {\n const candidates = asArray(value);\n\n for (const candidate of candidates) {\n if (typeof candidate === 'string') return trimmed(candidate);\n if (!isRecord(candidate)) continue;\n\n const rel = text(candidate['@_rel']);\n if (rel !== undefined && rel !== 'alternate') continue;\n\n const href = text(candidate['@_href']) ?? text(candidate['#text']);\n if (href !== undefined) return href;\n }\n\n return undefined;\n}\n\n/**\n * A date as ISO-8601, or nothing.\n *\n * `Date` parses RFC-822 (RSS) and ISO-8601 (Atom) alike, which is the whole\n * reason this is three lines rather than a format table. Anything it cannot\n * read answers `undefined` rather than a guess or an epoch: \"when this was\n * published is unknown\" is a fact a reader can act on, and 1970 is not.\n */\nfunction readDate(value: unknown): string | undefined {\n const raw = text(value);\n if (raw === undefined) return undefined;\n\n const at = new Date(raw);\n return Number.isNaN(at.getTime()) ? undefined : at.toISOString();\n}\n\n/** RSS writes a name or an address; Atom nests a `name` inside an `author` element. */\nfunction readAuthor(value: unknown): string | undefined {\n const first = asArray(value)[0];\n if (typeof first === 'string') return trimmed(first);\n if (!isRecord(first)) return undefined;\n\n return text(first.name) ?? text(first['#text']);\n}\n\n/**\n * RSS writes a category as text, Atom as a `term` attribute, and iTunes as a\n * `text` attribute with its subcategories nested inside it. Deduplicated,\n * order kept, parents before their children.\n */\nfunction readCategories(value: unknown): string[] {\n const seen = new Set<string>();\n\n const visit = (candidates: unknown[]): void => {\n for (const candidate of candidates) {\n const name =\n typeof candidate === 'string'\n ? trimmed(candidate)\n : isRecord(candidate)\n ? (text(candidate['@_term']) ?? text(candidate['@_text']) ?? text(candidate['#text']))\n : undefined;\n if (name !== undefined) seen.add(name);\n if (isRecord(candidate)) visit(asArray(candidate.category));\n }\n };\n\n visit(asArray(value));\n return [...seen];\n}\n\n/**\n * The first attachment that is at a fetchable address, from either place a\n * feed writes one.\n *\n * RSS 2.0 permits one `<enclosure>` per item and a good number of feeds write\n * several anyway; Atom writes any number of `<link rel=\"enclosure\">` beside the\n * page link. Both arrive as a list here, and an AUDIO attachment is preferred\n * over whatever came first, because a podcast that also attaches its cover as\n * a second enclosure is ordinary and the cover is not the episode.\n *\n * Read off `link` as well as `enclosure`, and only ever the entries whose\n * `rel` says enclosure: an Atom link with no `rel` is the page, which\n * `readLink` answers and this must not.\n */\nfunction readEnclosure(value: unknown): FeedEnclosure | undefined {\n const found: FeedEnclosure[] = [];\n\n for (const candidate of asArray(value)) {\n if (!isRecord(candidate)) continue;\n\n // An RSS `<enclosure>` has no `rel`; an Atom link has to say it is one.\n const rel = text(candidate['@_rel']);\n const isAtomLink = candidate['@_href'] !== undefined;\n if (isAtomLink && rel !== 'enclosure') continue;\n\n const url = webAddress(text(candidate['@_url']) ?? text(candidate['@_href']));\n if (url === undefined) continue;\n\n const enclosure: FeedEnclosure = { url };\n const type = text(candidate['@_type'])?.toLowerCase();\n if (type !== undefined) enclosure.type = type;\n const lengthBytes = positiveWhole(text(candidate['@_length']));\n if (lengthBytes !== undefined) enclosure.lengthBytes = lengthBytes;\n\n found.push(enclosure);\n }\n\n return found.find(enclosure => enclosure.type?.startsWith('audio/') === true) ?? found[0];\n}\n\n/**\n * `itunes:duration` as whole milliseconds, or nothing.\n *\n * Three spellings are in the wild and all three are read: `HH:MM:SS`, `MM:SS`,\n * and bare seconds, any of them with a fraction on the last part. Anything\n * else — `1h 2m`, `about an hour`, an empty element — answers `undefined`\n * rather than a guess, on {@link readDate}'s argument: an unknown length is a\n * fact a reader can act on and a wrong one is not.\n *\n * A duration of zero is absent too. Publishers write `0` and `00:00:00` when\n * their CMS did not know, and a zero-length episode is not a thing anything\n * should plan around.\n */\nfunction readDuration(value: unknown): number | undefined {\n const raw = text(asArray(value)[0]);\n if (raw === undefined) return undefined;\n\n const parts = raw.split(':').map(part => part.trim());\n if (parts.length > 3) return undefined;\n if (!parts.every((part, at) => (at === parts.length - 1 ? /^\\d+(\\.\\d+)?$/ : /^\\d+$/).test(part))) return undefined;\n\n // Only the LEADING unit may run past 59: `90:00` is an ordinary way to write an hour and a\n // half, where `1:90:00` and `12:75` are typos, and reading either would be a guess.\n if (parts.slice(1).some(part => Number(part) >= 60)) return undefined;\n\n const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);\n const ms = Math.round(seconds * 1_000);\n return Number.isFinite(ms) && ms > 0 ? ms : undefined;\n}\n\n/**\n * Artwork, from either of the two shapes a feed writes it in.\n *\n * `itunes:image` is an `href` attribute; RSS 2.0's own `<image>` nests a\n * `<url>`. A podcast channel commonly carries both, which arrive together as\n * a list once the prefix is gone, and the iTunes one is preferred: it is the\n * square the directories show, where RSS's is a small banner.\n */\nfunction readImage(value: unknown): string | undefined {\n const candidates = asArray(value);\n\n for (const candidate of candidates) {\n if (isRecord(candidate)) {\n const href = webAddress(text(candidate['@_href']));\n if (href !== undefined) return href;\n }\n }\n\n for (const candidate of candidates) {\n const url = isRecord(candidate) ? webAddress(text(candidate.url)) : webAddress(text(candidate));\n if (url !== undefined) return url;\n }\n\n return undefined;\n}\n\n/**\n * `itunes:explicit`, which has been spelled four ways across the spec's\n * history. Anything else is absent rather than either answer.\n */\nfunction readExplicit(value: unknown): boolean | undefined {\n const raw = text(asArray(value)[0])?.toLowerCase();\n if (raw === 'yes' || raw === 'true' || raw === 'explicit') return true;\n if (raw === 'no' || raw === 'false' || raw === 'clean') return false;\n return undefined;\n}\n\n/** A season or episode number: a positive whole number, or nothing. */\nconst readOrdinal = (value: unknown): number | undefined => positiveWhole(text(asArray(value)[0]));\n\n/** A positive whole number written as text, or nothing. */\nfunction positiveWhole(raw: string | undefined): number | undefined {\n if (raw === undefined || !/^\\d+$/.test(raw)) return undefined;\n const parsed = Number(raw);\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n\n/**\n * An address somebody could fetch, or nothing.\n *\n * Art and audio are both handed on to be fetched by something that is not\n * this plugin, and the SDK's rule for an art URL is that it is http(s) only;\n * the same rule is applied to the audio for the same reason. `file:`,\n * `data:` and a relative path are all things a feed can contain.\n */\nfunction webAddress(raw: string | undefined): string | undefined {\n if (raw === undefined) return undefined;\n try {\n const { protocol } = new URL(raw);\n return protocol === 'http:' || protocol === 'https:' ? raw : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * An element's markup as something a person could be read aloud.\n *\n * The stripping, decoding and capping are `html.text.ts`'s, shared with the\n * article reader; what is left here is reaching the element's text first, which\n * is this file's own problem. See that file for why the decode runs after the\n * strip and not before.\n */\nfunction plainText(value: unknown): string | undefined {\n const raw = text(value);\n return raw === undefined ? undefined : asPlainText(raw, FEED_SUMMARY_MAX_CHARS);\n}\n\n/**\n * FNV-1a, as hex.\n *\n * Not a cryptographic hash and not asked to be one: it identifies an entry\n * within one feed, where a collision costs one repeated headline. Written out\n * rather than taken from `node:crypto` so this file stays a pure string\n * function with no platform import, which is what lets a plugin call it from\n * anywhere.\n */\nfunction hash(value: string): string {\n let accumulated = 0x811c9dc5;\n\n for (let at = 0; at < value.length; at += 1) {\n accumulated ^= value.charCodeAt(at);\n accumulated = Math.imul(accumulated, 0x01000193);\n }\n\n return (accumulated >>> 0).toString(16).padStart(8, '0');\n}\n\n/** A repeated element arrives as an array and a single one does not. This is that, flattened. */\nfunction asArray(value: unknown): unknown[] {\n if (value === undefined || value === null) return [];\n return Array.isArray(value) ? value : [value];\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst record = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);\n\nconst trimmed = (value: string): string | undefined => (value.trim().length === 0 ? undefined : value.trim());\n\n/**\n * An element's text, wherever the parser put it.\n *\n * A field is a bare string until it carries an attribute, at which point it\n * becomes `{ '#text': …, '@_type': … }`. Reading only the first shape is the\n * single easiest way to lose a title, because `type=\"html\"` on one is entirely\n * ordinary.\n *\n * And it is an ARRAY when the element is repeated, which `removeNSPrefix`\n * makes far commoner than any feed intends: a podcast writes both `<title>`\n * and `<itunes:title>`, and with the prefix gone those are two `title`s. The\n * first readable one is the answer, which is the plain RSS element wherever a\n * publisher wrote both in the usual order. Before this, every entry of such a\n * feed was dropped as having no title at all — measured on NPR's Planet Money\n * feed, 355 entries and none of them read.\n */\nfunction text(value: unknown): string | undefined {\n if (typeof value === 'string') return trimmed(value);\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (Array.isArray(value)) {\n for (const candidate of value) {\n const found = text(candidate);\n if (found !== undefined) return found;\n }\n return undefined;\n }\n if (isRecord(value)) return text(value['#text']);\n\n return undefined;\n}\n\n/**\n * A TITLE, which is text a voice will say rather than a field something matches on.\n *\n * The same treatment {@link FeedItem.summary} already gets, and it has to be: the XML parser\n * decodes the document's own escaping ONCE, which is right for a title written as `AT&T` and\n * not enough for one written as `it&#8217;s` — an apostrophe a publisher escaped twice, which\n * is entirely ordinary in a feed whose titles came out of a CMS. What arrives here is then\n * `it’s`, and a bulletin read that out on air with the entity still in it.\n *\n * Tags come out for the same reason: `type=\"html\"` on a title is ordinary, and a `<em>` reaching a\n * speaking voice is the failure `NewsItem.summary` documents.\n */\nconst speakable = (value: unknown): string | undefined => {\n const raw = text(value);\n return raw === undefined ? undefined : asPlainText(raw);\n};\n","/**\n * Comparison forms for matching a title or artist name across two sources\n * that never spell either one the same way: a personal library's tags, a\n * provider's catalog, and MusicBrainz's own recording titles all disagree on\n * case, accents, punctuation, and which decorations belong on a title at all.\n *\n * Both `normalize` and `baseForm` were defined identically in more than one\n * plugin, because both feed match SCORING: a difference between the copies\n * would silently make two plugins match the same input differently.\n */\n\n/** Everything that is decoration rather than identity once a string is lowercased. */\nconst PUNCTUATION = /[^\\p{L}\\p{N}\\s]/gu;\n\n/** A parenthesised or bracketed suffix: `(2011 Remaster)`, `[Live]`. */\nconst PARENTHETICAL = /[([{][^)\\]}]*[)\\]}]/g;\n\n/**\n * Comparison form: lowercased, unaccented, stripped of punctuation, with runs\n * of whitespace collapsed. `Beyoncé` and `Beyonce`, `Mr. Brightside` and\n * `Mr Brightside` are the same string here.\n */\nexport function normalize(value: string): string {\n return value\n .normalize('NFD')\n .replace(/\\p{Diacritic}/gu, '')\n .toLowerCase()\n .replace(PUNCTUATION, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * {@link normalize} with a parenthesised or dashed suffix removed, so\n * `Roads (2011 Remaster)` and `Roads - Live` compare equal to `Roads`.\n *\n * This is where re-issues are caught. A personal library and a provider's\n * catalog are both full of these: the tags on a rip say what the pressing\n * said, MusicBrainz holds the recording under its plain name, and the same\n * song ends up appearing three times under three decorations. An exact-only\n * comparison misses most of a real library.\n */\nexport function baseForm(value: string): string {\n return normalize(value.replace(PARENTHETICAL, ' ').split(/\\s+[-–—]\\s+/)[0] ?? value);\n}\n","/**\n * The version of the deadair plugin API that this SDK implements.\n *\n * A plugin declares the range of API versions it is compatible with in its\n * manifest's `apiVersion` field (a semver range, e.g. `^1.0.0`). The host\n * refuses to load a plugin whose declared range does not satisfy this value.\n */\nexport const PLUGIN_API_VERSION = '1.0.0';\n","import { PluginError } from './plugin.error.js';\nimport type { PluginHost } from './plugin.host.js';\nimport type { PluginLifecycle } from './plugin.lifecycle.js';\n\n/** Undoes one thing a plugin set up. Run in reverse order of registration when the plugin unloads. */\nexport type PluginDisposer = () => void | Promise<void>;\n\n/**\n * The base a plugin extends, so unloading is correct by construction.\n *\n * Two jobs, both of them chores every plugin was otherwise doing by hand:\n *\n * 1. **`this.host` is there or it throws with a sentence.** Each plugin used to\n * hold `host?: PluginHost` and write its own `hostOrThrow()`, or forget to\n * and get a `TypeError` about reading a property of undefined instead.\n * 2. **Teardown is registered next to setup.** {@link Plugin.register} takes an\n * undo, and the base runs every one on unload, last registered first,\n * whether or not {@link Plugin.onUnload} throws.\n *\n * The second matters MORE in this host, not less. Plugins run inside the API process\n * (`packages/plugin-sdk/CLAUDE.md` § \"Trust and egress\"), so a timer or a socket a plugin forgets\n * is not confined to a sandbox that gets torn down: it lives in the server until somebody restarts\n * it. Reloading a plugin on every config change is a normal thing an operator does, so \"forgets on\n * unload\" compounds.\n *\n * ```ts\n * class MyPlugin extends Plugin implements EnrichmentPluginInstance {\n * protected async onLoad(): Promise<void> {\n * const timer = setInterval(() => void this.refresh(), 60_000);\n * this.register(() => clearInterval(timer));\n * }\n * }\n * ```\n *\n * Extending this is optional: the host only ever asks for `PluginLifecycle`, so\n * a plugin that implements `init` and `dispose` itself is as valid as it was.\n */\nexport abstract class Plugin implements PluginLifecycle {\n private currentHost?: PluginHost;\n private readonly disposers: PluginDisposer[] = [];\n\n /**\n * The host, once `init` has run.\n *\n * A getter rather than a field so the failure is a sentence naming the\n * plugin instead of a `TypeError` from somewhere three calls deeper.\n *\n * @throws {PluginError} `internal` when read before `init` or after\n * `dispose`. Both are host bugs rather than operator ones, which is what\n * that code means.\n */\n protected get host(): PluginHost {\n if (this.currentHost === undefined) {\n throw new PluginError(`${this.constructor.name} was used before init() or after dispose()`).withCode('internal');\n }\n return this.currentHost;\n }\n\n /**\n * Registers something to undo when this plugin unloads.\n *\n * Call it next to the setup it undoes, which is the whole point: a\n * `clearInterval` written beside its `setInterval` is one that cannot drift\n * away from it as the file grows.\n */\n protected register(disposer: PluginDisposer): void {\n this.disposers.push(disposer);\n }\n\n /** {@link Plugin.register} for the common case. Works for `setTimeout` and `setInterval` alike. */\n protected registerTimer(timer: ReturnType<typeof setTimeout>): void {\n this.register(() => clearTimeout(timer));\n }\n\n /** Your setup. Called once, after `this.host` is available and before any capability method. */\n protected onLoad(): Promise<void> {\n return Promise.resolve();\n }\n\n /**\n * Anything left to undo that `register` could not express, such as dropping\n * a cache. Runs after every registered disposer.\n */\n protected onUnload(): Promise<void> {\n return Promise.resolve();\n }\n\n async init(host: PluginHost): Promise<void> {\n this.currentHost = host;\n await this.onLoad();\n }\n\n /**\n * Undoes everything, in reverse.\n *\n * Every disposer runs even if an earlier one throws, because one broken\n * undo must not strand the rest: the failure is logged and the walk\n * continues. `onUnload` runs afterwards for the same reason it exists, and\n * `this.host` is released last so both still have it.\n *\n * Idempotent. The host calls it on unload, on a config change and at\n * shutdown, and those can overlap.\n */\n async dispose(): Promise<void> {\n if (this.currentHost === undefined) return;\n\n for (const disposer of this.disposers.splice(0).reverse()) {\n try {\n await disposer();\n } catch (error) {\n this.currentHost.logger.warn('a plugin disposer failed', {\n plugin: this.constructor.name,\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n try {\n await this.onUnload();\n } finally {\n this.currentHost = undefined;\n }\n }\n}\n","import { z } from 'zod';\n\n/**\n * The kinds of input a plugin can ask the operator for.\n *\n * - `string` free text, one line\n * - `text` free text over several lines, for anything a person writes\n * rather than pastes: a prompt, a persona, a list of phrasings.\n * Stored exactly like a `string`, so nothing downstream has to\n * know it exists; the difference is the box the operator types\n * into, and a one-line box for a paragraph is the reason a\n * setting like that ends up being edited by hand in psql\n * - `url` free text validated/normalised as a URL\n * - `secret` write-only: the host encrypts it, the settings UI never reads\n * it back, and only `host.secrets.get()` sees the plaintext\n * - `number` numeric input\n * - `boolean` toggle\n * - `select` one of `options`\n * - `multiselect` any number of `options`, stored as a JSON array of the chosen\n * values. Read it back with {@link parseMultiSelect}\n * - `list` any number of ROWS with the same `columns`, stored as a JSON\n * array of objects. Read it back with {@link parseRows}. For a\n * list whose entries have parts — a feed with a name and a\n * category — where the alternative is a `text` field with a\n * separator in it and a line an operator can mistype into\n * silence\n * - `note` not an input at all: static help text rendered in the form\n */\nexport type ConfigFieldType = 'string' | 'text' | 'url' | 'secret' | 'number' | 'boolean' | 'select' | 'multiselect' | 'list' | 'note';\n\n/**\n * What a `number` field's value is measured in, so the form can offer a control a person can use.\n *\n * The VALUE is always stored in the unit named here — `bytes` means the row holds bytes — and the\n * console converts on the way in and out. That is the whole point: a byte count is the right thing\n * for code to compare against and a terrible thing to type, and the alternative to declaring it is\n * either storing a friendlier unit (and doing the multiplication at every reader) or special-casing\n * a particular setting key inside the form, which is the kind of thing nobody finds later.\n *\n * `fraction` is the second member and the one the first paragraph predicted: the row holds a share\n * between 0 and 1, because that is what the code multiplying by it wants, and the console says\n * `40%`, because that is what a person means. Honoured by a `slider`, which is the control every\n * share in this station asks for; a fraction drawn as an ordinary spinner is still shown as the\n * fraction it stores, since a number typed exactly is unambiguous either way.\n *\n * An enum rather than a boolean because the third member is obvious (a duration in milliseconds has\n * exactly the same problem) and because a closed set is what the contract mirroring this can\n * express.\n */\nexport type ConfigFieldUnit = 'bytes' | 'fraction';\n\n/**\n * The control a field asks for, where the default one for its type is not the readable one.\n *\n * {@link ConfigFieldUnit}'s sibling, and the same bargain: what is STORED does not change, only the\n * thing the operator touches. A `slider` over a share between 0 and 1 stores a fraction, and every\n * reader still reads a fraction.\n *\n * Opt-in per field rather than inferred from `min` and `max` being present, which is the whole\n * point. A slider is right for a value somebody feels for (a percentage, a trim in decibels, one\n * pad every N breaks) and wrong for one they have to hit exactly: 3500 out of 0 to 600000 is a\n * pixel, and a pause in milliseconds is a number an operator types rather than aims at. Declaring\n * it makes that a judgement per setting instead of a rule that is right eight times and wrong six.\n *\n * A `slider` must declare both `min` and `max`, since a range with no ends is not one a track can\n * be drawn for. A field asking for one without them falls back to the ordinary input rather than\n * failing: this is a hint about drawing, and a form that renders nothing is worse than a form that\n * renders a spinner.\n *\n * `tags` is for a `string` that is really a SET, stored as one comma-separated line because that is\n * what the reader behind it splits. It changes nothing about the value: the form splits on the way\n * in and joins on the way out, so `dialogueKinds()` keeps parsing exactly the string it always did.\n * What it buys is that a set of names is added to and removed from one at a time, rather than by\n * editing punctuation in a sentence — a stray comma in a one-line box is a kind the station simply\n * does not have, and nothing says so. The cost is that a value CONTAINING a comma cannot be one\n * tag, which is why this is asked for per field rather than being what a `string` does.\n */\nexport type ConfigFieldControl = 'slider' | 'tags';\n\n/** One choice in a `select`, a `multiselect`, or a suggestion list. */\nexport interface ConfigFieldOption {\n value: string;\n label: string;\n}\n\n/**\n * Where a field's or a column's choices come from when neither the plugin nor the operator's own\n * server is the one that knows them.\n *\n * A closed HOST vocabulary, and the third of three ways a choice can be offered: `options` is what\n * the PLUGIN decided when its manifest was written, `suggestConfigOptions()` is what the operator's\n * own server currently says, and this is what the STATION says. It exists because a plugin cannot\n * ask — a news plugin has no way to learn which categories this station holds, and the alternative\n * is a free-text cell where `sports` and `sport` are a silent miss nobody sees until bulletins start\n * declining.\n *\n * `intl.timeZones` is the second member and stretches the name slightly: it is the PLATFORM's list\n * rather than the station's, out of `Intl.supportedValuesOf('timeZone')`. It belongs here anyway,\n * because the property is about who can answer rather than about where the answer is kept, and the\n * console is again the only side that can — a zone name has to be one the browser and the server\n * both know, and a server that enumerated its own would be answering for a different machine.\n *\n * `station.newsFeeds` answers the feeds the installed plugins currently offer, by their qualified id\n * and the operator's own name for each. It is the one source whose value is minted by a PLUGIN and\n * whose list only the station can assemble, which is why it is here rather than being something a\n * plugin could answer: the ids are qualified with the plugin that offered them, and no plugin knows\n * what the others are called.\n *\n * `station.podcastShows` is `station.newsFeeds`' twin for the programmes the station carries: every\n * show every podcast plugin currently lists, by its qualified id and its title, for the topic that\n * says which show a `syndicated` band on the format clock means. Here for the same reason: the ids\n * are qualified with the plugin that carries the show, and no plugin knows what the others carry.\n *\n * The four `plugins.*` members answer the enabled plugins that declare a given capability — speech,\n * llm, mixer, analysis — by id and name, for the settings that pick which plugin a capability with\n * several installed candidates uses. Those settings stay free text (`selectPlugin` in\n * `plugin.selection.ts` accepts an id that is not currently a candidate without falling back), so\n * this is a suggestion list rather than a closed `select` — the console resolves it the same way as\n * the other sources here, against the plugin list rather than a static enum.\n *\n * `llm.models` is the odd one and the only member whose answer comes from a PLUGIN rather than from\n * a station table: the models the selected model plugin currently offers, for the settings that say\n * which model a particular writer should use. It resolves through the suggestions route every plugin\n * settings form already uses, against whichever plugin `llm.pluginId` names, so the six writer\n * settings offer the same list the plugin's own form does — including which provider each model\n * lives on, since a model name carries that.\n *\n * An enum rather than a boolean for {@link ConfigFieldUnit}'s reason: the next one (voices,\n * personas) is obvious, and a closed set is what the contract mirroring this can express. Whatever\n * it names is resolved by the CONSOLE; nothing here reaches a plugin.\n */\nexport type ConfigFieldOptionSource =\n | 'station.newsCategories'\n | 'station.newsFeeds'\n | 'station.podcastShows'\n | 'intl.timeZones'\n | 'plugins.speech'\n | 'plugins.llm'\n | 'plugins.mixer'\n | 'plugins.analysis'\n | 'llm.models';\n\n/**\n * One column of a `list` field.\n *\n * Still a smaller vocabulary than {@link ConfigFieldType} — no nested `list`, because a table inside\n * a table is a form nobody can fill in — but `secret` is now in it, and the reason it was not is\n * worth keeping because it is what had to be built to allow it. A row was stored as plain JSON with\n * nothing to encrypt one cell against: a ciphertext has to belong to a ROW, and a row had no\n * identity beyond its position in an array the console rewrites whole on every save. {@link ROW_ID_KEY}\n * is that identity, and {@link rowSecretKey} is where the cell's ciphertext lives.\n *\n * Every ordinary cell is stored as a STRING in the row, so a column is about the control the\n * operator gets rather than about the shape of what is kept. A `secret` cell is the exception and\n * the exception is the point: it is never in the row at all, and the row an author reads back has\n * no trace of it. See {@link readRowSecret}.\n */\nexport interface ConfigFieldColumn {\n /**\n * Key this cell is stored under inside the row object.\n *\n * Whatever suits the plugin, dots included, exactly like {@link ConfigField.key}. A row editor\n * addresses a cell by a path and a dot in a path means a step into a nested object, but that is\n * the FORM's problem and it solves it the way it already solves the same problem for a\n * dot-keyed station setting: it names its inputs positionally and puts the real keys back on\n * the way out. Nothing a plugin author has to know about.\n *\n * One character is refused, and only because {@link rowSecretKey} joins on it: a `/`. That\n * separator has to be unambiguous or a cell's ciphertext could be addressed by two different\n * keys, so it is refused at the schema rather than escaped.\n */\n key: string;\n\n /** Column heading. */\n label: string;\n\n /**\n * `string` free text, `url` free text meant to be an address, `select` one of `options`, and\n * `secret` a credential this row holds — write-only, encrypted per cell, and never in the row.\n */\n type: 'string' | 'url' | 'select' | 'secret';\n\n /** Whether a row is only counted once this cell is filled in. */\n required?: boolean;\n\n /** Ghost text inside the cell. */\n placeholder?: string;\n\n /** Choices, fixed when the manifest is written. See {@link ConfigField.options}. */\n options?: ConfigFieldOption[];\n\n /** Choices only the station can enumerate. See {@link ConfigFieldOptionSource}. */\n optionsFrom?: ConfigFieldOptionSource;\n\n /**\n * Key of another column in the same list. This cell only applies to a row whose cell there\n * holds one of {@link dependsOnValues}.\n *\n * For the table whose columns are not all about the same row: a provider list where the\n * address belongs to a self-hosted server and the API key to a vendor, a supplier list where\n * one kind is reached by URL and another by account id. Without it every column is drawn on\n * every row, so an operator meets a cell their row has no use for, with no way to tell it from\n * one they have not filled in yet.\n *\n * **This is not the rendering hint {@link ConfigField.dependsOn} is**, and the difference is\n * the whole reason it is here. A field-level `dependsOn` hides a control and the server never\n * reads it. This says the cell DOES NOT APPLY, so the console declines to send it and the host\n * declines to derive anything from it — which for a `url` column means the row contributes no\n * hostname to the plugin's allowlist (`addressCells` in `plugin.host.factory.ts`). An address\n * typed on a row before its kind was changed would otherwise widen the allowlist by a host the\n * plugin can never call, which is exactly the quiet widening that path exists to refuse.\n *\n * Forgiving in three places, all of them the same instinct as `isVisible` in the console's\n * form: a target this list does not declare shows the cell, a target cell that is EMPTY shows\n * the cell, and values without a target are ignored. The empty case is the load-bearing one. A\n * column has no `default`, so a row somebody has just added holds `''` in every cell, and a\n * rule that hid a cell there would hide it on the one row that most needs filling in.\n *\n * A `secret` cell that does not apply keeps whatever is stored rather than being cleared.\n * Absent already means \"keep it\" in `plugin.config.rows.ts`, and reading a visibility rule as\n * an instruction to destroy a credential would be a surprise nobody asked for.\n */\n dependsOn?: string;\n\n /**\n * The values of the {@link dependsOn} cell that this one applies to. Ignored without a target.\n *\n * Omitted means \"any value at all\", which is what a field-level `dependsOn` already means, so\n * an author who knows that one knows this.\n */\n dependsOnValues?: string[];\n}\n\n/**\n * The chosen values of a `multiselect`, out of the string it is stored as.\n *\n * Stored as a JSON array because plugin config is a string map, and read back\n * through here so every plugin agrees on the encoding. Tolerant on purpose: a\n * value hand-edited into something unreadable answers empty rather than failing\n * a load, which for a field like \"which models can use tools\" is the difference\n * between a degraded station and one that will not start.\n */\nexport function parseMultiSelect(raw: unknown): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return [];\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed.filter((value): value is string => typeof value === 'string' && value.trim().length > 0).map(value => value.trim());\n } catch {\n return [];\n }\n}\n\n/**\n * The cell key a row's own identity is stored under.\n *\n * A reserved name rather than a column an author declares, because it is not data: nobody types it,\n * nothing renders it, and a plugin that ignores it entirely is a plugin that behaves exactly as it\n * did before this existed. The host mints one when a row is first saved and preserves it forever\n * after.\n *\n * ## Why a row needs a name at all\n *\n * Only so a {@link ConfigFieldColumn} may be a `secret`. A secret is encrypted and kept out of the\n * row, which means something has to say which row a given ciphertext belongs to — and until this\n * existed the only answer was \"the third one\", from a console that rewrites the whole array on\n * every save and lets the operator reorder it. Position is not identity. A `$` leads it because no\n * sensible column key does, and because a row whose keys are printed somewhere reads as obviously\n * not-a-column.\n */\nexport const ROW_ID_KEY = '$id';\n\n/**\n * Where a `secret` cell's ciphertext lives, in the same flat map a `secret` FIELD's does.\n *\n * `field/row/column`, joined on the one character a field key and a column key may not contain\n * (both schemas refuse it below). That is what keeps this unambiguous against a plain secret\n * field's key, which is a bare field key and can therefore never collide with a three-part one.\n *\n * One map rather than a nested shape because everything already built for secrets — encrypting per\n * entry, reporting configured-ness as a boolean, the rule that no value ever leaves the server —\n * works on a flat `Record<string, string>` and needed no changes to carry these.\n */\nexport function rowSecretKey(fieldKey: string, rowId: string, columnKey: string): string {\n return `${fieldKey}/${rowId}/${columnKey}`;\n}\n\n/**\n * Whether a stored secret key belongs to a row rather than to a field.\n *\n * For the host, which merges stored secrets back into the form to validate it: a row's cells belong\n * inside their row and putting them at the top level would show a plugin's schema three keys it has\n * never declared.\n */\nexport function isRowSecretKey(key: string): boolean {\n return key.split('/').length === 3;\n}\n\n/**\n * The rows of a `list` field, out of the string it is stored as.\n *\n * A JSON array of objects in a string, for {@link parseMultiSelect}'s reason and encoded the same\n * way, so a `list` needs nothing of the storage path that a `string` did not already have. Tolerant\n * in the same way and for the same stakes: anything unreadable is no rows rather than a plugin that\n * will not load, and a cell that is not a string is dropped rather than stringified, since a number\n * where a URL was expected is a mistake worth seeing as an empty cell.\n *\n * A row with nothing in it is dropped, because the form leaves one behind whenever an operator adds\n * a row and thinks better of it.\n */\nexport function parseRows(raw: unknown): Record<string, string>[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return [];\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n\n return parsed.flatMap(entry => {\n if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];\n\n const row: Record<string, string> = {};\n for (const [key, value] of Object.entries(entry as Record<string, unknown>)) {\n if (typeof value === 'string' && value.trim().length > 0) row[key] = value.trim();\n }\n\n return Object.keys(row).length === 0 ? [] : [row];\n });\n } catch {\n return [];\n }\n}\n\n/**\n * A declarative description of one row in a plugin's settings form. The host\n * renders these; plugins never ship UI.\n */\nexport interface ConfigField {\n /** Key this value is stored under, and the key `host.config`/`host.secrets` reads it back by. */\n key: string;\n\n /** Human label shown next to the input. */\n label: string;\n\n type: ConfigFieldType;\n\n /** Whether the form refuses to save without a value. Defaults to false. */\n required?: boolean;\n\n /** Prefilled value. Never provide a default for a `secret`. */\n default?: string | number | boolean;\n\n /**\n * What a `number`'s value is measured in. Ignored on every other type.\n *\n * See {@link ConfigFieldUnit}: the stored value stays in this unit and only the control the\n * operator touches changes.\n */\n unit?: ConfigFieldUnit;\n\n /**\n * The control to draw this field with, where the ordinary one for its type reads badly.\n *\n * `slider` is for a `number` and is ignored without both `min` and `max`; `tags` is for a\n * `string`. See {@link ConfigFieldControl}. Nothing about the stored value changes either way.\n */\n control?: ConfigFieldControl;\n\n /**\n * How coarsely a `control` moves. Ignored without one, and defaults to 1.\n *\n * The unit is the field's own, so a share between 0 and 1 wants `0.05` and a word count wants\n * `10`. Worth setting on anything whose range is wider than the pixels it is drawn in, since\n * the alternative is a control that can express values nobody wants and cannot be stopped on\n * the ones they do.\n */\n step?: number;\n\n /**\n * The smallest and largest a `number` may be, inclusive. Ignored on every other type.\n *\n * A range the field is DECLARED with rather than one the reader clamps to, which is the whole\n * point: a resolver that clamps answers a legal number for an illegal one, so the station runs\n * on something the console never showed and the operator never chose. Declared here, the console\n * refuses it in front of them and the station's own settings route refuses it again for anything\n * that did not come through a console.\n *\n * A plugin's config is NOT validated against these — the host stores what it is handed and a\n * plugin's own schema is what judges it — so for a plugin these are a hint to the form. For a\n * station setting they are enforced, in `serializeSetting`.\n */\n min?: number;\n max?: number;\n\n /** Ghost text inside the input. */\n placeholder?: string;\n\n /** Longer explanation rendered under the input. */\n help?: string;\n\n /**\n * Choices, for a `select` or a `multiselect`.\n *\n * Fixed when the manifest is written, so this is for a closed set the plugin\n * decides. For anything the operator's own server decides, implement\n * `suggestConfigOptions()` instead: what it returns for this key replaces\n * these, and it can also turn a `string` into free text with suggestions.\n */\n options?: ConfigFieldOption[];\n\n /**\n * Choices only the console can enumerate. See {@link ConfigFieldOptionSource}.\n *\n * The field-level twin of {@link ConfigFieldColumn.optionsFrom}, and it resolves the same way\n * and merges at the same point: whatever `suggestConfigOptions()` says for this key wins, and\n * this is what is offered when it says nothing.\n */\n optionsFrom?: ConfigFieldOptionSource;\n\n /**\n * The columns of a `list`, in the order they are drawn. Ignored on every other type.\n *\n * A `list` with none is a field with nothing to fill in, so declare at least one.\n */\n columns?: ConfigFieldColumn[];\n\n /**\n * Key of another field in the same form. This field is only shown when\n * that field has a truthy value.\n */\n dependsOn?: string;\n\n /**\n * Key of the `number` field that is the UPPER end of the range this one opens. Declared on the\n * lower end only, and ignored on every other type.\n *\n * Two settings, still: each keeps its own key, its own row and its own validation, and\n * `serializeSetting` refuses each one by name exactly as it did before. What this changes is\n * that the console draws them as one control with two handles instead of two boxes that happen\n * to sit next to each other.\n *\n * The reason is legibility. Two boxes cannot say that one is the far end of the other, so a\n * range arrives as two settings whose labels have to carry the relationship (\"fewest\", \"most\",\n * \"the other end\") and an operator reads the pair rather than seeing it. One track with two\n * handles says it in the shape of the control.\n *\n * NOT correctness, which is worth stating because it is the plausible reason and it is wrong\n * here: every reader of a paired setting in this station takes the two ends as an UNORDERED\n * pair and sorts them, so a range stored the wrong way round has always been tolerated rather\n * than obeyed. The handles not crossing is a nicety on top, not the point.\n *\n * Worth declaring only where the declared range is narrow enough that the whole track is\n * usable. A pair bounded by a typo guard rather than by intent — one to a hundred and twenty\n * minutes, for a station that runs eight to twelve — puts both handles in the first tenth and\n * makes the exact figure somebody has in mind a pixel. Those stay two boxes.\n *\n * A rendering hint like {@link ConfigField.dependsOn}, and forgiving in the same way: if the\n * named key is not in this form, both ends fall back to their own controls rather than one of\n * them disappearing. A settings page that draws one group of a larger set is the ordinary case\n * for that.\n */\n rangeWith?: string;\n}\n\nexport const configFieldOptionSchema = z.object({\n value: z.string(),\n label: z.string(),\n});\n\nexport const configFieldTypeSchema = z.enum(['string', 'text', 'url', 'secret', 'number', 'boolean', 'select', 'multiselect', 'list', 'note']);\n\nexport const configFieldUnitSchema = z.enum(['bytes', 'fraction']);\n\nexport const configFieldControlSchema = z.enum(['slider', 'tags']);\n\n/**\n * Every member of {@link ConfigFieldOptionSource}, and it has to stay every member: this validates\n * real manifests at load, so a source missing here is a plugin the host refuses to start. Two were\n * missing for exactly that reason and nothing caught it, because no bundled plugin had asked for\n * one yet.\n */\nexport const configFieldOptionSourceSchema = z.enum([\n 'station.newsCategories',\n 'station.newsFeeds',\n 'station.podcastShows',\n 'intl.timeZones',\n 'plugins.speech',\n 'plugins.llm',\n 'plugins.mixer',\n 'plugins.analysis',\n 'llm.models',\n]);\n\n/** The one character a key may not hold, because {@link rowSecretKey} joins on it. */\nconst noSeparator = (key: string): boolean => !key.includes('/');\nconst separatorMessage = 'a key may not contain \"/\"';\n\nexport const configFieldColumnSchema = z.object({\n key: z.string().min(1).refine(noSeparator, separatorMessage),\n label: z.string().min(1),\n type: z.enum(['string', 'url', 'select', 'secret']),\n required: z.boolean().optional(),\n placeholder: z.string().optional(),\n options: z.array(configFieldOptionSchema).optional(),\n optionsFrom: configFieldOptionSourceSchema.optional(),\n // Named here or dropped: `plugin.loader.ts` stores the zod OUTPUT of this schema as the\n // manifest, and a `z.object` strips what it does not name. A column declaring a condition this\n // schema had not been told about would lose it at load, in silence, and present as a console\n // that ignores the declaration.\n //\n // No refinement tying the values to a target. The `.ck` contract mirroring this cannot express\n // one, and a schema that refuses what the contract accepts is the drift the generated-output\n // rule exists to prevent; values without a target are documented as ignored instead.\n dependsOn: z.string().min(1).optional(),\n dependsOnValues: z.array(z.string().min(1)).optional(),\n});\n\nexport const configFieldSchema = z.object({\n key: z.string().min(1).refine(noSeparator, separatorMessage),\n label: z.string().min(1),\n type: configFieldTypeSchema,\n required: z.boolean().optional(),\n default: z.union([z.string(), z.number(), z.boolean()]).optional(),\n unit: configFieldUnitSchema.optional(),\n control: configFieldControlSchema.optional(),\n step: z.number().optional(),\n min: z.number().optional(),\n max: z.number().optional(),\n placeholder: z.string().optional(),\n help: z.string().optional(),\n options: z.array(configFieldOptionSchema).optional(),\n optionsFrom: configFieldOptionSourceSchema.optional(),\n columns: z.array(configFieldColumnSchema).optional(),\n dependsOn: z.string().optional(),\n rangeWith: z.string().optional(),\n});\n","import { ROW_ID_KEY, rowSecretKey } from './plugin.config.fields.js';\nimport type { PluginHost } from './plugin.host.js';\n\n/**\n * Reading an operator's typed-in config field.\n *\n * `host.config.get()` answers `Record<string, unknown>`, because the values came\n * out of a database column an operator edits through a text box. Every plugin\n * therefore narrows each field itself, and every plugin was narrowing it the\n * same two ways: \"a string, and blank counts as unset\", and \"a base URL with no\n * trailing slash\".\n *\n * Blank counting as unset is the important half. A cleared text box stores `''`\n * rather than removing the row, so a plugin comparing against `undefined` alone\n * sees an empty string, treats it as a real value, and sends it upstream.\n */\n\n/**\n * A config field as a trimmed string, or `undefined` when it is not set.\n *\n * Whitespace-only is unset for the same reason blank is: an operator who\n * selected a value and deleted it has said \"none\", and a space is not a model\n * name.\n */\nexport function configString(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n}\n\n/**\n * A base URL with no trailing slash, so a path can be appended with one.\n *\n * Answers `''` rather than `undefined` for a field that is not set, which is\n * deliberate and is what every caller already expected: a plugin holds its base\n * URL as a plain `string` and reports \"not configured\" by testing whether it is\n * empty, in a sentence of its own naming the thing it cannot reach (\"No\n * analyzer URL set.\"). Making this optional would push a `?? ''` to every use\n * and change nothing else.\n */\nexport function configBaseUrl(value: unknown): string {\n return (configString(value) ?? '').replace(/\\/+$/, '');\n}\n\n/**\n * The credential one ROW of a `list` field holds, or `undefined` when the operator has not set it.\n *\n * The whole of what a plugin has to know about secret cells. A `secret` column is never in the row\n * that {@link parseRows} hands back — that is what makes it a secret rather than a JSON string with\n * a password in it — so this is how the value is reached, and the key it is stored under is nobody's\n * business but this function's.\n *\n * Answers `undefined` for a row the host has never saved, which is the honest answer: a row with no\n * {@link ROW_ID_KEY} has never been stored, so there is nothing under it.\n *\n * ```ts\n * for (const row of parseRows(config.providers)) {\n * const apiKey = await readRowSecret(this.host, 'providers', row, 'apiKey');\n * }\n * ```\n */\nexport async function readRowSecret(host: PluginHost, fieldKey: string, row: Record<string, string>, columnKey: string): Promise<string | undefined> {\n const rowId = row[ROW_ID_KEY];\n if (rowId === undefined || rowId.length === 0) return undefined;\n\n return await host.secrets.get(rowSecretKey(fieldKey, rowId, columnKey));\n}\n","/**\n * Sugar over the `Response` that `host.fetch` hands back.\n *\n * Free functions rather than a `Response` subclass with methods, because\n * `host.fetch` returns the platform's own `Response` and a plugin has to be\n * able to pass it to any library that takes one. Anything that made it a\n * special response would take that away for the sake of dot notation.\n *\n * What they buy over `await response.json()` is the error. The common failure\n * is an API answering 200 with an HTML error page or a rate-limit notice, and\n * `Unexpected token < in JSON at position 0` says nothing about which call did\n * it.\n */\n\n/** How much of an unparseable body to quote back. Enough to identify it, not enough to fill a log line. */\nconst BODY_SNIPPET_LENGTH = 120;\n\nconst snippet = (body: string): string => {\n const trimmed = body.trim();\n if (trimmed.length === 0) return '<empty body>';\n return trimmed.length <= BODY_SNIPPET_LENGTH ? trimmed : `${trimmed.slice(0, BODY_SNIPPET_LENGTH)}…`;\n};\n\n/**\n * The body parsed as JSON.\n *\n * Throws when it is not JSON, with the status, the URL and the start of the\n * body in the message. That detail is the point, and it is why this reads the\n * body as text and parses that rather than calling `response.json()`: the text\n * is what names what the server actually sent.\n *\n * A body that fails to READ rather than to parse (over the host's byte cap, a\n * socket that went quiet) rejects with the host's own `PluginError` untouched.\n * That is a different failure from a body that arrived and was not JSON, and\n * relabelling it would lose the code the caller branches on.\n *\n * Note this does not check `response.ok`. A 4xx with a JSON error body is worth\n * parsing, so deciding what a bad status means is left to the caller.\n */\nexport async function jsonBody<T>(response: Response): Promise<T> {\n const text = await response.text();\n try {\n return JSON.parse(text) as T;\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error);\n throw new Error(`expected JSON from ${response.url} (HTTP ${response.status}) but got ${snippet(text)}: ${reason}`, { cause: error });\n }\n}\n\n/**\n * {@link jsonBody}, but `undefined` instead of a throw when the body will not\n * parse. For the callers that treat an unparseable body the same as a missing\n * one and have nothing useful to add to the error.\n *\n * A body that fails to read still rejects, for the reason above: the host\n * refusing an oversized body is not the same event as a server answering with\n * something that is not JSON, and swallowing the first would report a\n * misconfiguration as an empty result.\n */\nexport async function tryJsonBody<T>(response: Response): Promise<T | undefined> {\n const text = await response.text();\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined;\n }\n}\n","import { z } from 'zod';\nimport { configFieldSchema, type ConfigField } from './plugin.config.fields.js';\nimport { pluginPermissionsSchema, type PluginPermissions } from './plugin.permissions.js';\n\n/**\n * What a plugin can do, and the only thing the host ever dispatches on.\n *\n * There is no second axis. A manifest used to also carry a `kind`\n * (`music-provider`, `enrichment`, `tts`) which nothing checked: every call\n * site asked the capability list, because a plugin that declares a kind and\n * forgets the method is a `TypeError` mid-request. So the label went and this\n * is what is left.\n */\nexport const PLUGIN_CAPABILITY_CATALOG = 'catalog';\n\n/**\n * The plugin can get the station audio to play (`resolveStreamUrl`). Separate\n * from {@link PLUGIN_CAPABILITY_CATALOG} so a manifest can say it: \"will fetch\n * audio from your server\" is a thing an operator should read before installing,\n * and it used to be invisible.\n */\nexport const PLUGIN_CAPABILITY_STREAM = 'stream';\n\n/**\n * The plugin owns its own audio output and deadair only tells it what to do.\n * The opposite of deadair's `playout` module, which is why it is not called\n * that.\n */\nexport const PLUGIN_CAPABILITY_STEER = 'steer';\nexport const PLUGIN_CAPABILITY_OAUTH = 'oauth';\nexport const PLUGIN_CAPABILITY_ENRICHMENT = 'enrichment';\n\n/** The plugin can say something out loud: text in, audio out. */\nexport const PLUGIN_CAPABILITY_SPEECH = 'speech';\n\n/**\n * The plugin can produce words: a conversation in, text out.\n *\n * A transport rather than a writer. What to say is the station's business, which\n * is why this capability knows nothing about breaks, shows or running orders.\n */\nexport const PLUGIN_CAPABILITY_LLM = 'llm';\n\n/**\n * The plugin can measure a track's audio: bytes in, offsets out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} because the two answer\n * different kinds of question. Enrichment asks an upstream what it knows and\n * merges several answers; this computes one answer from the samples, and no\n * upstream sells it.\n */\nexport const PLUGIN_CAPABILITY_ANALYSIS = 'analysis';\n\n/**\n * The plugin can make one piece of audio out of several: parts in, audio out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ANALYSIS}, and NOT because the work is\n * different: both need decoded PCM, and the bundled adapter serves both off one\n * sidecar. It is separate because **a capability is the unit of SELECTION**. The\n * host picks one plugin per capability, so a joiner carried as an optional method\n * on the analyzer is the analyzer the operator chose to MEASURE with — install\n * one that measures better and cannot join, name it, and joining stops with\n * nothing to do about it but choose a worse analyzer. Two keys is what lets a\n * station measure with one engine and mix with another.\n *\n * One plugin may of course declare both, and the bundled one does.\n */\nexport const PLUGIN_CAPABILITY_MIXER = 'mixer';\n\n/**\n * The plugin can say what is popular: a chart id in, an ordered list of names\n * out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} because it is not a fact\n * about a record the station holds — it is an opinion about records in general,\n * most of which the library has never seen. And separate from\n * {@link PLUGIN_CAPABILITY_CATALOG} because a chart is not a source of audio:\n * naming a record is the whole of what it does.\n */\nexport const PLUGIN_CAPABILITY_CHARTS = 'charts';\n\n/**\n * The plugin can say what happened outside the station: a feed in, published\n * entries out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_CHARTS} even though both read somebody\n * else's document, because what comes back is not about records at all. A chart\n * entry is a name the pick path can turn into something that airs; a news item\n * is a fact, and the only thing that can be done with it is say it.\n */\nexport const PLUGIN_CAPABILITY_NEWS = 'news';\n\n/**\n * The plugin can say what programmes the station subscribes to, and what each\n * has published: a show in, episodes out, each with the address of its audio.\n *\n * Separate from {@link PLUGIN_CAPABILITY_NEWS} although both read somebody\n * else's feed, because what comes back is not a fact to say but a programme to\n * AIR, and the station carries it rather than reading it out. And separate from\n * {@link PLUGIN_CAPABILITY_CATALOG} although both end in audio, because an\n * episode is not a record: every rule the station applies to a record, from\n * rotation to scrobbling, is wrong for an hour of somebody else's programme.\n * `capabilities/podcast.ts` says why at length.\n */\nexport const PLUGIN_CAPABILITY_PODCAST = 'podcast';\n\n/**\n * The plugin can say who else sounds like this: an artist in, artists out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} for the reason\n * `capabilities/similarity.ts` gives at length: enrichment describes rows the\n * catalog holds, and the artists worth asking about here are the ones it does\n * not.\n */\nexport const PLUGIN_CAPABILITY_SIMILARITY = 'similarity';\n\n/**\n * The plugin can ask the open web a question: words in, pages out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_NEWS}, which is the other capability\n * that answers about the world, because the two are asked different things. News\n * serves a MENU an operator assembled and answers \"what happened\"; this is given\n * a subject the caller chose and answers \"what does the web say about it\", with\n * nothing stable to de-duplicate against because no two calls ask the same\n * question.\n *\n * And separate from {@link PLUGIN_CAPABILITY_CATALOG} for the reason\n * {@link PLUGIN_CAPABILITY_CHARTS} is: a result is a page, and a page cannot be\n * played. Looking for something to PLAY is `searchTracks` on the catalog.\n */\nexport const PLUGIN_CAPABILITY_SEARCH = 'search';\n\n/**\n * The plugin can say what it is like outside: a place in, measurements out.\n *\n * The third capability that answers about the world, and separate from both of\n * the others because it is asked a different question. News serves a menu\n * somebody assembled and answers \"what happened\"; search takes words a caller\n * made up and answers \"what does the web say\". This is asked about one PLACE,\n * and what comes back is numbers rather than sentences — the station does\n * arithmetic on them and then decides what to say, where a headline is already\n * the words.\n *\n * A capability rather than a general-purpose tool the model calls, which was the\n * older plan: a plugin answering a question the STATION has is a capability, and\n * this station wants a reading for the moment it is playing into as well as for\n * the presenter to mention. See [tool-plugins](https://github.com/robert-dean/deadair/discussions/44).\n */\nexport const PLUGIN_CAPABILITY_WEATHER = 'weather';\n\n/**\n * The plugin can report what the station played to somebody else's service.\n *\n * The only capability that SENDS. Everything else here reads an upstream; this\n * publishes the operator's own listening to an account they hold, which is why\n * the SDK gives it a way to be declined per installation rather than assuming\n * that installing a plugin is consent to broadcast from it.\n */\nexport const PLUGIN_CAPABILITY_SCROBBLE = 'scrobble';\n\nexport const KNOWN_PLUGIN_CAPABILITIES = [\n PLUGIN_CAPABILITY_CATALOG,\n PLUGIN_CAPABILITY_STREAM,\n PLUGIN_CAPABILITY_STEER,\n PLUGIN_CAPABILITY_OAUTH,\n PLUGIN_CAPABILITY_ENRICHMENT,\n PLUGIN_CAPABILITY_SPEECH,\n PLUGIN_CAPABILITY_LLM,\n PLUGIN_CAPABILITY_ANALYSIS,\n PLUGIN_CAPABILITY_MIXER,\n PLUGIN_CAPABILITY_CHARTS,\n PLUGIN_CAPABILITY_NEWS,\n PLUGIN_CAPABILITY_PODCAST,\n PLUGIN_CAPABILITY_SIMILARITY,\n PLUGIN_CAPABILITY_SEARCH,\n PLUGIN_CAPABILITY_WEATHER,\n PLUGIN_CAPABILITY_SCROBBLE,\n] as const;\n\nexport type KnownPluginCapability = (typeof KNOWN_PLUGIN_CAPABILITIES)[number];\n\n/**\n * Known capabilities get autocomplete; the type stays open so a plugin built\n * against a newer host can declare one this SDK has never heard of without\n * failing manifest validation.\n */\nexport type PluginCapability = KnownPluginCapability | (string & Record<never, never>);\n\n/**\n * A zod schema instance. Typed loosely so a plugin can hand over any schema\n * shape (object, union, refined object) without fighting the compiler.\n */\nexport type PluginConfigSchema = z.ZodType;\n\n/**\n * Duck-typed \"is this a zod schema?\". Deliberately not a bare `instanceof`:\n * a plugin may resolve its own copy of zod, and `instanceof` fails across\n * module instances.\n */\nexport function isZodSchema(value: unknown): value is PluginConfigSchema {\n if (value instanceof z.ZodType) return true;\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as { parse?: unknown; safeParse?: unknown };\n return typeof candidate.parse === 'function' && typeof candidate.safeParse === 'function';\n}\n\n/**\n * Everything the host needs to know about a plugin before it runs any of the\n * plugin's code: who it is, what it can do, what it needs permission for, and\n * what to ask the operator for.\n */\nexport interface PluginManifest {\n /** Reverse-DNS identifier, e.g. `deadair.spotify`. Globally unique, stable across versions. */\n id: string;\n\n /** Display name, e.g. `Spotify`. */\n name: string;\n\n /** Semver version of the plugin itself. */\n version: string;\n\n /** Which capability interfaces the factory result actually implements. */\n capabilities: PluginCapability[];\n\n /**\n * Semver RANGE of the plugin API this plugin works against, e.g. `^1.0.0`.\n * Compared against {@link PLUGIN_API_VERSION} at load time.\n */\n apiVersion: string;\n\n description?: string;\n\n homepage?: string;\n\n /** Data URI or absolute https URL of a small square icon. */\n icon?: string;\n\n permissions: PluginPermissions;\n\n /** Declarative settings form. */\n configFields: ConfigField[];\n\n /**\n * Server-side validation of the submitted config. The host parses the\n * operator's submission with this before storing it, so a plugin never\n * has to defend against malformed config at runtime.\n */\n configSchema: PluginConfigSchema;\n}\n\n/** Reverse-DNS: at least two lowercase dot-separated segments. */\nconst PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9][a-z0-9-]*)+$/;\n\n/**\n * Validates everything in a manifest except the contents of `configSchema`,\n * which is only checked to be a zod schema instance.\n */\nexport const pluginManifestSchema = z.object({\n id: z.string().regex(PLUGIN_ID_PATTERN, 'plugin id must be reverse-DNS, e.g. \"deadair.spotify\"'),\n name: z.string().min(1),\n version: z.string().min(1),\n capabilities: z.array(z.string().min(1)),\n apiVersion: z.string().min(1),\n description: z.string().optional(),\n homepage: z.string().optional(),\n icon: z.string().optional(),\n permissions: pluginPermissionsSchema,\n configFields: z.array(configFieldSchema),\n configSchema: z.custom<PluginConfigSchema>(isZodSchema, { message: 'configSchema must be a zod schema' }),\n});\n","import { z } from 'zod';\n\n/** The pacing knobs, shared by both kinds of entry. */\ninterface NetworkPermissionPacing {\n /**\n * Requests per second the host will let through to this entry, capped by\n * the host's own ceiling. You can ask to be slower, never faster.\n *\n * Omit it and the host's default applies. Set it and `host.fetch` paces you\n * automatically, parking each call until there is headroom rather than\n * failing it, so there is nothing left for the plugin to implement. The\n * wait is spent from the call's budget, so see `host.remainingMs()` for\n * deciding whether the work still fits.\n */\n ratePerSecond?: number;\n\n /**\n * Name of the rate-limit bucket this entry draws from. Entries sharing a\n * bucket share one limiter; the default is the hostname the entry resolves\n * to.\n *\n * For when a published limit covers a service rather than a hostname.\n * `musicbrainz.org` and `*.musicbrainz.org` are two entries against one\n * 1 req/s policy, and without a shared bucket declaring both would quietly\n * buy 2 req/s and get the station blocked.\n */\n bucket?: string;\n}\n\n/**\n * One upstream a plugin may reach, named outright.\n *\n * The bare string shorthand means exactly this with no pacing set. Reach for\n * the object form when the upstream publishes a limit of its own: MusicBrainz\n * allows roughly one request per second to anonymous clients, a tenth of what\n * the host would otherwise let through.\n */\nexport interface NetworkPermissionHost extends NetworkPermissionPacing {\n /**\n * Bare hostname (`api.spotify.com`), no scheme and no path. A leading `*.`\n * marks a wildcard subdomain match (`*.example.com`) and does NOT match the\n * bare apex.\n */\n host: string;\n}\n\n/**\n * One upstream the operator names, not the plugin: the hostname comes from one\n * of your own config fields, resolved when the plugin is initialized.\n *\n * For anything self-hosted or mirrored, where there is no hostname to write\n * down at authoring time. A MusicBrainz mirror, a Navidrome server, an internal\n * API: the plugin declares which setting holds the address and the host reads\n * the hostname out of it.\n *\n * The value is read as a URL, or as a bare hostname if it does not look like\n * one. Empty, unparseable, or wildcard-bearing values simply contribute no\n * entry, so an unconfigured plugin is refused exactly as if it had asked for\n * an undeclared host. Because it resolves at init, changing the setting\n * reinitializes the plugin and the new address takes effect with it.\n *\n * ## One setting, several addresses\n *\n * A setting holding SEVERAL addresses contributes one entry each. That is for\n * the plugin whose upstreams are a list the operator pasted rather than one\n * server they run — a reader of feeds — where there is no honest number of\n * `url` fields to offer.\n *\n * The shape is one address per line (a `text` field), or the JSON array a\n * `multiselect` stores. Where a line carries more than the address, the address\n * is its last `|`-separated field, so `world|World news|https://…/feed.xml`\n * resolves to that host: a list wants labels, and fixing where they go keeps\n * the hostnames readable out of the operator's own text instead of out of a\n * plugin's private parser.\n *\n * Every rule above is applied per address rather than to the value as a whole,\n * so one mistyped line costs its own upstream and not the rest, and a wildcard\n * still cannot arrive from data. Repeats collapse: two feeds at one publisher\n * are one entry, or the second would install a limiter that doubles the rate\n * this entry asked to be paced at.\n */\nexport interface NetworkPermissionFromConfig extends NetworkPermissionPacing {\n /** Key of the config field holding the address, e.g. `baseUrl`. */\n fromConfig: string;\n}\n\n/** A hostname at the host's default rate, or an entry that says more. */\nexport type NetworkPermission = string | NetworkPermissionHost | NetworkPermissionFromConfig;\n\n/**\n * Something a plugin needs that only the OPERATOR can say yes to.\n *\n * The rest of {@link PluginPermissions} is disclosure: a manifest states what it\n * reaches and the host holds it to that, with nobody asked anything. A grant is\n * the other kind — a capability wide enough that a person should decide, per\n * install, with the plugin's own reason in front of them.\n *\n * ## The manifest is the request, and it is the only record of one\n *\n * Nothing is stored when a plugin asks. The host reads this on every discovery\n * and stores only the ANSWER, keyed by plugin and capability, so a plugin whose\n * manifest stops asking simply stops appearing and cannot be re-enabled by a row\n * nobody can see. A plugin that has not been answered is refused: undecided and\n * denied differ on the operator's page and nowhere else.\n *\n * There is no runtime ask. A `host.requestPermission()` would have to block a\n * plugin mid-work on a person who may be asleep, and a plugin that never runs\n * would never appear to be asked about.\n *\n * ## The vocabulary is the host's\n *\n * {@link capability} is one of a fixed list the HOST publishes, because a\n * capability only means something where the host enforces it. An id nothing\n * recognises is ignored with a warning rather than becoming a row that gates\n * nothing — a permission for a door that does not exist is worse than no\n * permission, since it reads on the page as though it were protecting something.\n */\nexport interface PluginGrantRequest extends NetworkPermissionPacing {\n /** A capability id the host publishes, e.g. `network.open`. */\n capability: string;\n /**\n * Why this plugin needs it, in one sentence, addressed to the operator.\n *\n * Required, and the field this whole shape exists to carry. It is what the\n * console shows beside the Allow control, and a manifest that cannot say why\n * it wants something does not get to ask for it: the alternative is an\n * operator deciding on a capability id alone, which is a decision nobody can\n * actually make.\n */\n reason: string;\n}\n\n/**\n * What a plugin is allowed to do. Declared up front in the manifest so the\n * host (and the operator installing the plugin) can see the full blast radius\n * before any plugin code runs.\n */\nexport interface PluginPermissions {\n /**\n * Hostname allowlist for `host.fetch()`: any request to a host not listed\n * here is rejected before it leaves the process.\n *\n * This describes what a plugin says it needs, and it is enforced on every\n * call (and every redirect hop) that goes through `host.fetch`. It is not\n * yet enforced against a plugin that reaches for global `fetch` instead,\n * because the host still imports plugin code into its own realm. Read this\n * field as a disclosure an operator can weigh before installing, not as a\n * containment guarantee.\n *\n * Entries are bare hostnames, {@link NetworkPermissionHost} objects when\n * the upstream needs pacing, or {@link NetworkPermissionFromConfig} when\n * the operator is the one who names it. Order matters only for pacing: the\n * first entry a hostname matches supplies its rate and bucket.\n */\n network: NetworkPermission[];\n\n /** Whether the plugin may use `host.storage` (namespaced key/value state). */\n storage: boolean;\n\n /** Whether the plugin may use `host.oauth` (redirect URI + token vault). */\n oauth: boolean;\n\n /**\n * Whether the plugin may use `host.trackFetcher` (lend the station's\n * fetcher a login, get back a URL).\n *\n * Optional, unlike the two above, because almost no plugin wants it: a\n * provider whose audio can be fetched with a URL mints one itself. Making\n * it required would put a `false` in every manifest to disclaim something\n * only one provider has ever needed.\n */\n trackFetcher?: boolean;\n\n /**\n * Capabilities this plugin is ASKING for, each with the reason an operator\n * reads before deciding. See {@link PluginGrantRequest}.\n *\n * Optional and usually absent: almost every plugin does its whole job inside\n * what it declares above, and a manifest that asks for nothing is the normal\n * case rather than a modest one.\n *\n * Nothing here is granted by declaring it. Until the operator answers, the\n * capability behaves exactly as it does for a plugin that never asked.\n */\n grants?: PluginGrantRequest[];\n}\n\n// Finite and positive: a zero or a NaN would compute a limiter window of\n// Infinity, which is a plugin that never gets to make a request.\nconst pacingShape = {\n ratePerSecond: z.number().positive().finite().optional(),\n bucket: z.string().min(1).optional(),\n};\n\nexport const networkPermissionSchema: z.ZodType<NetworkPermission> = z.union([\n z.string().min(1),\n z.object({ host: z.string().min(1), ...pacingShape }),\n z.object({ fromConfig: z.string().min(1), ...pacingShape }),\n]);\n\nexport const grantRequestSchema: z.ZodType<PluginGrantRequest> = z.object({\n capability: z.string().min(1),\n // Non-empty for the reason the field exists: an operator deciding on a\n // capability id with no sentence beside it is being asked a question they\n // cannot answer.\n reason: z.string().min(1),\n ...pacingShape,\n});\n\nexport const pluginPermissionsSchema = z.object({\n network: z.array(networkPermissionSchema),\n storage: z.boolean(),\n oauth: z.boolean(),\n trackFetcher: z.boolean().optional(),\n grants: z.array(grantRequestSchema).optional(),\n});\n"],"mappings":";;;;;AA8NO,IAAMA,0BAA0B;EACnC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAYG,IAAMC,wBAAwB;EACjC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAaG,IAAMC,6BAA6B;;;EAGtC;;;EAGA;;;;EAIA;;;;;EAKA;;;;ACpRG,IAAMC,0BAA0B;;;ACpChC,IAAMC,4BAA4B;AAClC,IAAMC,oCAAoC;AAE1C,IAAMC,8BAA8B;EAACF;EAA2BC;;;;ACMhE,IAAME,qBAAqB;EAC9B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAmBJ,IAAMC,wBAAsD,oBAAIC,IAAI;EAAC;EAAa;EAAa;CAAc;AAQtG,SAASC,qBAAqBC,MAAqB;AACtD,SAAOH,sBAAsBI,IAAID,IAAAA;AACrC;AAFgBD;AAahB,IAAMG,oBAAsD;EACxDC,MAAM;EACNC,QAAQ;EACRC,WAAW;EACXC,WAAW;EACXC,aAAa;EACbC,cAAc;EACdC,SAAS;EACTC,aAAa;EACbC,UAAU;EACVC,UAAU;AACd;AAmBA,IAAMC,qBAAqBC,uBAAOC,IAAI,yBAAA;AAqB/B,IAAMC,cAAN,cAA0BC,MAAAA;EAzJjC,OAyJiCA;;;;EAEpB,CAACJ,kBAAAA,IAAsB;;EAGhCb,OAAwB;;EAExBkB,YAAqBhB,kBAAkBU;;EAEvCO;;;;;EAKAC;EAEA,YAAYC,SAAiBC,SAA+B;AACxD,UAAMD,SAASC,OA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","names":["JSON_SAFE_PAYLOAD_TYPES","BOUNDARY_METHOD_TYPES","BOUNDARY_LIVE_OBJECT_TYPES","ANALYSIS_SCHEMA_VERSION","ENRICHMENT_MATCH_KEY_ISRC","ENRICHMENT_MATCH_KEY_ARTIST_TITLE","KNOWN_ENRICHMENT_MATCH_KEYS","PLUGIN_ERROR_CODES","RESOURCE_SCOPED_CODES","Set","isResourceScopedCode","code","has","RETRYABLE_BY_CODE","auth","config","not_found","forbidden","unsupported","rate_limited","timeout","unavailable","upstream","internal","PLUGIN_ERROR_BRAND","Symbol","for","PluginError","Error","retryable","retryAfterMs","upstreamStatus","message","options","Object","setPrototypeOf","prototype","name","withCode","withRetry","withUpstreamStatus","isPluginError","error","isForeignPluginError","value","toPluginError","fallback","known","includes","String","adopted","cause","Number","isFinite","errorText","collectGeneration","handle","signal","reader","text","getReader","result","catch","undefined","aborted","chunk","Promise","race","read","ABORTED","done","cancel","releaseLock","PluginError","withCode","Symbol","resolve","addEventListener","once","SPEECH_CUES","cuesIn","text","matchAll","cuePattern","map","match","toLowerCase","withoutCues","keep","kept","Set","replace","cue","has","trim","RegExp","sort","left","right","length","join","SPEECH_DELIVERIES","isSpeechDelivery","value","includes","MAX_UPSTREAM_MESSAGE","truncateUpstreamMessage","message","length","slice","upstreamField","body","pick","undefined","value","JSON","parse","retryAfterMs","header","seconds","Number","trim","isFinite","pluginCodeForStatus","status","upstreamDetail","statusText","reason","filter","part","join","HOST_FETCH_METHODS","hostFetchMethod","method","upper","toUpperCase","find","candidate","headersToRecord","headers","record","forEach","name","toLowerCase","NAMED_ENTITIES","amp","lt","gt","quot","apos","nbsp","ndash","mdash","hellip","lsquo","rsquo","ldquo","rdquo","decodeEntities","value","replace","whole","body","startsWith","code","toLowerCase","Number","parseInt","slice","isFinite","String","fromCodePoint","plainText","raw","maxChars","stripped","trim","length","undefined","truncateWords","cut","lastSpace","lastIndexOf","trimEnd","SENTENCE_END","ABBREVIATIONS","endsSentence","at","test","Math","max","firstSentence","text","lastIndex","match","exec","stop","index","truncateSentences","lastStop","wordsIn","split","filter","Boolean","closersIn","boundary","sentencesWithin","maxWords","fits","ending","ARTICLE_MAX_CHARS","MIN_PARAGRAPH_CHARS","FURNITURE","FURNITURE_CLASSES","REFERENCE_APPARATUS","SPONSOR_OPENERS","opensWithASponsor","text","said","toLowerCase","some","opener","startsWith","next","charAt","length","test","CONTAINERS","PARAGRAPH","extractArticle","html","maxChars","cleaned","replace","body","map","pattern","exec","find","found","undefined","paragraphs","match","matchAll","withoutApparatus","plainText","includes","push","truncateSentences","join","stripped","replacement","tidied","trim","fetchArticle","host","url","init","options","response","fetch","ok","cancel","error","PluginError","upstreamDetail","status","statusText","withCode","pluginCodeForStatus","upstreamStatus","advice","retryAfterMs","headers","get","withRetry","contentType","definePlugin","manifest","factory","XMLParser","FEED_SUMMARY_MAX_CHARS","parser","XMLParser","ignoreAttributes","attributeNamePrefix","removeNSPrefix","trimValues","parseTagValue","parseAttributeValue","parseFeed","xml","document","parse","items","isRecord","rss","record","rdf","RDF","atom","feed","channel","undefined","entries","asArray","item","entry","flatMap","readItem","title","speakable","homeUrl","readLink","link","description","plainText","summary","subtitle","author","readAuthor","imageUrl","readImage","image","language","text","categories","readCategories","category","length","explicit","readExplicit","fetchFeed","host","url","init","response","fetch","ok","body","cancel","error","PluginError","upstreamDetail","status","statusText","withCode","pluginCodeForStatus","upstreamStatus","advice","retryAfterMs","headers","get","withRetry","publishedAt","readDate","pubDate","published","date","updated","issued","encoded","content","creator","id","readId","enclosure","readEnclosure","durationMs","readDuration","duration","season","readOrdinal","episode","guid","hash","value","candidates","candidate","trimmed","rel","href","raw","at","Date","Number","isNaN","getTime","toISOString","first","name","seen","Set","visit","add","found","isAtomLink","webAddress","type","toLowerCase","lengthBytes","positiveWhole","push","find","startsWith","parts","split","map","part","trim","every","test","slice","some","seconds","reduce","total","ms","Math","round","isFinite","parsed","isSafeInteger","protocol","URL","asPlainText","accumulated","charCodeAt","imul","toString","padStart","Array","isArray","String","PUNCTUATION","PARENTHETICAL","normalize","value","replace","toLowerCase","trim","baseForm","split","PLUGIN_API_VERSION","Plugin","currentHost","disposers","host","undefined","PluginError","name","withCode","register","disposer","push","registerTimer","timer","clearTimeout","onLoad","Promise","resolve","onUnload","init","dispose","splice","reverse","error","logger","warn","plugin","Error","message","String","z","parseMultiSelect","raw","trim","length","parsed","JSON","parse","Array","isArray","filter","value","map","ROW_ID_KEY","rowSecretKey","fieldKey","rowId","columnKey","isRowSecretKey","key","split","parseRows","flatMap","entry","row","Object","entries","keys","configFieldOptionSchema","z","object","string","label","configFieldTypeSchema","enum","configFieldUnitSchema","configFieldControlSchema","configFieldOptionSourceSchema","noSeparator","includes","separatorMessage","configFieldColumnSchema","min","refine","type","required","boolean","optional","placeholder","options","array","optionsFrom","dependsOn","dependsOnValues","configFieldSchema","default","union","number","unit","control","step","max","help","columns","rangeWith","configString","value","undefined","trimmed","trim","length","configBaseUrl","replace","readRowSecret","host","fieldKey","row","columnKey","rowId","ROW_ID_KEY","secrets","get","rowSecretKey","BODY_SNIPPET_LENGTH","snippet","body","trimmed","trim","length","slice","jsonBody","response","text","JSON","parse","error","reason","Error","message","String","url","status","cause","tryJsonBody","undefined","z","z","pacingShape","ratePerSecond","number","positive","finite","optional","bucket","string","min","networkPermissionSchema","union","object","host","fromConfig","grantRequestSchema","capability","reason","pluginPermissionsSchema","network","array","storage","boolean","oauth","trackFetcher","grants","PLUGIN_CAPABILITY_CATALOG","PLUGIN_CAPABILITY_STREAM","PLUGIN_CAPABILITY_STEER","PLUGIN_CAPABILITY_OAUTH","PLUGIN_CAPABILITY_ENRICHMENT","PLUGIN_CAPABILITY_SPEECH","PLUGIN_CAPABILITY_LLM","PLUGIN_CAPABILITY_ANALYSIS","PLUGIN_CAPABILITY_MIXER","PLUGIN_CAPABILITY_CHARTS","PLUGIN_CAPABILITY_NEWS","PLUGIN_CAPABILITY_PODCAST","PLUGIN_CAPABILITY_SIMILARITY","PLUGIN_CAPABILITY_SEARCH","PLUGIN_CAPABILITY_WEATHER","PLUGIN_CAPABILITY_SCROBBLE","KNOWN_PLUGIN_CAPABILITIES","isZodSchema","value","z","ZodType","candidate","parse","safeParse","PLUGIN_ID_PATTERN","pluginManifestSchema","object","id","string","regex","name","min","version","capabilities","array","apiVersion","description","optional","homepage","icon","permissions","pluginPermissionsSchema","configFields","configFieldSchema","configSchema","custom","message"]}
|
|
1
|
+
{"version":3,"sources":["../src/boundary.json.safe.ts","../src/capabilities/analysis.ts","../src/capabilities/enrichment.ts","../src/plugin.error.ts","../src/capabilities/llm.ts","../src/capabilities/speech.ts","../src/plugin.http.ts","../src/html.text.ts","../src/article.parse.ts","../src/define.plugin.ts","../src/feed.parse.ts","../src/match.text.ts","../src/plugin.api.version.ts","../src/plugin.base.ts","../src/plugin.config.fields.ts","../src/plugin.config.read.ts","../src/plugin.host.response.ts","../src/plugin.manifest.ts","../src/plugin.permissions.ts"],"sourcesContent":["/**\n * Compile-time enforcement of the JSON-safe rule, over the payloads it applies\n * to.\n *\n * ## Which payloads, and why\n *\n * This rule used to cover everything a plugin touched, on the strength of a deferred move behind a\n * subprocess: JSON was going to be the wire format, so nothing could carry a `Date`, a `Uint8Array`\n * or a live object. That move is closed (`packages/plugin-sdk/CLAUDE.md` § \"Trust and egress\"), and\n * with it the reason to apply the rule to `host.fetch`'s arguments and return value, which nothing\n * serializes.\n *\n * What is registered here is what has an INDEPENDENT reason to survive\n * `JSON.parse(JSON.stringify(x))`: it is stored in Postgres, or sent to the\n * console over HTTP, or both. A `Date` in `TrackEnrichment` is a bug whether or\n * not plugins ever move anywhere, which is why that half of the rule outlived\n * the argument that introduced it.\n *\n * ## Why compile time\n *\n * The runtime conformance suite in `tests/` round-trips hand-written fixtures,\n * which catches a value that violates the rule despite a type that permits it\n * (a class instance where the type says `object`). What it cannot catch is a\n * newly added field: nothing populates it, so nothing round-trips it, and CI\n * stays green while a `Date` crosses the boundary.\n *\n * TypeScript types are erased, so no runtime walk can ever be exhaustive over\n * a type. That check has to happen at compile time, which is what this file\n * is. {@link AssertAllBoundaryPayloadsAreJsonSafe} fails `tsc` the moment any\n * field of any registered payload stops being JSON-safe, without anyone\n * writing a fixture for it.\n *\n * This lives in `src/` rather than `tests/` on purpose: per the repo\n * convention the build tsconfig includes only `./src/**\\/*`, so an assertion\n * in `tests/` would never run during `tsc --noEmit`. Everything here is types\n * plus three name arrays, so the runtime cost is the arrays alone.\n */\n\nimport type { AnalysisRef, TrackAnalysis, TrackCuePoints, TrackLoudness, TrackTaggedLoudness } from './capabilities/analysis.js';\nimport type { AudioJoin, AudioOverlay } from './capabilities/mixer.js';\nimport type { ChartDescriptor, ChartEntry, ChartQuery } from './capabilities/charts.js';\nimport type {\n NarrationPart,\n NarrationPiece,\n NarrationPiecesQuery,\n NarrationSeries,\n NarrationText,\n NarrationTextQuery,\n} from './capabilities/narration.js';\nimport type { NewsFeedDescriptor, NewsItem, NewsQuery } from './capabilities/news.js';\nimport type {\n PodcastAudio,\n PodcastDirectoryEntry,\n PodcastDirectoryQuery,\n PodcastEpisode,\n PodcastEpisodesQuery,\n PodcastShow,\n} from './capabilities/podcast.js';\nimport type { ScrobblePlay, ScrobbleRejection, ScrobbleResult } from './capabilities/scrobble.js';\nimport type { SearchQuery, SearchResult } from './capabilities/search.js';\nimport type { ArtistTrack, SimilarArtist } from './capabilities/similarity.js';\nimport type { WeatherConditions, WeatherDay, WeatherQuery, WeatherReading } from './capabilities/weather.js';\nimport type {\n AlbumEnrichment,\n AlbumRef,\n ArtistEnrichment,\n ArtistRef,\n ExternalId,\n ExternalLink,\n SourceDocument,\n TrackEnrichment,\n TrackRef,\n} from './capabilities/enrichment.js';\nimport type {\n GetPlaylistTracksOptions,\n ListPlaylistsOptions,\n PlaybackState,\n ProviderPlaylist,\n ProviderStream,\n ProviderTrack,\n SearchTracksOptions,\n} from './capabilities/music.provider.js';\nimport type { LlmMessage, LlmModelInfo, LlmRequest, LlmResult, LlmToolCall, LlmToolDeclaration, LlmUsage } from './capabilities/llm.js';\nimport type { SpeechLimits, SpeechRequest, SpeechVoice } from './capabilities/speech.js';\nimport type { ConfigField, ConfigFieldColumn, ConfigFieldOption } from './plugin.config.fields.js';\nimport type { PlaylistTracksRequest, TrackFetchRequest, TrackFetchSession } from './plugin.host.js';\nimport type { PluginConnectionResult } from './plugin.lifecycle.js';\nimport type { PluginManifest } from './plugin.manifest.js';\nimport type { NetworkPermissionFromConfig, NetworkPermissionHost, PluginGrantRequest, PluginPermissions } from './plugin.permissions.js';\n\n/**\n * `T` with every part that cannot survive `JSON.parse(JSON.stringify(x))`\n * replaced by `never`, so `T extends JsonSafe<T>` holds only for a genuinely\n * JSON-safe `T`.\n *\n * Deliberately narrower than structured-clone-safe. `Date`, `Map`, `Set` and\n * typed arrays all survive `structuredClone` and are still rejected here,\n * because what these payloads actually have to survive is Postgres and HTTP,\n * and both of those are JSON. A `Date` that round-trips through\n * `structuredClone` still comes back out of a `jsonb` column as a string.\n *\n * `any` and `unknown` pass through unchecked: there is nothing to inspect at\n * compile time, which is exactly the case the runtime fixture round-trip\n * covers.\n */\nexport type JsonSafe<T> = 0 extends 1 & T\n ? T // `any`\n : unknown extends T\n ? T // `unknown`\n : T extends string | number | boolean | null | undefined\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends Date | RegExp | Map<unknown, unknown> | Set<unknown> | WeakMap<object, unknown> | WeakSet<object> | Promise<unknown>\n ? never\n : T extends ArrayBuffer | SharedArrayBuffer | ArrayBufferView\n ? never\n : T extends bigint | symbol\n ? never\n : T extends readonly (infer TElement)[]\n ? readonly JsonSafe<TElement>[]\n : T extends object\n ? { [K in keyof T]: JsonSafe<T[K]> }\n : never;\n\n/**\n * `true` when `T` survives the boundary, `false` when any part of it does not.\n *\n * The tuple wrappers stop the conditional from distributing over a union, so a\n * field typed `string | Date` fails as a whole rather than partially matching.\n *\n * Written as a conditional rather than the more obvious\n * `T extends JsonSafe<T>` constraint, because TypeScript rejects that form as\n * a circular constraint (TS2313).\n */\ntype IsJsonSafe<T> = [T] extends [JsonSafe<T>] ? true : false;\n\n/**\n * Compiles only when every value in `T` is `true`. When one is not, the error\n * lands on that property, so the diagnostic names the offending boundary type.\n */\ntype AssertAllTrue<T extends Record<string, true>> = T;\n\n/**\n * Every payload that is stored or sent, asserted in one place.\n *\n * ADDING A BOUNDARY TYPE? Add it here and to {@link JSON_SAFE_PAYLOAD_TYPES}.\n * `tests/boundary.registry.test.ts` fails if an exported interface in a\n * boundary source file is in none of the three registries, so this cannot be\n * skipped by accident.\n *\n * `PluginManifest` is asserted without `configSchema`, which is a zod schema:\n * a class instance, deliberately never serialized, and stripped by the host\n * before a manifest is sent anywhere.\n */\nexport type AssertAllBoundaryPayloadsAreJsonSafe = AssertAllTrue<{\n PluginConnectionResult: IsJsonSafe<PluginConnectionResult>;\n PluginPermissions: IsJsonSafe<PluginPermissions>;\n NetworkPermissionHost: IsJsonSafe<NetworkPermissionHost>;\n NetworkPermissionFromConfig: IsJsonSafe<NetworkPermissionFromConfig>;\n PluginGrantRequest: IsJsonSafe<PluginGrantRequest>;\n ConfigField: IsJsonSafe<ConfigField>;\n ConfigFieldOption: IsJsonSafe<ConfigFieldOption>;\n ConfigFieldColumn: IsJsonSafe<ConfigFieldColumn>;\n PluginManifestWithoutConfigSchema: IsJsonSafe<Omit<PluginManifest, 'configSchema'>>;\n ProviderTrack: IsJsonSafe<ProviderTrack>;\n ProviderPlaylist: IsJsonSafe<ProviderPlaylist>;\n ProviderStream: IsJsonSafe<ProviderStream>;\n TrackFetchSession: IsJsonSafe<TrackFetchSession>;\n TrackFetchRequest: IsJsonSafe<TrackFetchRequest>;\n PlaylistTracksRequest: IsJsonSafe<PlaylistTracksRequest>;\n SearchTracksOptions: IsJsonSafe<SearchTracksOptions>;\n ListPlaylistsOptions: IsJsonSafe<ListPlaylistsOptions>;\n GetPlaylistTracksOptions: IsJsonSafe<GetPlaylistTracksOptions>;\n PlaybackState: IsJsonSafe<PlaybackState>;\n TrackRef: IsJsonSafe<TrackRef>;\n ArtistRef: IsJsonSafe<ArtistRef>;\n AlbumRef: IsJsonSafe<AlbumRef>;\n ExternalId: IsJsonSafe<ExternalId>;\n ExternalLink: IsJsonSafe<ExternalLink>;\n SourceDocument: IsJsonSafe<SourceDocument>;\n TrackEnrichment: IsJsonSafe<TrackEnrichment>;\n ArtistEnrichment: IsJsonSafe<ArtistEnrichment>;\n AlbumEnrichment: IsJsonSafe<AlbumEnrichment>;\n SpeechRequest: IsJsonSafe<SpeechRequest>;\n SpeechVoice: IsJsonSafe<SpeechVoice>;\n LlmMessage: IsJsonSafe<LlmMessage>;\n LlmToolDeclaration: IsJsonSafe<LlmToolDeclaration>;\n LlmToolCall: IsJsonSafe<LlmToolCall>;\n LlmUsage: IsJsonSafe<LlmUsage>;\n LlmRequest: IsJsonSafe<LlmRequest>;\n LlmResult: IsJsonSafe<LlmResult>;\n LlmModelInfo: IsJsonSafe<LlmModelInfo>;\n AnalysisRef: IsJsonSafe<AnalysisRef>;\n AudioJoin: IsJsonSafe<AudioJoin>;\n AudioOverlay: IsJsonSafe<AudioOverlay>;\n TrackCuePoints: IsJsonSafe<TrackCuePoints>;\n TrackLoudness: IsJsonSafe<TrackLoudness>;\n TrackTaggedLoudness: IsJsonSafe<TrackTaggedLoudness>;\n TrackAnalysis: IsJsonSafe<TrackAnalysis>;\n ChartDescriptor: IsJsonSafe<ChartDescriptor>;\n ChartQuery: IsJsonSafe<ChartQuery>;\n ChartEntry: IsJsonSafe<ChartEntry>;\n NewsFeedDescriptor: IsJsonSafe<NewsFeedDescriptor>;\n NewsQuery: IsJsonSafe<NewsQuery>;\n NewsItem: IsJsonSafe<NewsItem>;\n NarrationSeries: IsJsonSafe<NarrationSeries>;\n NarrationPiece: IsJsonSafe<NarrationPiece>;\n NarrationPart: IsJsonSafe<NarrationPart>;\n NarrationText: IsJsonSafe<NarrationText>;\n NarrationPiecesQuery: IsJsonSafe<NarrationPiecesQuery>;\n NarrationTextQuery: IsJsonSafe<NarrationTextQuery>;\n PodcastShow: IsJsonSafe<PodcastShow>;\n PodcastAudio: IsJsonSafe<PodcastAudio>;\n PodcastEpisode: IsJsonSafe<PodcastEpisode>;\n PodcastEpisodesQuery: IsJsonSafe<PodcastEpisodesQuery>;\n PodcastDirectoryQuery: IsJsonSafe<PodcastDirectoryQuery>;\n PodcastDirectoryEntry: IsJsonSafe<PodcastDirectoryEntry>;\n SimilarArtist: IsJsonSafe<SimilarArtist>;\n ArtistTrack: IsJsonSafe<ArtistTrack>;\n SearchQuery: IsJsonSafe<SearchQuery>;\n SearchResult: IsJsonSafe<SearchResult>;\n WeatherQuery: IsJsonSafe<WeatherQuery>;\n WeatherConditions: IsJsonSafe<WeatherConditions>;\n WeatherDay: IsJsonSafe<WeatherDay>;\n WeatherReading: IsJsonSafe<WeatherReading>;\n ScrobblePlay: IsJsonSafe<ScrobblePlay>;\n ScrobbleRejection: IsJsonSafe<ScrobbleRejection>;\n ScrobbleResult: IsJsonSafe<ScrobbleResult>;\n SpeechLimits: IsJsonSafe<SpeechLimits>;\n}>;\n\n/**\n * Names of the interfaces asserted above. Kept as a runtime array so the\n * registry-coverage test can compare it against what is actually exported\n * from the boundary source files.\n */\nexport const JSON_SAFE_PAYLOAD_TYPES = [\n 'PluginConnectionResult',\n 'PluginPermissions',\n 'NetworkPermissionHost',\n 'NetworkPermissionFromConfig',\n 'PluginGrantRequest',\n 'ConfigField',\n 'ConfigFieldOption',\n 'ConfigFieldColumn',\n 'PluginManifest',\n 'ProviderTrack',\n 'ProviderPlaylist',\n 'ProviderStream',\n 'TrackFetchSession',\n 'TrackFetchRequest',\n 'PlaylistTracksRequest',\n 'SearchTracksOptions',\n 'ListPlaylistsOptions',\n 'GetPlaylistTracksOptions',\n 'PlaybackState',\n 'TrackRef',\n 'ArtistRef',\n 'AlbumRef',\n 'ExternalId',\n 'ExternalLink',\n 'SourceDocument',\n 'TrackEnrichment',\n 'ArtistEnrichment',\n 'AlbumEnrichment',\n 'SpeechRequest',\n 'SpeechVoice',\n 'LlmMessage',\n 'LlmToolDeclaration',\n 'LlmToolCall',\n 'LlmUsage',\n 'LlmRequest',\n 'LlmResult',\n 'LlmModelInfo',\n 'AnalysisRef',\n 'AudioJoin',\n 'AudioOverlay',\n 'TrackCuePoints',\n 'TrackLoudness',\n 'TrackTaggedLoudness',\n 'TrackAnalysis',\n 'ChartDescriptor',\n 'ChartQuery',\n 'ChartEntry',\n 'NewsFeedDescriptor',\n 'NewsQuery',\n 'NewsItem',\n 'NarrationSeries',\n 'NarrationPiece',\n 'NarrationPart',\n 'NarrationText',\n 'NarrationPiecesQuery',\n 'NarrationTextQuery',\n 'PodcastShow',\n 'PodcastAudio',\n 'PodcastEpisode',\n 'PodcastEpisodesQuery',\n 'PodcastDirectoryQuery',\n 'PodcastDirectoryEntry',\n 'SimilarArtist',\n 'ArtistTrack',\n 'SearchQuery',\n 'SearchResult',\n 'WeatherQuery',\n 'WeatherConditions',\n 'WeatherDay',\n 'WeatherReading',\n 'ScrobblePlay',\n 'ScrobbleRejection',\n 'ScrobbleResult',\n 'SpeechLimits',\n] as const;\n\n/**\n * Boundary interfaces that are deliberately NOT payloads: they describe\n * methods, so they carry functions by definition and can never be JSON-safe.\n *\n * The arguments and return values of those methods are payloads, and those\n * are what {@link JSON_SAFE_PAYLOAD_TYPES} covers. Listing the method-bearing\n * interfaces explicitly is what makes \"is this a payload or a contract?\" a\n * conscious decision for anyone adding one, rather than a silent omission.\n */\nexport const BOUNDARY_METHOD_TYPES = [\n 'PluginLogger',\n 'PluginStorage',\n 'PluginSecrets',\n 'PluginConfigAccess',\n 'PluginOAuth',\n 'PluginEvents',\n 'PluginTrackFetcher',\n 'PluginHost',\n 'PluginLifecycle',\n 'MusicProviderCatalog',\n 'MusicProviderStream',\n 'MusicProviderSteer',\n 'MusicProviderOAuth',\n 'MusicProvider',\n 'EnrichmentProvider',\n 'SpeechPluginInstance',\n 'LlmPluginInstance',\n 'AnalysisProvider',\n 'MixerProvider',\n 'ChartsProvider',\n 'NewsProvider',\n 'NarrationProvider',\n 'PodcastProvider',\n 'SimilarityProvider',\n 'SearchProvider',\n 'WeatherProvider',\n 'ScrobbleProvider',\n] as const;\n\n/**\n * Boundary interfaces that deliberately carry a LIVE object, and so are neither\n * payloads nor method contracts.\n *\n * The host and the plugin share a realm, permanently (see `packages/plugin-sdk/CLAUDE.md` § \"Trust\n * and egress\"), so handing over a real `AbortSignal` or a real stream is the correct design rather\n * than a shortcut around the rule. They are listed rather than simply left out, because the\n * registry-coverage test treats an unclassified boundary interface as an omission, and \"this one\n * holds a live object on purpose\" is a decision somebody should have to make in writing.\n */\nexport const BOUNDARY_LIVE_OBJECT_TYPES = [\n // `signal`: the invocation's own `AbortSignal`, watched by `host.fetch` and\n // passed on by the plugin to anything else that takes one.\n 'HostFetchInit',\n // `audio`: the engine's response body, usually forwarded straight through,\n // so the bytes are never held whole on either side of the call.\n 'SpeechHandle',\n // `audio`: the same thing one capability over, on the `mixer` side. A joined production is the\n // longest single piece of audio the station ever makes, so holding it whole\n // is the one thing this must not do.\n 'JoinedAudio',\n // `text`: the words as the model produces them, and `result`: a promise that\n // settles when it stops. The stream is what lets the host hold its single\n // model slot until the generation really ends rather than until the call\n // returns. `LlmResult` is the payload half, and it is JSON-safe.\n 'LlmHandle',\n] as const;\n","/**\n * The `analysis` capability. An analysis plugin takes one track's audio and\n * answers with measurements of it: where the record actually starts, where it is\n * underway, where the ending begins, where it stops.\n *\n * ## Why this is not enrichment\n *\n * Enrichment asks an upstream what it KNOWS about a recording, fans the question\n * out to everything that answers, and merges the results in priority order.\n * Nothing here works that way. There is no upstream that knows where a record's\n * outro begins — it is computed from the samples — so there is exactly one\n * answer, one source, and nothing to merge. `TrackRef` also carries no way to\n * reach the audio, deliberately, because no enrichment source wants it.\n *\n * ## The plugin is an adapter, not an analyzer\n *\n * Measuring any of this needs decoded PCM, and decoding is the one thing this\n * tree does not do in Node. So the expected implementation is a thin adapter over\n * a separate program: take {@link AnalysisRef.audioUrl}, hand it to whatever\n * actually decodes, return what comes back. That is the same relationship the\n * bundled speech plugin has with its engine.\n *\n * The reason is decoding and only decoding. An earlier version of this note also claimed the\n * boundary bought a licence position, which `analysis/README.md` § \"The rule, stated once\" retired:\n * nothing copyleft or non-commercial enters the analysis path in the first place, so there is no\n * position for the boundary to buy.\n *\n * Nothing here requires that shape. A plugin that can measure audio some other\n * way is a valid implementation; this note exists so the first one is not\n * mistaken for the contract.\n *\n * ## Why joining audio is not here\n *\n * It was, for one commit, as an optional `joinAudio`. The argument was the\n * paragraph above read backwards — joining needs decoded PCM too, so it wants the\n * same adapter over the same program, and the bundled plugin does serve both off\n * one sidecar with one address. That much is still true and is why there is still\n * one plugin.\n *\n * What it got wrong is that a capability is the unit of SELECTION, not of\n * implementation: the host picks one plugin per capability, so a joiner carried\n * here is whichever plugin the operator chose to MEASURE with. `capabilities/mixer.ts`\n * has the rest of it.\n *\n * ## What the host guarantees, and what it cannot\n *\n * The host resolves {@link AnalysisRef.audioUrl} because a plugin cannot: the\n * copy that can actually be served is a binding the catalog owns, and asking one\n * plugin to go through another is not a thing the host permits. In exchange the\n * host cannot see the bytes, so it cannot tell a complete download from a\n * truncated one — which is why {@link TrackAnalysis.complete} is reported rather\n * than inferred. See the note on that field: it is the difference between a cache\n * that can be trusted and one that cannot.\n *\n * Every shape here is JSON-safe. Offsets are integer milliseconds.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\n\n/**\n * The current shape of {@link TrackAnalysis.data}.\n *\n * Bumped whenever a detector's output changes shape, which is what lets a stored\n * row be recognised as STALE rather than read as missing or, worse, as current.\n * Reanalysis then falls out of an ordinary \"needs work\" query instead of needing\n * a migration.\n *\n * **An OPTIONAL field being added is not that**, and does not bump this. Every\n * consumer of `data` already has a defined answer for a field that is absent —\n * it has to, since an analyzer may not compute one — so a row written before the\n * field existed is still a correct row of this version rather than a stale one.\n * Bumping for it would mark the whole catalog for re-measurement to gain\n * something the station degrades over anyway. `tagGainDb` and friends arrived\n * exactly this way.\n *\n * The host compares this against what it stored, so a plugin must report the\n * version it actually produced rather than this constant, in case the two have\n * drifted apart across an upgrade.\n */\nexport const ANALYSIS_SCHEMA_VERSION = 1;\n\n/**\n * How the host asks about one track.\n *\n * Deliberately not a {@link TrackRef}: nothing here is matched by name, so\n * artist and title would be decoration. What a measurement needs is bytes and a\n * way to tell a truncation from a short record.\n */\nexport interface AnalysisRef {\n /**\n * The canonical catalog id for this track.\n *\n * Passed for the plugin's own logging and for its own caching, if it keeps\n * any. It is not a key into anything the plugin can read, and the host does\n * not expect it back.\n */\n trackId: string;\n\n /**\n * A complete, fetchable URL for the audio, resolved by the host.\n *\n * Carries its own authentication, exactly as the playout URLs do: whatever\n * fetches this sends no headers on the host's behalf. It may be short-lived,\n * so fetch it during the call rather than storing it.\n *\n * **It has to be reachable from wherever the decoding happens**, which is not\n * necessarily where this plugin runs. An address that resolves inside the\n * API process and not inside a sidecar container is the first thing to check\n * when every analysis fails at once.\n */\n audioUrl: string;\n\n /**\n * How long the catalog believes the track is.\n *\n * A cross-check rather than an input to any measurement: audio that decodes\n * to appreciably less than this was truncated, and measuring it would report\n * a confident cold ending for a record that fades. Absent when the catalog\n * never learned a duration, which is ordinary and is not a reason to refuse.\n */\n durationMs?: number;\n}\n\n/**\n * The four points, all absolute offsets into the file.\n *\n * Including `cueOut`. Storing it relative to `cueIn` is the obvious-looking\n * choice and it is wrong: everything downstream seeks in file time, so a relative\n * figure has to be re-based at every read, and eventually one read does not.\n *\n * Two lengths fall out — `intro = introEnd - cueIn` and\n * `outro = cueOut - outroStart` — and they are what a transition is actually\n * sized from. Neither is stored, because a stored derivation is a second thing\n * that can disagree with the first.\n */\nexport interface TrackCuePoints {\n /** Where audio actually starts, past the leading silence. */\n cueIn: number;\n\n /**\n * Where the record is fully underway: the beat established, or the vocal in.\n *\n * The talk-up limit, and one of the two points that is real work. A detector\n * weighted to low frequencies places this early on a record that opens with\n * a pad, which reads as \"the intro is over\" while it plainly is not.\n */\n introEnd: number;\n\n /**\n * Where the ending begins, so the earliest a blend may start.\n *\n * The other real one, and the one with a specific failure to design against:\n * a low-frequency-weighted detector places it too early on a quiet ending,\n * which is exactly the case an ending-aware transition exists to serve.\n */\n outroStart: number;\n\n /** Where audio actually stops, before the trailing silence. */\n cueOut: number;\n}\n\n/**\n * How loud the record is, and how close it already runs to its ceiling.\n *\n * Every field is OPTIONAL, and absent is a real answer rather than a gap: a\n * silent or near-silent track has no loudness, and the alternative to omitting\n * it is a floor value like -80 that a caller would then \"correct\" by fifty\n * decibels. Absent means no opinion, which is what every consumer of these\n * measurements already has to handle.\n *\n * They are also optional in the weaker sense that an analyzer may not compute\n * them at all. A plugin that only finds cue points is a valid analyzer; a\n * station reading these has to degrade to its live normalizer, which is what it\n * does today anyway.\n */\nexport interface TrackLoudness {\n /**\n * Gated programme loudness in LUFS, to ITU-R BS.1770.\n *\n * Gated, which is the whole difference between this and an average level: a\n * record with a long quiet outro is as loud as its body, not as loud as its\n * mean. The station's per-track gain is the distance from this to whatever\n * target it holds.\n */\n integratedLufs?: number;\n\n /**\n * The highest inter-sample peak in dBTP, which is what caps a boost.\n *\n * Distinct from {@link samplePeakDb} and the distinction is the point: the\n * reconstructed waveform between two samples can exceed both of them,\n * routinely by around a decibel. A gain computed against sample peak alone\n * is how a quiet master gets lifted into clipping, so this is the number a\n * boost has to respect.\n *\n * Legitimately positive. A value above 0 dBTP means the master already\n * overshoots on playback, which is worth knowing before adding anything.\n */\n truePeakDb?: number;\n\n /**\n * The highest actual sample, in dBFS.\n *\n * Carried alongside the true peak rather than instead of it, because the gap\n * between them is diagnostic: a wide one means the master is already fighting\n * its own ceiling.\n */\n samplePeakDb?: number;\n}\n\n/**\n * What the FILE says about its own loudness, as opposed to what was measured.\n *\n * A different kind of claim from {@link TrackLoudness}, which is why it is a\n * different interface: those fields are this analyzer's opinion, and these are\n * whoever mastered or scanned the record telling the station what they decided.\n * A consumer that prefers one over the other has to be able to tell them apart,\n * which it cannot do if they arrive in the same field.\n *\n * All optional, and most files carry none of them.\n */\nexport interface TrackTaggedLoudness {\n /**\n * The gain the file's own tags ask for, in dB.\n *\n * **Meaningless without {@link tagReferenceLufs}**, and that is the whole\n * reason both are reported. A gain is a correction relative to some level,\n * and the two conventions in the wild are five decibels apart, so a station\n * that stored only this would be storing the answer to a question it can no\n * longer ask.\n */\n tagGainDb?: number;\n\n /**\n * The loudness {@link tagGainDb} is relative to, in LUFS.\n *\n * -23 for an R128 tag, where the specification fixes it. -18 for a\n * ReplayGain tag, where it is an ASSUMPTION: ReplayGain 2.0 targets -18 and\n * every current scanner writes it, but the older convention used the same\n * tag names with no version field, so a file carrying it is\n * indistinguishable from the outside.\n *\n * Subtracting this pair gives the loudness the tagger believed the record\n * has, which is the figure a station's own target applies to.\n */\n tagReferenceLufs?: number;\n\n /**\n * The peak the file's tags declare, in dBFS.\n *\n * A SAMPLE peak, always, because that is what ReplayGain defines. It is not\n * a substitute for {@link TrackLoudness.truePeakDb} and nothing should cap a\n * boost with it; it is stored because the gap between the two says how hard\n * the master is already running.\n */\n tagPeakDb?: number;\n}\n\n/**\n * What one analysis produced.\n *\n * `data` is deliberately the only place measurements live, and the host stores\n * it whole without reading the individual fields. That is what lets a later\n * schema version add a beat grid or a vocal curve without touching the plugin\n * contract, the host, or the database.\n */\nexport interface TrackAnalysis {\n /**\n * The shape of {@link data}, as this plugin actually produced it.\n *\n * Report what was measured rather than {@link ANALYSIS_SCHEMA_VERSION}: an\n * adapter over a separate analyzer is reporting that analyzer's version, and\n * the two drift the moment one of them is upgraded and the other is not. A\n * version the host does not know is a configuration problem it can name,\n * where a wrong one is a row nothing can read and nothing can explain.\n */\n schemaVersion: number;\n\n /**\n * Whether the whole file was measured.\n *\n * **Load-bearing, and the host cannot check it.** A byte-capped, idle-timed\n * out or otherwise truncated download produces perfectly confident\n * measurements of a file that was never the track, and the specific lie it\n * tells is that a record which fades ended cold. Since the host never sees\n * the bytes, this is the only signal that separates a measurement worth\n * keeping from one worth discarding, and a plugin that always answers `true`\n * has quietly disabled the check.\n *\n * A genuinely short track is `true`. A download that stopped early is\n * `false`, and the two are told apart with {@link AnalysisRef.durationMs}\n * where there is one.\n */\n complete: boolean;\n\n /**\n * The measurements, in the shape {@link schemaVersion} names.\n *\n * Typed as the v1 fields plus room to grow rather than as a closed\n * interface, because the host passes it through unread. A v2 payload with a\n * tempo and a downbeat grid is the same call, the same plugin method, and a\n * different number above.\n *\n * The cue points are required and the loudness is not, which reflects what\n * each costs to produce: the points come from the decode that has already\n * happened, where loudness needs a filter chain an analyzer may reasonably\n * not implement.\n */\n data: TrackCuePoints & TrackLoudness & TrackTaggedLoudness & Record<string, unknown>;\n\n /**\n * How long the audio turned out to be once decoded.\n *\n * The honest figure, as opposed to the catalog's claim in\n * {@link AnalysisRef.durationMs}. Worth reporting even when the two agree:\n * where they do not, this is the one that the offsets above are on the same\n * timeline as.\n */\n durationMs?: number;\n\n /**\n * What did the measuring, as a name and version.\n *\n * Stored as provenance, so a row can be attributed after the fact when a\n * detector turns out to have been wrong about a class of records. Free-text\n * and never parsed.\n */\n analyzer?: string;\n}\n\n/** A plugin that can measure a track's audio. */\nexport interface AnalysisProvider extends PluginLifecycle {\n /**\n * Measure one track.\n *\n * One track per call, with no batch sibling: the unit of work is one file's\n * bytes, and there is no upstream round trip to amortise across several. How\n * many run at once is the host's decision, taken against hardware it can see\n * and this plugin cannot.\n *\n * Expect to be given minutes rather than seconds — decoding a full record is\n * not a request, it is a job — but honour `host.signal` all the same, because\n * a station shutting down should not wait on a measurement nobody will read.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no analyzer address), `upstream` when the audio could not be fetched or\n * could not be decoded, `timeout` when the analyzer did not answer.\n */\n analyzeTrack(ref: AnalysisRef): Promise<TrackAnalysis>;\n}\n","/**\n * The `enrichment` kind. An enrichment plugin takes a reference to something in\n * the catalog and returns extra facts about it: the stuff the DJ talks over,\n * the stuff the UI shows. Several enrichment plugins run for the same thing and\n * the host merges their results in `priority` order.\n *\n * Three references, three answers, because the facts have three different\n * lifetimes and three different costs. A recording is asked about once per\n * track, an artist once per artist, and a record once per album — so a rotation\n * that revisits the same few hundred artists pays for them once rather than\n * once per song. Only `enrichTrack` is required.\n *\n * Every shape here is JSON-safe.\n */\n\n/**\n * How an enrichment plugin is asked to identify a track. `isrc` is the\n * preferred key when present; otherwise match on the normalised\n * `artist|title` pair.\n */\nexport interface TrackRef {\n /** Recording ISRC. When set, prefer it over the string fields. */\n isrc?: string;\n /**\n * MusicBrainz RECORDING id, when the catalog has resolved one.\n *\n * The mirror of {@link ArtistRef.mbid} and {@link AlbumRef.mbid}, and it\n * arrives the same way: something already resolved it and the host promoted\n * it onto `tracks.mbid`, so a later pass gets it for free. Prefer it over\n * every other field here when your source can take one, because it is the\n * only identifier in this shape that cannot match the wrong record.\n */\n mbid?: string;\n /** Primary artist name, as the source provider spells it. */\n artist: string;\n title: string;\n album?: string;\n durationMs?: number;\n /** Release year, when known. Cheap disambiguator for covers and re-issues. */\n year?: number;\n}\n\n/** Match keys an enrichment plugin can look a track up by. */\nexport const ENRICHMENT_MATCH_KEY_ISRC = 'isrc';\nexport const ENRICHMENT_MATCH_KEY_ARTIST_TITLE = 'artist-title';\n\nexport const KNOWN_ENRICHMENT_MATCH_KEYS = [ENRICHMENT_MATCH_KEY_ISRC, ENRICHMENT_MATCH_KEY_ARTIST_TITLE] as const;\n\nexport type EnrichmentMatchKey = (typeof KNOWN_ENRICHMENT_MATCH_KEYS)[number];\n\n/** A named external identifier, e.g. `{ source: 'musicbrainz', id: '...' }`. */\nexport interface ExternalId {\n source: string;\n id: string;\n}\n\n/** A link out to the source, shown in the UI and usable as a citation. */\nexport interface ExternalLink {\n label: string;\n url: string;\n}\n\n/**\n * A piece of PROSE about this thing, handed over for the host to read rather\n * than for anyone to say.\n *\n * The difference between this and {@link TrackEnrichment.facts} is who wrote\n * the sentence. A `fact` is a line your plugin composed and is willing to have\n * spoken on air unchanged. A document is somebody else's article, verbatim: the\n * host extracts claims from it, checks each claim against the text, and keeps\n * the provenance. Nothing reads a document aloud, and nothing shows one on a\n * page.\n *\n * Hand over the prose rather than your own summary of it. The host stores it,\n * so a better extraction later costs no request to your upstream, and\n * `sourceQuote` on the claims it produces has to be a span that really occurs\n * in `text` or the claim is dropped.\n *\n * Plain text, not HTML and not wiki markup. Strip the furniture (navigation,\n * licence footers, reference markers) the way a reader would ignore it.\n */\nexport interface SourceDocument {\n /** Where this text can be read by a person. Becomes the claim's citation, so it must be public. */\n url: string;\n /** The document's own title, e.g. the article name. */\n title: string;\n /** The prose, as plain text. */\n text: string;\n /** ISO-8601 instant. Never a `Date`. */\n retrievedAt: string;\n}\n\n/**\n * How an enrichment plugin is asked to identify an artist.\n *\n * Two identifiers, and the difference between them matters. `mbid` is the one\n * id that crosses providers, so a plugin that is not MusicBrainz can still use\n * it (Last.fm takes one directly) or ignore it and match on `name`.\n * `providerRef` is this plugin's *own* last id for this artist, handed back so\n * a second pass is a lookup rather than another search. Neither is promised:\n * the first time anything asks about an artist, all there is is a name.\n */\nexport interface ArtistRef {\n /** Canonical artist name, as the catalog holds it. */\n name: string;\n /** MusicBrainz artist id, when the catalog has resolved one. */\n mbid?: string;\n /** The id this plugin itself used last time it answered about this artist. */\n providerRef?: string;\n}\n\n/** The album equivalent of {@link ArtistRef}. `mbid` is a MusicBrainz release-group id. */\nexport interface AlbumRef {\n name: string;\n /** The album's artist, because a title alone does not identify a record. */\n artist: string;\n /** MusicBrainz release-group id, when the catalog has resolved one. */\n mbid?: string;\n providerRef?: string;\n}\n\n/**\n * The union of facts enrichment can contribute. Every field is optional: a\n * plugin returns a `Partial<TrackEnrichment>` containing only what it knows.\n */\nexport interface TrackEnrichment {\n /**\n * Your own id for this thing, so the next pass can be a lookup rather than\n * another search. The mirror of the `providerRef` on the ref you were\n * handed: the host stores it against your plugin and gives it back to you,\n * and only to you.\n *\n * Say it outright whenever you have one. It is not identity and it is not a\n * claim about anyone else's ids — put those in {@link externalIds}, in\n * whatever order suits you.\n */\n providerRef: string;\n\n /** Canonical artist name, if the source has a better spelling than the provider. */\n artist: string;\n title: string;\n album: string;\n\n /** Original release year of the recording. */\n year: number;\n /** ISO-8601 date string (`YYYY-MM-DD` or `YYYY`). Never a `Date`. */\n releaseDate: string;\n\n genres: string[];\n moods: string[];\n\n /** Free-text background: label, session players, chart history. DJ patter fodder. */\n biography: string;\n /** Short trivia lines, each independently speakable. */\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n\n /** Beats per minute. */\n bpm: number;\n /** Musical key, e.g. `A minor`. */\n musicalKey: string;\n\n label: string;\n isrc: string;\n\n artworkUrl: string;\n\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * What enrichment can say about an artist rather than a recording.\n *\n * Asked once per artist instead of once per track, which is the whole point of\n * it being a separate method: a rotation revisits the same few hundred artists\n * constantly, and an artist's background changes on a scale of years.\n */\nexport interface ArtistEnrichment {\n /** Your own id for this artist. See {@link TrackEnrichment.providerRef}. */\n providerRef: string;\n\n /** Canonical artist name, if the source has a better spelling than the provider. */\n name: string;\n /** Free-text background. DJ patter fodder. */\n biography: string;\n /** Short trivia lines, each independently speakable. */\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n genres: string[];\n imageUrl: string;\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * What enrichment can say about a record rather than a recording.\n *\n * The label, the pressing and the cover belong to the release, not to any one\n * track on it, so they are asked for once per album. A track's own\n * `TrackEnrichment.label` is still meaningful for a single that was never on a\n * record, or for a compilation whose tracks were licensed separately.\n */\nexport interface AlbumEnrichment {\n /** Your own id for this record. See {@link TrackEnrichment.providerRef}. */\n providerRef: string;\n\n name: string;\n /** Canonical artist name for the album, which is not always the track's. */\n artist: string;\n /** Release year of this record, as opposed to of any recording on it. */\n year: number;\n /** ISO-8601 date string (`YYYY-MM-DD` or `YYYY`). Never a `Date`. */\n releaseDate: string;\n label: string;\n genres: string[];\n facts: string[];\n /** Source prose for the host to extract claims from. See {@link SourceDocument}. */\n documents: SourceDocument[];\n artworkUrl: string;\n externalIds: ExternalId[];\n links: ExternalLink[];\n}\n\n/**\n * Implemented by an `enrichment` plugin.\n */\nexport interface EnrichmentProvider {\n /**\n * Lower runs first and wins conflicts on merge. Use 100 for a canonical\n * source (MusicBrainz), 500 for a supplementary one, 900 for a guess.\n */\n priority: number;\n\n /** Which of the {@link TrackRef} fields this plugin can actually match on. */\n matchKeys: EnrichmentMatchKey[];\n\n /**\n * How many refs this plugin will take in one {@link enrichTracks} call.\n *\n * The host chunks to it, so a batch call is a small fixed number of upstream\n * round trips and can be given a deadline that means something. Ignored by a\n * plugin that does not implement `enrichTracks`.\n */\n maxBatchSize?: number;\n\n /** Return only the fields you actually resolved. Return `{}` on no match. */\n enrichTrack(ref: TrackRef): Promise<Partial<TrackEnrichment>>;\n\n /**\n * The same question as {@link enrichTrack}, asked about several tracks at\n * once. Optional, the way {@link enrichArtist} is: the host loops\n * `enrichTrack` for any plugin that does not write it, so implementing it is\n * an optimisation and never a requirement.\n *\n * Implement it only where the upstream can genuinely answer about many at\n * once. A source paced at a request per second is the case this exists for:\n * one query that identifies twenty-five tracks costs a second, where\n * twenty-five `enrichTrack` calls cost twenty-five.\n *\n * Index-aligned with `refs`: entry `i` is the answer about `refs[i]`, the\n * returned array is the same length, and `{}` at a position is a miss\n * exactly as it is for `enrichTrack`. A source that could only account for\n * some of the batch returns `{}` for the rest rather than a short array.\n */\n enrichTracks?(refs: TrackRef[]): Promise<Partial<TrackEnrichment>[]>;\n\n /**\n * Optional, the way a music provider's `resolveStreamUrl` is: a source\n * that only knows recordings is still a valid enrichment plugin, and the\n * host asks nothing of a plugin that did not write the method.\n *\n * Implement it for anything that belongs to the artist rather than to one\n * of their recordings. The host asks once per artist, so the same answer\n * covers every track they appear on.\n */\n enrichArtist?(ref: ArtistRef): Promise<Partial<ArtistEnrichment>>;\n\n /** The album equivalent of {@link enrichArtist}, asked once per record. */\n enrichAlbum?(ref: AlbumRef): Promise<Partial<AlbumEnrichment>>;\n}\n","/**\n * The one error shape that means the same thing on both sides of the plugin\n * boundary.\n *\n * Deliberately NOT a subclass of the API's `ServerkitError`: that class lives\n * in `@maroonedsoftware/errors`, a server dependency, and inheriting from it\n * here would pull the host's framework into every plugin's dependency tree,\n * which is exactly what this package exists to avoid. So a plugin says what\n * went wrong in its own vocabulary and the host decides what that means over\n * HTTP (see `plugin.error.http.ts` in the API).\n *\n * The classification fields (`code`, `retryable`, `retryAfterMs`,\n * `upstreamStatus`, `message`) are all JSON-safe on purpose: nothing here\n * crosses the boundary as a live object today, but the day plugins move out of\n * process, what has to change is how this is transported, not what it says.\n * `cause` is the exception, and is in-process debugging detail only.\n */\n\n/**\n * Why a plugin call failed, in terms the host can act on.\n *\n * These are semantic, not HTTP: an upstream's status code is a diagnostic\n * (`upstreamStatus`), never the answer to what this API should respond. A\n * provider's 404 and \"no plugin by that id\" are not the same 404.\n */\nexport type PluginErrorCode =\n /** Credentials are missing, expired or rejected. The operator has to reauthorize. */\n | 'auth'\n /** The plugin's stored settings are wrong or incomplete. The operator has to fix the form. */\n | 'config'\n /** The upstream has no such resource. Often not an error at all to the caller. */\n | 'not_found'\n /**\n * The upstream understood, and refused for this specific resource. Distinct\n * from `auth`: the credentials are fine and the next call for something\n * else will succeed.\n */\n | 'forbidden'\n /** The upstream is throttling. Honour `retryAfterMs` when it is set. */\n | 'rate_limited'\n /** The call did not finish in time. */\n | 'timeout'\n /** The plugin or its upstream is temporarily out of service. */\n | 'unavailable'\n /** The plugin does not implement what was asked of it. */\n | 'unsupported'\n /** The upstream answered, and what it said was a failure. */\n | 'upstream'\n /** Anything else, including a bug in the plugin. */\n | 'internal';\n\n/** Every {@link PluginErrorCode}, for validating a code that arrived from third-party code. */\nexport const PLUGIN_ERROR_CODES = [\n 'auth',\n 'config',\n 'not_found',\n 'forbidden',\n 'rate_limited',\n 'timeout',\n 'unavailable',\n 'unsupported',\n 'upstream',\n 'internal',\n] as const;\n\n/**\n * Codes that describe the thing that was asked for rather than the health of\n * the plugin that was asked.\n *\n * This is a separate question from {@link RETRYABLE_BY_CODE}, and conflating\n * the two is a trap. \"Retrying will not help\" and \"this plugin is sick\" feel\n * like the same statement and are not: asking Spotify for a playlist you do\n * not own is refused every time, forever, while the connection it was asked\n * over is in perfect health. A host that counts those refusals as failures\n * eventually quarantines a working plugin for correctly answering the\n * question it was asked.\n *\n * So a resource-scoped failure is reported to the caller and otherwise\n * forgotten: it neither trips a circuit breaker nor clears one, because it is\n * not evidence in either direction.\n */\nconst RESOURCE_SCOPED_CODES: ReadonlySet<PluginErrorCode> = new Set(['not_found', 'forbidden', 'unsupported']);\n\n/**\n * Whether this failure is about the requested resource rather than the plugin.\n *\n * See {@link RESOURCE_SCOPED_CODES}. Hosts use this to decide whether a\n * failure counts against a plugin's health.\n */\nexport function isResourceScopedCode(code: PluginErrorCode): boolean {\n return RESOURCE_SCOPED_CODES.has(code);\n}\n\n/**\n * Whether repeating the identical call could plausibly succeed.\n *\n * `internal` is listed as retryable even though a plugin bug will not fix\n * itself: it is the bucket every unclassified throw lands in, and the host\n * uses this flag to decide whether to quarantine a plugin on the spot. Being\n * wrong in the lenient direction costs a couple of retries; being wrong in the\n * strict direction quarantines a working plugin over one bad response.\n */\nconst RETRYABLE_BY_CODE: Record<PluginErrorCode, boolean> = {\n auth: false,\n config: false,\n not_found: false,\n forbidden: false,\n unsupported: false,\n rate_limited: true,\n timeout: true,\n unavailable: true,\n upstream: true,\n internal: true,\n};\n\n/**\n * Marker that survives a plugin carrying its own copy of this module.\n *\n * `Symbol.for` reads from the global symbol registry, which is shared by every\n * realm in the agent, so a second copy of this file computes the *identical*\n * symbol rather than a private one. That is the whole trick: `instanceof`\n * compares class identity and a second copy has its own class, while this\n * compares a value two copies independently agree on.\n *\n * A symbol rather than a string property because it then stays out of\n * `JSON.stringify`, `Object.keys` and log output, and because a plain object\n * decoded off the wire cannot carry one by accident the way a well-guessed\n * string field could.\n *\n * Only {@link toPluginError} reads it. See the note on {@link isPluginError}\n * for why recognition and adoption are deliberately different questions.\n */\nconst PLUGIN_ERROR_BRAND = Symbol.for('deadair.plugin-error/v1');\n\n/**\n * A failure a plugin can describe well enough for the host to answer properly.\n *\n * Throwing a bare `Error` stays perfectly legal; the host treats it as\n * `internal` and behaves exactly as it did before this class existed. Reach\n * for `PluginError` when the caller can do something different with the answer:\n * reauthorize, wait, fix a setting, or give up.\n *\n * The classification is applied with the `with*` builders rather than through\n * the constructor, so a subclass only has to forward `(message, options)` to\n * `super` and can then say what it means on its own terms:\n *\n * ```ts\n * throw new PluginError('slow down').withCode('rate_limited').withUpstreamStatus(429).withRetry(30_000);\n * ```\n *\n * Call {@link withCode} first: it resets `retryable` to the default for the\n * code, so a later `withCode` would undo an earlier {@link withRetry}.\n */\nexport class PluginError extends Error {\n /** @internal Recognition marker for a foreign copy. See {@link PLUGIN_ERROR_BRAND}. */\n readonly [PLUGIN_ERROR_BRAND] = true;\n\n /** What went wrong, in host vocabulary. Defaults to `internal`; set it with {@link withCode}. */\n code: PluginErrorCode = 'internal';\n /** Whether repeating the call could plausibly succeed. See {@link RETRYABLE_BY_CODE}. */\n retryable: boolean = RETRYABLE_BY_CODE.internal;\n /** How long to wait before retrying, when the upstream said so (`Retry-After`). */\n retryAfterMs?: number;\n /**\n * The upstream's HTTP status, for logs and for plugin-internal branching.\n * Diagnostic only: the host never forwards it as its own response status.\n */\n upstreamStatus?: number;\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n\n // Restore the prototype to the actual class used with `new` (workaround\n // for the historic Error-subclass instanceof bug in transpilers / older\n // V8). Using `new.target.prototype` means subclasses get correct `instanceof`\n // behaviour without each one having to replicate this line.\n Object.setPrototypeOf(this, new.target.prototype);\n this.name = 'PluginError';\n }\n\n /** Classifies the failure, and resets `retryable` to the default for that code. */\n withCode(code: PluginErrorCode) {\n this.code = code;\n this.retryable = RETRYABLE_BY_CODE[code];\n return this;\n }\n\n /**\n * Attaches the upstream's retry advice. Saying \"wait this long and try\n * again\" is itself a statement that a retry is worth making, so this marks\n * the failure retryable regardless of what the code defaults to.\n */\n withRetry(retryAfterMs: number) {\n this.retryable = true;\n this.retryAfterMs = retryAfterMs;\n return this;\n }\n\n withUpstreamStatus(upstreamStatus: number) {\n this.upstreamStatus = upstreamStatus;\n return this;\n }\n}\n\n/**\n * Whether `value` is one of ours: an error this copy of the module built.\n *\n * Deliberately `instanceof`, and deliberately NOT the same question\n * {@link toPluginError} answers. Almost every caller asking this is host code\n * downstream of the invoker, where the error has already been adopted and the\n * honest question is \"did we make this\", to which `instanceof` is the exact,\n * unforgeable answer. It also keeps subclasses (`SpotifyRequestError`) working\n * for the plugin's own branching, via the constructor's `new.target` fix-up.\n *\n * Tolerating a foreign copy is the boundary's job, and the boundary is one\n * function wide. Making this guard structural instead would spread that\n * laxness across every call site that only ever sees host-built errors.\n */\nexport const isPluginError = (error: unknown): error is PluginError => {\n return error instanceof PluginError;\n};\n\n/**\n * Whether `value` is a `PluginError` from a *different* copy of this module.\n *\n * See {@link PLUGIN_ERROR_BRAND}. The brand proves the shape, not that the\n * other copy agreed with this one about which codes exist, so\n * {@link toPluginError} still validates every field it reads.\n */\nfunction isForeignPluginError(value: unknown): value is Partial<PluginError> {\n if (typeof value !== 'object' || value === null) return false;\n return (value as Partial<PluginError>)[PLUGIN_ERROR_BRAND] === true;\n}\n\n/**\n * Whatever a plugin threw, as a {@link PluginError} this copy owns.\n *\n * This is the boundary function, and the only place tolerant recognition\n * belongs: the host funnels every call into plugin code through one door\n * (`PluginInvoker.invoke`), so a foreign error is adopted exactly once and\n * everything downstream deals only with errors the host itself constructed.\n *\n * Three cases, in order of how much is trusted:\n *\n * 1. Ours: passed straight through, keeping its identity so a `catch` further\n * up can still recognize the specific subclass that was thrown.\n * 2. Branded but from another copy: rebuilt here, field by field, with the\n * code checked against {@link PLUGIN_ERROR_CODES}. An unrecognized code\n * degrades to `fallback` rather than leaking a made-up string into the\n * host's HTTP mapping, and a non-numeric `retryAfterMs` is dropped rather\n * than turned into a `Retry-After` header of `NaN`.\n * 3. Anything else: adopted under `fallback`, original kept as the `cause`.\n *\n * The rebuild in case 2 is the same operation that will be needed when plugins\n * move out of process and a failure arrives as JSON rather than as a live\n * object: what changes then is how it is transported, not what it says.\n */\nexport function toPluginError(error: unknown, fallback: PluginErrorCode = 'internal'): PluginError {\n if (isPluginError(error)) return error;\n\n if (isForeignPluginError(error)) {\n const known = typeof error.code === 'string' && (PLUGIN_ERROR_CODES as readonly string[]).includes(error.code);\n const message = typeof error.message === 'string' ? error.message : String(error);\n const adopted = new PluginError(message, { cause: error }).withCode(known ? (error.code as PluginErrorCode) : fallback);\n\n if (typeof error.retryAfterMs === 'number' && Number.isFinite(error.retryAfterMs)) adopted.withRetry(error.retryAfterMs);\n if (typeof error.upstreamStatus === 'number' && Number.isFinite(error.upstreamStatus)) adopted.withUpstreamStatus(error.upstreamStatus);\n\n return adopted;\n }\n\n const message = error instanceof Error ? error.message : String(error);\n return new PluginError(message, { cause: error }).withCode(fallback);\n}\n\n/**\n * A caught `unknown` as a sentence, for a message a person reads.\n *\n * `catch` binds `unknown`, so every plugin reporting a failure narrows it before\n * it can say anything — five sites here did, four of them inline. The fallback\n * is not padding: a rejected fetch, a thrown string and an aborted signal all\n * arrive here and only some of them are `Error`.\n *\n * This is NOT error handling and is not a substitute for {@link toPluginError}.\n * It produces a string for a log line or a `testConnection` result, deliberately\n * losing the code, the cause and the retry advice. Anything deciding what to DO\n * about a failure wants the error itself.\n */\nexport const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n","/**\n * The `llm` capability. A language-model plugin takes a conversation and answers\n * with words: the line a DJ says, the running order a set generator asked for,\n * the copy for a sponsor read.\n *\n * ## This is a transport, not a writer\n *\n * Nothing here knows what a break is. That is deliberate and it is the one\n * constraint worth defending: the previous station had five things that produced\n * a script (a talk break, a sign-on, a news bulletin, a DJ set, a two-voice\n * dialogue) and every one of them was messages in and text out. A capability\n * shaped around any single one of them has to be reshaped for the next.\n *\n * So: {@link LlmMessage}s in, text out, and everything about WHAT to say lives\n * on the station's side of the fence.\n *\n * ## The words come back as a stream\n *\n * {@link LlmPluginInstance.generate} answers with a handle carrying a stream, the\n * way `speak()` does, and for a stronger reason than memory. The host serializes\n * generations through a single slot, and it holds that slot until the words stop\n * arriving rather than until the call resolves. On a local model, releasing early\n * lets two generations overlap and both of them get slower.\n *\n * Read {@link LlmHandle.text} to the end, or `cancel()` it. Then await\n * {@link LlmHandle.result}, which is where the tool calls, the usage and the\n * reason it stopped are. {@link collectGeneration} does both for a caller that\n * only wants the answer.\n *\n * ## The model is chosen per call\n *\n * {@link LlmRequest.model} overrides whatever the plugin has configured, because\n * a station wants a big model for a show and a small one for a station ident, and\n * one plugin holds exactly one config row (`plugin_configs.plugin_id` is a\n * primary key). Absent means \"whatever you are set up with\", which is the\n * ordinary case.\n *\n * ## Tools are declared here and executed by the host\n *\n * A request may carry {@link LlmToolDeclaration}s. What comes back is\n * {@link LlmToolCall}s: data, not invocations. The host runs the tool and sends\n * the result back as another message.\n *\n * That split is not ceremony. A callback crossing this boundary would be a\n * function in a payload, and it would put station code inside a plugin, which is\n * the wrong side of the fence for deciding what the station is allowed to do.\n * Every shape in this file except {@link LlmHandle} is JSON-safe, and the one\n * exception carries a stream on purpose.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\nimport { PluginError } from '../plugin.error.js';\n\n/**\n * How hard a reasoning model should think before answering.\n *\n * Sent as `reasoning_effort`, and **only when the caller asked for it** — which is\n * a hint, not a guarantee it reaches the server. A plugin may hold its own\n * setting that overrides this, forwards it unchanged, or refuses to send it at\n * all, and a plugin that has seen a 400 naming the field drops it for its own\n * lifetime regardless of what a caller asks for afterward. Leave it unset for\n * anything that is not a reasoning model: the field means nothing to a plain\n * model and a strict OpenAI-compatible server answers 400 rather than ignoring\n * it.\n */\nexport type LlmReasoningEffort = 'low' | 'medium' | 'high';\n\n/** Why a generation stopped. `tool-calls` is the one the host's loop acts on. */\nexport type LlmFinishReason = 'stop' | 'length' | 'tool-calls' | 'content-filter' | 'error' | 'other';\n\n/** One turn of the conversation. */\nexport interface LlmMessage {\n role: 'system' | 'user' | 'assistant' | 'tool';\n\n /**\n * The words. Empty is legitimate on an `assistant` turn that did nothing but\n * ask for a tool.\n */\n content: string;\n\n /**\n * What this `assistant` turn asked for, when it asked for tools.\n *\n * The host replays it verbatim on the next call, because a model that cannot\n * see its own tool call has no idea what the `tool` message after it is\n * answering.\n */\n toolCalls?: LlmToolCall[];\n\n /** Which call this `tool` turn answers. Absent on every other role. */\n toolCallId?: string;\n\n /**\n * What the provider signed on this `assistant` turn, quoted back verbatim.\n * Absent on every other role, and absent from a provider that signs nothing.\n *\n * Opaque to the host, which is the point: it is the plugin's own\n * {@link LlmResult.providerState} handed straight back, so a provider that\n * refuses a turn missing its own signature gets one that has it. See\n * {@link LlmResult.providerState} for what puts it there.\n */\n providerState?: Record<string, unknown>;\n}\n\n/** A tool the model may ask for. */\nexport interface LlmToolDeclaration {\n /** How the model names it when calling. */\n name: string;\n\n /**\n * What it does, written for the model rather than for a developer. This is\n * the entire basis on which it decides whether to call the thing, so \"current\n * conditions and today's high and low for a place\" beats \"weather lookup\".\n */\n description: string;\n\n /**\n * JSON Schema for the arguments, as a plain object.\n *\n * Not a zod schema: this is a payload that is sent, and a schema instance is\n * a class. Convert on the way in if that is what you hold.\n */\n parameters: Record<string, unknown>;\n}\n\n/** The model asking for a tool. Data, not an invocation. */\nexport interface LlmToolCall {\n /** The model's own id for this call, quoted back on {@link LlmMessage.toolCallId}. */\n id: string;\n\n /** Which declaration it wants, by {@link LlmToolDeclaration.name}. */\n name: string;\n\n /**\n * The arguments, already parsed out of the JSON the model produced.\n *\n * Unvalidated against the declared schema: the model is perfectly capable of\n * inventing a field or omitting a required one, and the host checks before\n * running anything.\n */\n arguments: Record<string, unknown>;\n}\n\n/** What one generation cost. Absent fields are ones the provider did not report. */\nexport interface LlmUsage {\n inputTokens?: number;\n outputTokens?: number;\n totalTokens?: number;\n\n /**\n * How much of {@link outputTokens} went on thinking rather than on the answer.\n *\n * Separate because the two failures underneath one `outputTokens` figure are\n * opposite: a model that wrote a long answer and one that spent its whole\n * allowance reasoning and emitted nothing both finish on `length` at the same\n * total, and only this tells them apart. A caller that logs a token count\n * without it cannot answer \"where did the allowance go\" after the fact, which\n * is the question a zero-pick run actually raises.\n *\n * Reported by the provider, so absent on plenty of them — see\n * {@link reasoningChars} for what to fall back on.\n */\n reasoningTokens?: number;\n\n /**\n * The length of the reasoning text, in characters.\n *\n * MEASURED rather than reported, and here for one reason: an\n * OpenAI-compatible server that streams reasoning without counting it leaves\n * {@link reasoningTokens} undefined, and a station running against one would\n * otherwise learn nothing from either field. Characters are a poor unit and\n * an honest one — roughly four to the token — so this answers the shape of\n * the question (\"all of it\" versus \"none of it\") where the exact figure is\n * not on offer.\n */\n reasoningChars?: number;\n}\n\n/** One conversation to continue. */\nexport interface LlmRequest {\n /**\n * The conversation so far, oldest first, with the system prompt as the first\n * turn where there is one.\n */\n messages: LlmMessage[];\n\n /** Which model, or absent for the plugin's configured one. */\n model?: string;\n\n /** Sampling temperature, or absent for the plugin's configured one. */\n temperature?: number;\n\n /** A ceiling on the answer, in tokens. Absent means the provider's own. */\n maxOutputTokens?: number;\n\n /** See {@link LlmReasoningEffort}. Absent means send nothing at all. */\n reasoningEffort?: LlmReasoningEffort;\n\n /**\n * What the model may call.\n *\n * Only sent to a model that says it can (see {@link LlmModelInfo.tools}), so\n * a plugin receiving this has already been told the model supports it. If it\n * does not, answer `unsupported` rather than dropping them silently: a break\n * written without the facts a tool would have supplied is worse than one that\n * fell back to the deterministic writer.\n */\n tools?: LlmToolDeclaration[];\n}\n\n/** Everything about a finished generation except the words as they arrived. */\nexport interface LlmResult {\n /** The whole answer, accumulated. Empty when the model only asked for tools. */\n text: string;\n\n /** What it asked for. Empty on an ordinary answer. */\n toolCalls: LlmToolCall[];\n\n /** What it cost, where the provider said. */\n usage?: LlmUsage;\n\n finishReason: LlmFinishReason;\n\n /**\n * Whatever this provider SIGNED on this turn, in the plugin's own shape, for\n * the host to hand back on the `assistant` message it builds out of this\n * result. Absent when the provider signed nothing, which is every\n * OpenAI-compatible server.\n *\n * The host never reads it. It exists because two providers refuse a tool\n * round trip whose earlier turns arrive stripped: Anthropic will not accept a\n * turn whose thinking block and its signature are missing, and Gemini wants\n * its thought signatures back on the function calls it made. Both are facts\n * about a wire protocol rather than about a conversation, so the station's\n * boundary carries them without describing them.\n *\n * JSON-safe like everything else here: it is stored in a transcript and sent\n * back across the boundary, so no class instances and no functions.\n */\n providerState?: Record<string, unknown>;\n}\n\n/**\n * A generation in flight.\n *\n * Carries a live stream, which is why it is classified as a live-object type in\n * `boundary.json.safe.ts` rather than as a payload. {@link LlmResult} is the\n * payload, and it is JSON-safe.\n */\nexport interface LlmHandle {\n /**\n * The answer as it arrives.\n *\n * The host reads it to the end or cancels it, and either one releases what is\n * underneath. **Cancelling has to actually stop the generation**, which is a\n * requirement on the PLUGIN rather than something the platform can arrange:\n * the host has no other handle on the work once `generate` has returned.\n *\n * The trap that makes this worth spelling out is that forwarding a provider's\n * stream does NOT satisfy it by itself. A plugin that hands over one branch of\n * a tee, or whose {@link result} keeps reading the source, has given the host\n * a stream it can close and a generation it cannot stop — and the invocation\n * signal is no help, because that is disposed the moment `generate` resolves\n * and a generation legitimately outlives the call that started it. So a plugin\n * owns an abort of its own, for as long as the words are still arriving.\n */\n text: ReadableStream<string>;\n\n /**\n * Settles once the generation is done.\n *\n * **Read {@link text} first.** A provider stream that nobody is draining\n * applies backpressure, so awaiting this without consuming the words is how a\n * caller waits forever. {@link collectGeneration} exists so that ordering is\n * not something each caller has to remember.\n */\n result: Promise<LlmResult>;\n}\n\n/** One model this plugin can be asked for. */\nexport interface LlmModelInfo {\n /** The id to pass back as {@link LlmRequest.model}. */\n id: string;\n\n /** What the console calls it. Absent means show the id. */\n label?: string;\n\n /**\n * Whether this model can be given {@link LlmRequest.tools}.\n *\n * Per MODEL, not per server, which is why it lives here rather than on the\n * plugin: one endpoint commonly serves both a model that can call tools and\n * one that cannot, and asking the wrong one is a failed generation rather\n * than a degraded answer.\n */\n tools: boolean;\n\n /**\n * Whether this is the model a request with no {@link LlmRequest.model} gets.\n *\n * Set it if you have one. Without it the host cannot tell which of the models\n * you listed an unnamed request will actually reach, so it has to assume the\n * worst one and will never send tools unless a caller names a model itself —\n * which gets steadily more likely to be wrong the more models your server has.\n */\n default?: boolean;\n}\n\n/** A plugin that can produce words. */\nexport interface LlmPluginInstance extends PluginLifecycle {\n /**\n * Continue the conversation.\n *\n * May return before any words exist, because the handle carries a stream and\n * not the text, so a model that thinks for ten seconds shows up as a slow\n * first chunk rather than as a slow `generate`.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no server address, no model), `unsupported` when asked for tools the\n * named model cannot do, `upstream` when the provider refused, `timeout`\n * when it did not answer, `rate_limited` when it said to wait.\n */\n generate(request: LlmRequest): Promise<LlmHandle>;\n\n /**\n * The models this plugin can be asked for, for a console drawing a list and\n * for the host deciding whether it may send tools.\n *\n * Optional, like `listVoices` on the speech capability. But note what absent\n * costs: the host has no way to learn that any model here supports tools, so\n * it sends none. A plugin that wants tool calling has to describe itself.\n */\n listModels?(): Promise<LlmModelInfo[]>;\n}\n\n/**\n * Drain a handle and answer with the finished result.\n *\n * The ordinary way to use {@link LlmPluginInstance.generate} when the caller\n * wants the answer rather than the words as they arrive. Streaming still happens\n * underneath, so the host's slot is still released at the right moment; what this\n * removes is the chance of awaiting {@link LlmHandle.result} without draining\n * {@link LlmHandle.text} first.\n *\n * A free function rather than a method, following `jsonBody` and `tryJsonBody`:\n * it keeps the handle the platform's own shape, and it is nothing a plugin should\n * have to implement.\n *\n * Pass a `signal` to stop early. The drain is raced against it and the stream is\n * cancelled, which — per {@link LlmHandle.text} — is what asks the plugin to stop\n * generating. Without one this waits for the model however long it takes, which is\n * right for a caller that has nothing better to do and wrong for one holding a\n * slot something with a deadline is queued for.\n */\nexport async function collectGeneration(handle: LlmHandle, signal?: AbortSignal): Promise<LlmResult> {\n const reader = handle.text.getReader();\n // Attached before anything can reject, and for that alone. A cancelled generation settles\n // `result` by rejecting, and on the abort path below nobody ever awaits it — so without a\n // handler already on it the process takes an unhandled rejection for a stop the host asked for.\n void handle.result.catch(() => undefined);\n\n try {\n while (true) {\n if (signal?.aborted) break;\n\n // Raced rather than awaited, because a read that is already waiting on the provider\n // does not come back when the signal fires. The loser is dropped, and `cancel` below\n // is what actually ends it.\n const chunk = await Promise.race([reader.read(), aborted(signal)]);\n if (chunk === ABORTED) break;\n\n // Read for the backpressure, not for the text: `result.text` is the accumulated\n // answer and the authority on it. A plugin forwarding a provider stream and a\n // plugin buffering one both satisfy that, and only the first would agree with\n // whatever this loop concatenated.\n if (chunk.done) break;\n }\n } finally {\n if (signal?.aborted) {\n // NOT awaited, and the lock is left held. A read is still outstanding — that is the\n // whole situation being escaped — and `cancel()` does not settle until the source's\n // pending pull does, so awaiting it here would wait out the generation this is\n // cancelling. The side effect that reaches the plugin's abort runs synchronously\n // inside the stream's own cancel algorithm, which is the part that matters.\n void reader.cancel().catch(() => undefined);\n } else {\n reader.releaseLock();\n }\n }\n\n if (signal?.aborted) {\n // Deliberately NOT awaiting `settled`. Attaching the handler is what prevents the unhandled\n // rejection, and waiting for it would put this back where it started: a plugin whose result\n // only settles when the generation ends would hold the caller here for exactly as long as\n // the generation it just cancelled.\n throw new PluginError('the generation was stopped before it finished').withCode('unavailable');\n }\n\n return handle.result;\n}\n\n/** The sentinel the race below resolves to, so an abort is told apart from a chunk. */\nconst ABORTED = Symbol('aborted');\n\n/** A promise that settles when the signal fires, and never otherwise. */\nfunction aborted(signal: AbortSignal | undefined): Promise<typeof ABORTED> {\n if (signal === undefined) return new Promise<typeof ABORTED>(() => undefined);\n if (signal.aborted) return Promise.resolve(ABORTED);\n\n return new Promise<typeof ABORTED>(resolve => signal.addEventListener('abort', () => resolve(ABORTED), { once: true }));\n}\n","/**\n * The `speech` capability. A speech plugin takes a line of text and answers\n * with audio: the station saying its own name, reading a back-announce, or\n * delivering a whole talk break.\n *\n * ## The audio comes back as a stream, not as a value\n *\n * {@link SpeechPluginInstance.speak} returns a stream rather than bytes, and\n * the usual implementation is to hand back the `host.fetch` body of the engine\n * call unchanged. So the audio is never held whole anywhere, and a long script\n * costs a chunk of memory rather than a file of it.\n *\n * Cancellation is the host's `cancel()` on that stream, and forwarding a\n * `host.fetch` body propagates it to the socket for you. There is nothing to\n * implement.\n *\n * ## A voice is a name the station chose, not one your engine knows\n *\n * {@link SpeechRequest.voice} is an opaque id from the station's side of the\n * fence — `host`, `newsreader` — and mapping it to whatever your engine actually\n * takes is your job, out of your own config. The host never reads it, never\n * validates it, and never stores anything engine-specific.\n *\n * The indirection is the point, and it was paid for once already: it is what\n * lets the same station voice be a named preset on one engine and a cloned\n * reference clip on another, so changing engine does not rewrite every persona.\n * Keep engine-specific tuning (an expressiveness dial, a similarity weight)\n * inside your own config where it belongs, rather than asking the host to carry\n * knobs only you understand.\n *\n * ## A delivery is a word the station chose, too\n *\n * The one thing about HOW a line is read that the host does carry is\n * {@link SpeechRequest.delivery}: `hushed` or `frantic`, in the station's words\n * and never as a number. That is the same bargain as a voice and a cue. The host\n * says what it wants in a vocabulary every engine can be asked in, and each plugin\n * translates it into whatever its engine has, whether that is an expressiveness\n * dial, a speed, a style preset or nothing at all. The numbers stay in your config.\n *\n * Every shape here is JSON-safe.\n */\n\nimport type { PluginLifecycle } from '../plugin.lifecycle.js';\n\n/**\n * The things a presenter DOES that are not words, as the station names them.\n *\n * A cue rides inside {@link SpeechRequest.text} rather than beside it, written\n * `[laugh]`, which is why nothing in this interface carries one. That is not a\n * shortcut: a laugh happens at a place in a sentence, and a field would have to\n * invent a way to say where.\n *\n * The vocabulary is the STATION's and mapping it is yours, exactly as for\n * {@link SpeechVoice}. A laugh is something a presenter does; whether your engine\n * spells it `[laugh]`, `<laugh>` or not at all is engine business, and the host\n * never learns which.\n *\n * ## Eight, and the second four are not for the presenter\n *\n * This was four, and its stated reason for stopping there was that \"a cough or a\n * sniff reads as illness rather than as delivery\". That is right about somebody\n * being paid to talk and exactly wrong about somebody on the end of a telephone,\n * where the throat-clear IS the realism — so the vocabulary is wider now and WHO\n * may use which is the host's business rather than this list's.\n *\n * The host keeps the first four. A caller in a production may use all eight. Both\n * sets live app-side, because they are permissions rather than capabilities, and\n * a plugin has no way to know which of its speakers is which.\n *\n * **Widening this list without narrowing the offer is how the presenter starts\n * coughing.** Whatever reads a script back has to be told which of these were\n * actually on offer to the person who wrote it, rather than reaching for the whole\n * vocabulary.\n *\n * Still chosen rather than copied from an engine: `shush` is one this list does\n * not take, because shushing is aimed AT somebody in the room. That is a piece of\n * business rather than a way of delivering a line.\n *\n * **Answer {@link SpeechPluginInstance.listCues} honestly and the rest is free.**\n * The host strips every cue you do not claim before it calls {@link\n * SpeechPluginInstance.speak}, so a plugin that implements nothing here never sees\n * one, and the failure where an engine READS the word \"laugh\" out loud cannot\n * happen. Claiming one you cannot perform is the only way to break that.\n */\nexport const SPEECH_CUES = ['laugh', 'chuckle', 'sigh', 'gasp', 'cough', 'clear throat', 'sniff', 'groan'] as const;\n\n/** One of {@link SPEECH_CUES}. */\nexport type SpeechCue = (typeof SPEECH_CUES)[number];\n\n/** Every cue written into a script, in the order they appear, with repeats. */\nexport function cuesIn(text: string): SpeechCue[] {\n return [...text.matchAll(cuePattern())].map(match => match[1]!.toLowerCase() as SpeechCue);\n}\n\n/**\n * The same text with cues removed, or with only some of them kept.\n *\n * `keep` is the set to LEAVE, so the default of none is \"take them all out\" — the\n * safe direction, and the one an engine that has never heard of a cue wants. A\n * removal closes the space it leaves behind, because `word [laugh] word` would\n * otherwise render with a double space that {@link SPEECH_CUES}' own consumers\n * would have to know to tidy.\n *\n * Only the four are touched. Anything else in brackets is somebody else's problem\n * and stays exactly as it arrived: this is not a bracket stripper.\n */\nexport function withoutCues(text: string, keep: Iterable<SpeechCue> = []): string {\n const kept = new Set<string>([...keep]);\n\n return text\n .replace(cuePattern(), (match, cue: string) => (kept.has(cue.toLowerCase()) ? match : ' '))\n .replace(/[^\\S\\n]{2,}/g, ' ')\n .replace(/[^\\S\\n]+([.,!?;:])/g, '$1')\n .trim();\n}\n\n/**\n * A fresh matcher every call, because a `g` flag carries `lastIndex` between them.\n *\n * **Longest form first**, which is `applyPronunciations`' rule one layer up and became\n * load-bearing the moment `clear throat` joined the list: an alternation takes the\n * earliest branch that matches at a position, so a shorter cue that is a prefix of a\n * longer one would claim it and leave the rest as text an engine reads out.\n */\nconst cuePattern = (): RegExp => new RegExp(`\\\\[(${[...SPEECH_CUES].sort((left, right) => right.length - left.length).join('|')})\\\\]`, 'gi');\n\n/**\n * How a whole line is read, as the station names it.\n *\n * Two, and the middle is deliberately not one of them: a request with no delivery is the voice's own\n * ordinary reading, which is what nearly every line should be. A third word for \"ordinary\" would be a\n * second way to ask for nothing, and would key a second cached preview of identical audio.\n *\n * The vocabulary is the STATION's and translating it is yours, exactly as for {@link SPEECH_CUES}.\n * `hushed` is quieter, slower, closer to the microphone; `frantic` is urgent, faster, barely holding\n * on. Whether your engine gets there with an expressiveness dial, a speed, a style token or a\n * different reference clip is engine business, and the host never learns which.\n *\n * ## Why a word and not a number\n *\n * A number is a promise about one engine's scale. `exaggeration: 0.9` means something to one family of\n * models and nothing to the next, so a station that asked for it would be tied to the engine it was\n * built against, which is the thing the voice indirection exists to prevent. A word survives a change\n * of engine. The numbers it becomes live in each plugin's own config, beside the voice they tune.\n *\n * ## Claim only what you can perform\n *\n * {@link SpeechPluginInstance.listDeliveries} says which of these your engine can do RIGHT NOW, and the\n * host drops any delivery you did not claim before it calls {@link SpeechPluginInstance.speak}. So a\n * plugin that implements nothing here never sees one, and the writer is never offered a delivery the\n * engine would ignore. Claiming one you cannot perform is the only way to break it.\n */\nexport const SPEECH_DELIVERIES = ['hushed', 'frantic'] as const;\n\n/** One of {@link SPEECH_DELIVERIES}. */\nexport type SpeechDelivery = (typeof SPEECH_DELIVERIES)[number];\n\n/** Whether a value is one of {@link SPEECH_DELIVERIES}, exactly as written. */\nexport function isSpeechDelivery(value: unknown): value is SpeechDelivery {\n return typeof value === 'string' && (SPEECH_DELIVERIES as readonly string[]).includes(value);\n}\n\n/** One thing to say. */\nexport interface SpeechRequest {\n /**\n * The words, already final. Nothing downstream rewrites them, so any\n * pronunciation fixing your engine needs is yours to apply.\n */\n text: string;\n\n /**\n * Which station voice to use, or absent for this plugin's default.\n *\n * Opaque, and a name the operator chose. An id you have no mapping for\n * should fall back to your default rather than fail: a missing voice is\n * worth a `logger.warn` and a rendered line, not a silent station.\n */\n voice?: string;\n\n /**\n * The audio format the caller would prefer, as a bare extension (`mp3`).\n *\n * A hint, and the weakest thing in this interface: answer with whatever you\n * actually produced in {@link SpeechHandle.mime} and the host will believe\n * that instead. Ignore it entirely if your engine emits one format.\n */\n format?: string;\n\n /**\n * How to read the whole line, or absent for the voice's own ordinary reading.\n *\n * Only ever one you claimed through {@link SpeechPluginInstance.listDeliveries}: the host drops the\n * rest before this call. Translate it into your engine's own controls, relative to whatever the\n * voice already sounds like, so a voice that is intense at rest is still more intense than its\n * neighbours when hushed. See {@link SPEECH_DELIVERIES}.\n */\n delivery?: SpeechDelivery;\n}\n\n/** The audio for one {@link SpeechPluginInstance.speak}, and what it is. */\nexport interface SpeechHandle {\n /**\n * What the bytes ARE, as a media type (`audio/mpeg`).\n *\n * Load-bearing rather than decoration: it is what the host stores the audio\n * under and what it later serves, and both consumers of station audio (a\n * browser's `<audio>`, which does not sniff, and the playout engine, which\n * picks its decoder from the content type) go by that header rather than by\n * the bytes. A wav announced as `audio/mpeg` fails as silence rather than\n * as an error anybody sees.\n */\n mime: string;\n\n /**\n * The audio.\n *\n * The host reads it to the end or cancels it, and either one releases\n * whatever is underneath. Usually a `host.fetch` body forwarded unchanged,\n * which is what makes that true for free.\n */\n audio: ReadableStream<Uint8Array>;\n}\n\n/** One voice this plugin can be asked for. */\nexport interface SpeechVoice {\n /** The id to pass back as {@link SpeechRequest.voice}. */\n id: string;\n\n /** What the console calls it. */\n label: string;\n\n /** Anything worth knowing when choosing between them: an accent, a register. */\n description?: string;\n\n /**\n * An opaque token that changes when what this voice SOUNDS LIKE changes.\n *\n * The host does not interpret it, parse it, store it or show it. It uses it\n * for one thing: keying the cached preview at `GET /voices/{id}/sample`, so\n * that remapping `host` from one engine voice to another — or nudging its\n * speed, or swapping its reference clip — mints a new key and the next\n * preview renders instead of playing the old voice back.\n *\n * That was already the claim `VoiceSampleStore` made in its own doc comment\n * and it was not true: the key held the STATION voice id, which is exactly\n * the part that does not change when an operator edits the mapping under it.\n * The fence is intact because this stays opaque — whatever string identifies\n * a rendering to you is the right value, and `engineVoice@speed` is a fine\n * one.\n *\n * Absent is normal. A plugin that omits it keys previews as it always did,\n * which is correct for one whose voices cannot be reconfigured.\n */\n spec?: string;\n}\n\n/**\n * What one `speak` can be given, where the engine has a limit worth declaring.\n *\n * Every field is optional and absent means \"no limit this plugin knows of\",\n * which is the ordinary answer: most engines take a sentence and most callers\n * send one.\n */\nexport interface SpeechLimits {\n /**\n * The most characters one {@link SpeechRequest.text} may hold.\n *\n * A ceiling on ONE call rather than a budget for the work: a caller with more\n * text than this splits it and asks several times, which is what the station\n * does when it reads something long out. Answer the engine's own documented\n * limit and nothing tighter. A plugin guessing low costs the station an extra\n * seam in the middle of a sentence, and a seam is audible where a slightly\n * longer call is not.\n */\n maxCharacters?: number;\n}\n\n/** A plugin that can speak. */\nexport interface SpeechPluginInstance extends PluginLifecycle {\n /**\n * Start turning `request.text` into audio.\n *\n * May return before any audio exists, because the handle carries a stream\n * and not the bytes, so a slow engine shows up as a slow first chunk rather\n * than as a slow `speak`.\n *\n * @throws {PluginError} `config` when the plugin is not set up enough to try\n * (no server address), `upstream` when the engine refused or answered with\n * something that is not audio, `timeout` when it did not answer at all.\n */\n speak(request: SpeechRequest): Promise<SpeechHandle>;\n\n /**\n * The voices this plugin can be asked for, for a console that has to draw a\n * list.\n *\n * Optional, because a plugin with exactly one voice is a legitimate thing to\n * be and should not have to describe it. Absent is normal, not broken.\n */\n listVoices?(): Promise<SpeechVoice[]>;\n\n /**\n * Which of {@link SPEECH_CUES} this plugin can perform RIGHT NOW.\n *\n * Optional, and absent means none: an engine that only reads words is the\n * ordinary case and should not have to say so. That default is what makes this\n * safe to add — the host strips what you do not claim, so silence costs a\n * plugin nothing and risks nothing.\n *\n * **Answer from what the engine currently IS, not from what this plugin was\n * built against.** On the engine this was written for, cues belong to the\n * loaded MODEL rather than to the server, so swapping the model takes them away\n * with no plugin change involved — which is exactly the case a manifest flag\n * would get wrong, and it would get it wrong by having the station perform to\n * an engine that reads the word out.\n *\n * Called on the path that writes a break as well as the one that speaks it, so\n * keep it cheap and answer empty rather than throwing when the engine cannot be\n * reached: a station that cannot ask should write a script with no cues in it,\n * not fail to write one.\n */\n listCues?(): Promise<readonly SpeechCue[]>;\n\n /**\n * Which of {@link SPEECH_DELIVERIES} this plugin can perform RIGHT NOW.\n *\n * Optional, and absent means none, for the reason {@link listCues} gives: most engines have no\n * such control, and saying nothing costs a plugin nothing because the host drops what you do not\n * claim.\n *\n * The same three rules as for cues, too. Answer from what the engine currently IS, since on some\n * engines the control belongs to the loaded model rather than to the server. Keep it cheap,\n * because it is asked on the path that writes a break. And answer empty rather than throwing when\n * the engine cannot be reached, because a station that cannot ask should write an ordinary\n * reading rather than fail to write one.\n */\n listDeliveries?(): Promise<readonly SpeechDelivery[]>;\n\n /**\n * What one call to this engine can be given. See {@link SpeechLimits}.\n *\n * Optional, and absent means the host uses a conservative default rather than\n * assuming there is no limit. That is the opposite of {@link listCues}'\n * default, and deliberately so, because the two fail in opposite directions. An unclaimed cue\n * is stripped and the break is plainer; an unknown ceiling that turns out to\n * be real is a request the engine REFUSES, which reads as a broken plugin\n * rather than a long one: the error is an upstream failure like any other and\n * three of those in a row quarantine the plugin.\n *\n * So declare one if the engine documents one. It is worth the method for the\n * case the station cannot otherwise get right: reading something long out\n * loud, where the text is somebody else's and its length is not the station's\n * to choose.\n *\n * {@link listCues}' three rules apply: answer from what the engine currently\n * IS, keep it cheap, and answer empty rather than throwing when the engine\n * cannot be reached.\n */\n listLimits?(): Promise<SpeechLimits>;\n}\n","import type { HostFetchMethod } from './plugin.host.js';\nimport type { PluginErrorCode } from './plugin.error.js';\n\n/**\n * The pieces every plugin that talks to an HTTP upstream was writing for itself.\n *\n * Four clients here wrap `host.fetch` the same way — check the status, turn it\n * into a {@link PluginErrorCode}, pull the upstream's own sentence out of the\n * body, and throw something carrying both. The WRAPPER is not shared, because\n * each one throws its own error type and each branches on its own service's\n * quirks. What is shared is underneath it: the rows of the status ladder that\n * mean the same thing everywhere, the truncation, the `Retry-After` parse, and\n * the defensive dig into a JSON error body.\n *\n * Deliberately not here: retry and pacing. A plugin declares its rate limit on\n * its manifest and `host.fetch` applies it, so no plugin implements a backoff\n * and none should start.\n */\n\n/** How long an upstream's own sentence may be before it is cut. */\nconst MAX_UPSTREAM_MESSAGE = 200;\n\n/**\n * An upstream's sentence, bounded.\n *\n * An error body is untrusted text that ends up in a log line and on a settings\n * card, so its length is not the upstream's decision to make. Three clients cut\n * it at the same 200 characters with the same ellipsis.\n */\nexport function truncateUpstreamMessage(message: string): string {\n return message.length > MAX_UPSTREAM_MESSAGE ? `${message.slice(0, MAX_UPSTREAM_MESSAGE)}…` : message;\n}\n\n/**\n * A string field out of a JSON error body, or `undefined`.\n *\n * Defensive on purpose, and in a specific way: the body is whatever the edge\n * happened to send — an HTML page from a proxy, an empty 429, a truncated\n * response — so a failed parse has to read as \"said nothing\" rather than throw\n * inside the code that was already handling a failure. An empty string is also\n * nothing, since it would otherwise print as a blank reason.\n *\n * @param body - The raw response body. `undefined` when it was never read.\n * @param pick - Reaches the field. Runs on `unknown`, so it casts; anything it\n * returns that is not a non-empty string is discarded.\n */\nexport function upstreamField(body: string | undefined, pick: (parsed: unknown) => unknown): string | undefined {\n if (!body) return undefined;\n\n let value: unknown;\n try {\n value = pick(JSON.parse(body));\n } catch {\n return undefined;\n }\n\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\n/**\n * `Retry-After` in whole seconds, as milliseconds.\n *\n * Only the seconds form is read. The HTTP-date form is legal and no upstream\n * here sends it, and guessing wrong would tell the host to sit out a wait that\n * was never asked for. Anything unparseable or negative means \"no advice\n * given\", which is different from \"wait zero\".\n *\n * Takes `null` as well as `undefined` because one caller reads it off a\n * `Headers` (which answers `null`) and another off a record (which answers\n * `undefined`).\n */\nexport function retryAfterMs(header: string | null | undefined): number | undefined {\n if (header === null || header === undefined) return undefined;\n\n const seconds = Number(header.trim());\n if (!Number.isFinite(seconds) || seconds < 0) return undefined;\n\n return seconds * 1000;\n}\n\n/**\n * The rows of the status ladder that mean the same thing at every upstream.\n *\n * A plugin layers its own service's readings in FRONT of this rather than\n * replacing it, because the deviations are the interesting part and each one is\n * a decision worth writing down beside the plugin it belongs to. Spotify reads\n * 401 as `auth` and 403 as `forbidden`; MusicBrainz reads a 503 carrying a\n * `Retry-After` as `rate_limited`; the two token-authenticated services read\n * 401 as `config`, because a pasted token is a setting. None of those belongs\n * here.\n *\n * What does belong here is the part nobody disagrees about: a 404 is a missing\n * thing, a 429 is going too fast, any other 5xx is the upstream being down, and\n * everything else is the upstream saying something unhelpful.\n */\nexport function pluginCodeForStatus(status: number): PluginErrorCode {\n if (status === 404) return 'not_found';\n if (status === 429) return 'rate_limited';\n if (status >= 500) return 'unavailable';\n\n return 'upstream';\n}\n\n/**\n * A one-line summary of a failed response, for a message a human reads.\n *\n * The status is always there; the other two often are not, and an upstream that\n * sent neither should not produce a line with holes in it.\n */\nexport function upstreamDetail(status: number, statusText?: string, reason?: string): string {\n return [`HTTP ${status}`, statusText, reason].filter(part => part).join(' ');\n}\n\n/** The methods `host.fetch` will carry. */\nexport const HOST_FETCH_METHODS: readonly HostFetchMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];\n\n/**\n * A method name as one `host.fetch` accepts, or `undefined` if it is not one.\n *\n * Answers rather than throws, because the two callers refuse in different words\n * and to different audiences — one is telling a plugin author that the AI SDK\n * asked for something impossible, the other is a bridge reporting its own bug —\n * and a shared throw would have flattened both into one message.\n */\nexport function hostFetchMethod(method: string | undefined): HostFetchMethod | undefined {\n const upper = (method ?? 'GET').toUpperCase();\n\n return HOST_FETCH_METHODS.find(candidate => candidate === upper);\n}\n\n/**\n * Headers as the plain lower-cased record `HostFetchInit.headers` takes.\n *\n * A `Headers` instance has already lower-cased its names and joined repeats,\n * which is the behaviour to want: the host's header handling carries one value\n * per name. Lower-casing again is free and makes the guarantee local.\n */\nexport function headersToRecord(headers: Headers): Record<string, string> {\n const record: Record<string, string> = {};\n\n headers.forEach((value, name) => {\n record[name.toLowerCase()] = value;\n });\n\n return record;\n}\n","/**\n * Somebody else's markup as something a person could be read aloud.\n *\n * Lifted out of `feed.parse.ts` when `article.parse.ts` needed the same two\n * steps, and shared rather than copied for the reason `match.text.ts` gives\n * about its own two: a difference between the copies would have one reader\n * hand a model `&` while the other did not, and nobody would notice until\n * a voice said it on air.\n *\n * Neither function knows anything about feeds or articles. What is here is the\n * part that is true of any publisher's text: tags are not words, entities are\n * not punctuation, and a paragraph's worth of whitespace is one space.\n */\n\n/**\n * The XML five, plus the typography a publisher actually writes, plus numeric\n * escapes.\n *\n * Still not the whole HTML entity table, and deliberately: a named entity this\n * does not know is left exactly as it was, which reads as the publisher's own\n * text rather than as a hole. What earns a row here is what turns up in prose —\n * quotes, dashes, an ellipsis — because those are the ones a voice would\n * otherwise read out as \"and r s q u o\".\n */\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n ndash: '–',\n mdash: '—',\n hellip: '…',\n lsquo: '‘',\n rsquo: '’',\n ldquo: '“',\n rdquo: '”',\n};\n\nexport function decodeEntities(value: string): string {\n return value.replace(/&(#x?[0-9a-f]+|\\w+);/gi, (whole, body: string) => {\n if (body.startsWith('#')) {\n const code = body[1]?.toLowerCase() === 'x' ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;\n }\n\n return NAMED_ENTITIES[body.toLowerCase()] ?? whole;\n });\n}\n\n/**\n * Markup as plain text, capped.\n *\n * Tags out, entities decoded, whitespace collapsed, length bounded.\n *\n * Entities are decoded AFTER the tags come out, and that order matters: an\n * escaped `<script>` would otherwise be turned into a tag by the decode\n * and then survive the strip.\n *\n * @param maxChars - Where to cut. Absent means keep whatever there is, which is\n * what a caller that has already bounded the input wants.\n */\nexport function plainText(raw: string, maxChars?: number): string | undefined {\n const stripped = decodeEntities(raw.replace(/<[^>]*>/g, ' '))\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (stripped.length === 0) return undefined;\n return maxChars === undefined ? stripped : truncateWords(stripped, maxChars);\n}\n\n/**\n * Text cut at a word boundary where there is one nearby, with an ellipsis.\n *\n * A summary that ends mid-word reads as a broken feed rather than as a\n * truncation, which is the difference between a listener hearing a station\n * quoting a publisher and one hearing a station malfunction.\n */\nexport function truncateWords(value: string, maxChars: number): string {\n if (value.length <= maxChars) return value;\n\n const cut = value.slice(0, maxChars);\n const lastSpace = cut.lastIndexOf(' ');\n return `${(lastSpace > maxChars - 40 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;\n}\n\n/**\n * Where one sentence ends and the next begins.\n *\n * A terminator, any closing quote or bracket after it, whitespace, and then\n * something that starts a sentence. The lookahead is what does the work: a\n * full stop followed by a digit or a lowercase letter is an abbreviation or a\n * decimal, not an ending. Measured on a real wire story — \"Saturday, Aug. 15,\n * 2026\" was being read as a complete sentence and a bulletin said it out loud.\n */\nconst SENTENCE_END = /[.!?][\"'”’)\\]]*\\s+(?=[\"'“‘(]?[A-Z0-9])/g;\n\n/**\n * The abbreviations the lookahead cannot catch, because a name follows them and\n * a name is capitalised.\n *\n * Short and deliberately not a gazetteer: every entry here is one that turns up\n * in news copy, and the cost of a miss is one sentence cut early rather than\n * anything false. Matched with the full stop already consumed.\n */\nconst ABBREVIATIONS =\n /\\b(mr|mrs|ms|dr|prof|rev|st|jr|sr|gov|sen|rep|lt|sgt|col|gen|capt|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec|no|vs|approx|est|inc|ltd|co|dept|univ|u\\.s|u\\.k)$/i;\n\n/** Whether the stop at `at` really ends a sentence, or belongs to an abbreviation. */\nconst endsSentence = (value: string, at: number): boolean => !ABBREVIATIONS.test(value.slice(Math.max(0, at - 12), at));\n\n/**\n * The first sentence of a passage, as published, or the whole thing when it is\n * one sentence.\n *\n * Here rather than in a caller because both consumers of prose need it and\n * neither should be splitting sentences by hand: a station reads the opening\n * line of a story aloud, and a truncation cuts at the last one that fits.\n */\nexport function firstSentence(value: string): string {\n const text = value.trim();\n\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(text); match !== null; match = SENTENCE_END.exec(text)) {\n const stop = match.index;\n if (endsSentence(text, stop)) return text.slice(0, stop + 1).trim();\n }\n\n return text;\n}\n\n/**\n * Text cut at the last SENTENCE that fits, with no ellipsis.\n *\n * The other kind of cut, and the one that belongs on anything a voice will read\n * or a model will be told is the story: half a sentence is something to finish,\n * and a model handed one finishes it out of its own head — which is exactly the\n * failure a bulletin cannot have. Falls back to {@link truncateWords} when the\n * first sentence is already longer than the cap, because a hard stop mid-clause\n * is still better than three paragraphs.\n */\nexport function truncateSentences(value: string, maxChars: number): string {\n if (value.length <= maxChars) return value;\n\n let lastStop = -1;\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(value); match !== null; match = SENTENCE_END.exec(value)) {\n if (match.index >= maxChars) break;\n if (endsSentence(value, match.index)) lastStop = match.index;\n }\n\n if (lastStop <= 0) return truncateWords(value, maxChars);\n return value.slice(0, lastStop + 1).trimEnd();\n}\n\n/** How many words a passage is, on the one definition every caller here uses. */\nconst wordsIn = (value: string): number => value.split(/\\s+/).filter(Boolean).length;\n\n/**\n * The closing quotes and brackets a boundary swallowed, so a cut keeps them.\n *\n * {@link SENTENCE_END} matches the terminator, then any closers, then the\n * whitespace before the next sentence. Cutting at the terminator alone leaves\n * the closer at the head of the half that was thrown away, which turns `as\n * \"love.\"` into `as \"love.` — an unbalanced quote in something a voice reads.\n */\nconst closersIn = (boundary: string): string => boundary.slice(1).trimEnd();\n\n/**\n * The longest run of WHOLE sentences within a word count, or nothing.\n *\n * The word-counted sibling of {@link truncateSentences}, and the difference\n * between them is the fallback rather than the unit. That one cuts mid-clause\n * when the first sentence is already too long, because its caller would rather\n * have a hard stop than three paragraphs. This one answers `undefined`, because\n * its caller is choosing between a script and something else it can say\n * instead, and half a sentence read aloud is worse than either.\n *\n * A passage already within the count comes back trimmed and otherwise\n * untouched, so a caller can put this in front of everything rather than\n * branching on the length itself.\n */\nexport function sentencesWithin(value: string, maxWords: number): string | undefined {\n const text = value.trim();\n if (wordsIn(text) <= maxWords) return text;\n\n let fits: string | undefined;\n\n SENTENCE_END.lastIndex = 0;\n for (let match = SENTENCE_END.exec(text); match !== null; match = SENTENCE_END.exec(text)) {\n if (!endsSentence(text, match.index)) continue;\n\n const ending = text.slice(0, match.index + 1 + closersIn(match[0]).length);\n if (wordsIn(ending) > maxWords) break;\n fits = ending;\n }\n\n return fits;\n}\n","import { PluginError } from './plugin.error.js';\nimport { pluginCodeForStatus, retryAfterMs, upstreamDetail } from './plugin.http.js';\nimport { plainText, truncateSentences } from './html.text.js';\nimport type { HostFetchInit, PluginHost } from './plugin.host.js';\n\n/**\n * The story behind a headline, as the paragraphs somebody else published.\n *\n * The sibling of `feed.parse.ts`, here for the same reason and with the same\n * split. A feed is a summary of a publication and frequently not a summary of\n * anything else: measured against the station's own configured feed, every\n * item's `description` was one sentence restating its title and its\n * `content:encoded` was that same sentence wrapped in a `<p>`. So a station\n * that wants to say what HAPPENED has to read the page the entry points at,\n * and every plugin that reads a feed will want this the moment it wants more\n * than titles.\n *\n * ## It extracts and it does not summarise\n *\n * Everything that comes out of here is the publisher's own prose, in the\n * publisher's own order, with the furniture removed. Nothing is rewritten,\n * nothing is joined, and no sentence is composed. That is the same boundary\n * `plugins/wikipedia` keeps by handing the host an article verbatim: only the\n * host can check a claim against the text it came from, and prose that has been\n * through a plugin's own paraphrase is prose nothing can check.\n *\n * The one thing removed that is not furniture is a paragraph announcing itself as an advertisement\n * — see {@link SPONSOR_OPENERS}. It is still a cut rather than a rewrite: what is left is the\n * publisher's own prose, and what went is prose the publisher was paid for.\n *\n * ## Wrong in one direction on purpose\n *\n * A page that cannot be read answers `undefined` rather than a guess. Every\n * caller's fallback is the entry's own words, which is a real answer, whereas\n * navigation copy and cookie banners scraped off a template are noise a voice\n * would read out as news. So the extraction is deliberately conservative: a\n * block that is not clearly a paragraph of prose is dropped, and a page that\n * yields nothing is a page with nothing on it as far as anything here is\n * concerned.\n */\n\n/**\n * How much of an article is kept.\n *\n * Sized to what a bulletin can use rather than to the article: the destination\n * is a model's context beside a persona sheet and a set of content rules, and a\n * writer that only ever reads out one sentence per story does not need three\n * thousand words to find it. Cut on a sentence boundary\n * ({@link truncateSentences}), because half a sentence is something a model\n * finishes out of its own head.\n */\nexport const ARTICLE_MAX_CHARS = 2_000;\n\n/**\n * Shortest a block of text may be and still be taken for a paragraph of prose.\n *\n * The whole filter, and it does most of the work. A page's non-prose blocks are\n * short and its prose is not: bylines, timestamps, share prompts, tags, \"hide\n * caption\", and the cookie line are all under this, and a sentence of reporting\n * is comfortably over it. Every alternative worth having (a readability score, a\n * text-to-link-density ratio) is a bigger thing to be wrong in ways that are\n * harder to see.\n */\nconst MIN_PARAGRAPH_CHARS = 60;\n\n/** Everything whose text is never the story, taken out with its contents. */\nconst FURNITURE = /<(script|style|noscript|template|svg|figure|figcaption|aside|nav|header|footer|form)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi;\n\n/**\n * The same thing again, for publishers who mark furniture with a CLASS rather\n * than with an element.\n *\n * Not optional polish. Measured on a real wire story: the photo caption and its\n * credit sit in `<div class=\"credit-caption\">…<p>…</p>`, which is an ordinary\n * paragraph by every structural test, and the station read \"A view of the rising\n * water levels at the Wainaku Street Bridge in Hilo, Saturday, Aug. 15\" out loud\n * as its first item of news.\n *\n * A heuristic over somebody else's markup, and it is allowed to be one because\n * it can only ever cut: the vocabulary is small, every word in it names\n * something that is furniture on any site that uses the word at all, and a false\n * positive costs one paragraph out of a story that has others. Non-greedy to the\n * first matching close tag, which on nested markup ends EARLY — that is the safe\n * direction, since the text being removed is in the innermost element.\n */\nconst FURNITURE_CLASSES =\n /<(div|section|span|p|ul|ol)\\b[^>]*(?:class|id|aria-label)=\"[^\"]*\\b(caption|credit|byline|promo|newsletter|related|recirc|sidebar|share|social|advert|subscribe|paywall|tags?)\\b[^\"]*\"[^>]*>[\\s\\S]*?<\\/\\1>/gi;\n\n/**\n * The apparatus a reference-heavy page leaves in its own prose.\n *\n * Measured against thirty documents an enrichment walk collected off Wikipedia:\n * twenty-eight carried `[ 1 ]`-style markers and four carried raw `{{cite web\n * |title=… |url=… |publisher=[[BBC]] |access-date=…}}` templates, 2.1% of every\n * character stored. Both survive {@link FURNITURE} because neither is an\n * element — the markers are text a renderer put inside the paragraph, and the\n * templates are source that reached the page unrendered.\n *\n * This is the one kind of noise where the paragraph-length filter is no help,\n * because a citation template is long. It is also the kind that matters most:\n * what reads these is a claim extractor that checks a quoted span really occurs\n * in the text, so a span quoting `{{cite web |title=How to say: Bowie` passes\n * that check and can be read out on air. The file header's promise to remove\n * \"reference markers\" was not implemented until this existed.\n *\n * Deliberately narrow. A bracketed NUMBER and a small closed vocabulary, never a\n * bracketed word in general: `[sic]` is the author's and belongs in a quotation.\n *\n * Each entry carries its own replacement, which is not ceremony: three of these\n * delete and one KEEPS what it matched. A shared `'$1'` would have written the\n * literal characters `$1` into the prose for every pattern with no group, which\n * is the same class of bug as the markup being removed.\n */\nconst REFERENCE_APPARATUS: readonly (readonly [RegExp, string])[] = [\n // `{{cite web |…}}`, `{{efn|…}}`. One level and non-nesting, which is the\n // safe direction: a nested template leaves its outer braces behind as two\n // characters rather than eating the sentence after it.\n [/\\{\\{[^{}]*\\}\\}/g, ''],\n // `[ 1 ]`, `[12]`.\n [/\\[\\s*\\d+\\s*\\]/g, ''],\n // `[ citation needed ]`, `[ edit ]`.\n [/\\[\\s*(?:citation needed|clarification needed|edit|note \\d+|update)\\s*\\]/gi, ''],\n // `[[BBC]]` and `[[David Bowie|Bowie]]`: the only one that keeps its match,\n // because what is inside is the words a reader sees rather than apparatus.\n [/\\[\\[(?:[^[\\]|]*\\|)?([^[\\]|]*)\\]\\]/g, '$1'],\n];\n\n/**\n * How a paragraph announces that it is somebody's advertisement.\n *\n * A newsletter carries its sponsor inside its own prose rather than in a block anything structural\n * can find: no class, no element, just a paragraph that opens \"A message from …\" or \"Presented by\n * …\" between two paragraphs of reporting. {@link FURNITURE_CLASSES} cannot see it, the length\n * filter passes it, and what a bulletin then reads out is an advertisement in the voice it has just\n * established as the one that reports facts. Measured on the station's own feeds: an aired bulletin\n * opened with a shop's discount code and the affiliate disclosure that came with it.\n *\n * Anchored at the START and nowhere else, which is the whole safety property. \"The campaign was\n * paid for by donors\" is reporting and is left alone; a paragraph that BEGINS \"Paid for by\" is the\n * disclosure itself. The vocabulary is closed and every entry names something that is an\n * advertisement wherever it appears, so this can only ever cut, which is {@link FURNITURE_CLASSES}'\n * argument one level down — and a false positive costs one paragraph of a story that has others.\n */\nconst SPONSOR_OPENERS: readonly string[] = [\n 'a message from',\n 'a note from our sponsor',\n 'advertisement',\n 'advertorial',\n 'paid content',\n 'paid for by',\n 'paid partnership',\n 'presented by',\n 'promoted by',\n 'sponsored',\n 'sponsored by',\n 'sponsored content',\n 'sponsored survey',\n 'support for this',\n 'support comes from',\n 'this post is sponsored',\n];\n\n/**\n * Whether a paragraph opens by announcing itself as an advertisement.\n *\n * The opener has to be FOLLOWED by a separator rather than merely be a prefix, or \"Sponsored\" would\n * match \"Sponsorship deals collapsed\" and \"Advertisement\" would match \"Advertisements for the\n * scheme ran for a month\" — both of which are stories about advertising rather than advertisements.\n * A colon, a dash, a space or the end of the paragraph are what an announcement actually looks like.\n */\nfunction opensWithASponsor(text: string): boolean {\n const said = text.toLowerCase();\n\n return SPONSOR_OPENERS.some(opener => {\n if (!said.startsWith(opener)) return false;\n\n const next = said.charAt(opener.length);\n return next === '' || /[\\s:;,.\\u2013\\u2014-]/.test(next);\n });\n}\n\n/** The containers a publisher marks the story with, in the order they are worth trusting. */\nconst CONTAINERS = [/<article\\b[^>]*>([\\s\\S]*?)<\\/article>/i, /<main\\b[^>]*>([\\s\\S]*?)<\\/main>/i];\n\n/** A paragraph, which is the only block this reads. See {@link MIN_PARAGRAPH_CHARS}. */\nconst PARAGRAPH = /<p\\b[^>]*>([\\s\\S]*?)<\\/p>/gi;\n\n/**\n * An article page as plain text, or `undefined` when it does not carry one.\n *\n * Pure, and that is what makes it testable against saved pages with no host in\n * the way — `parseFeed`'s split, for `parseFeed`'s reason.\n *\n * @param html - The page, as served.\n * @param maxChars - Where to cut. See {@link ARTICLE_MAX_CHARS}.\n */\nexport function extractArticle(html: string, maxChars: number = ARTICLE_MAX_CHARS): string | undefined {\n // Furniture first, and before the container is picked: a `<figure>` inside\n // the article is exactly the case this is for, and its caption is a full\n // sentence that would otherwise pass every test below.\n const cleaned = html.replace(FURNITURE, ' ').replace(FURNITURE_CLASSES, ' ');\n\n // A publisher that marked the story is trusted about where it is. One that\n // did not gets the whole document, which is safe only because the paragraph\n // filter is what actually decides — a template's own blocks do not survive\n // it.\n const body = CONTAINERS.map(pattern => pattern.exec(cleaned)?.[1]).find(found => found !== undefined) ?? cleaned;\n\n const paragraphs: string[] = [];\n for (const match of body.matchAll(PARAGRAPH)) {\n // Apparatus BEFORE the length test, so a paragraph that is mostly\n // citation is measured on the prose it actually has left. After\n // `plainText`, because a template can carry a `<` in an attribute and\n // stripping tags first is what makes the braces the outermost thing.\n const text = withoutApparatus(plainText(match[1] ?? ''));\n if (text === undefined || text.length < MIN_PARAGRAPH_CHARS) continue;\n\n // After the length test rather than before it, because this is the more expensive check and\n // most of what it would run over has already been dropped for being too short to be prose.\n if (opensWithASponsor(text)) continue;\n\n // A page that repeats its own standfirst inside the body is ordinary,\n // and reading it twice is not.\n if (!paragraphs.includes(text)) paragraphs.push(text);\n }\n\n if (paragraphs.length === 0) return undefined;\n return truncateSentences(paragraphs.join(' '), maxChars);\n}\n\n/**\n * A paragraph with its {@link REFERENCE_APPARATUS} taken out, or `undefined`\n * when there was nothing else in it.\n *\n * The whitespace pass afterwards is not tidiness: removing `[ 1 ]` from\n * `the album [ 1 ] sold` leaves two spaces, and removing a template that stood\n * alone leaves a paragraph of spaces that would otherwise be measured as\n * sixty characters of prose. The space before a comma or a full stop is the\n * same problem one punctuation mark along, and it is what a voice would read\n * as a pause in the wrong place.\n */\nfunction withoutApparatus(text: string | undefined): string | undefined {\n if (text === undefined) return undefined;\n\n let stripped = text;\n for (const [pattern, replacement] of REFERENCE_APPARATUS) stripped = stripped.replace(pattern, replacement);\n\n const tidied = stripped\n .replace(/\\s+/g, ' ')\n .replace(/\\s+([,.;:!?])/g, '$1')\n .trim();\n\n return tidied.length === 0 ? undefined : tidied;\n}\n\n/**\n * An article off the network, as plain text.\n *\n * Everything about the request except the status check is `host.fetch`'s, and\n * the status ladder is `plugin.http.ts`'s unmodified: `fetchFeed`'s shape, for\n * `fetchFeed`'s reasons.\n *\n * The one thing this adds is the content-type check, which is not fussiness. An\n * entry legitimately links to a PDF, an audio file or a video page, and a\n * megabyte of binary put through a tag stripper produces a long string of\n * plausible-looking rubbish rather than an error — which a bulletin would then\n * read out.\n *\n * `options.maxChars` is separate from `init` because the two are addressed to\n * different parties: everything in `init` is the host's business and this is the\n * parser's. The default is {@link ARTICLE_MAX_CHARS}, which is sized for a\n * bulletin — a writer that reads one sentence per story out of a model's context\n * has no use for three thousand words. A plugin contributing an enrichment\n * `SourceDocument` wants far more of the page, because what reads that is a\n * claim extractor rather than a presenter.\n */\nexport async function fetchArticle(\n host: PluginHost,\n url: string,\n init?: HostFetchInit,\n options?: { maxChars?: number },\n): Promise<string | undefined> {\n const response = await host.fetch(url, init);\n\n if (!response.ok) {\n // The body is an error page nobody wants quoted, and reading it costs a\n // round trip against the same budget a retry would want.\n await response.body?.cancel();\n\n const error = new PluginError(`article request failed: ${upstreamDetail(response.status, response.statusText)}`).withCode(\n pluginCodeForStatus(response.status),\n );\n error.upstreamStatus = response.status;\n\n const advice = retryAfterMs(response.headers.get('retry-after'));\n if (advice !== undefined) error.withRetry(advice);\n\n throw error;\n }\n\n const contentType = response.headers.get('content-type') ?? '';\n if (!/\\b(text\\/html|application\\/xhtml\\+xml)\\b/i.test(contentType)) {\n await response.body?.cancel();\n return undefined;\n }\n\n return extractArticle(await response.text(), options?.maxChars);\n}\n","import type { ChartsProvider } from './capabilities/charts.js';\nimport type { EnrichmentProvider } from './capabilities/enrichment.js';\nimport type { MusicProviderCatalog, MusicProviderOAuth, MusicProviderSteer, MusicProviderStream } from './capabilities/music.provider.js';\nimport type { NarrationProvider } from './capabilities/narration.js';\nimport type { NewsProvider } from './capabilities/news.js';\nimport type { PodcastProvider } from './capabilities/podcast.js';\nimport type { ScrobbleProvider } from './capabilities/scrobble.js';\nimport type { SearchProvider } from './capabilities/search.js';\nimport type { SimilarityProvider } from './capabilities/similarity.js';\nimport type { WeatherProvider } from './capabilities/weather.js';\nimport type { PluginManifest } from './plugin.manifest.js';\nimport type { PluginLifecycle } from './plugin.lifecycle.js';\n\n/**\n * A plugin instance: the lifecycle, plus whatever capability interfaces the\n * plugin declared in `manifest.capabilities`.\n */\nexport type PluginInstance = PluginLifecycle;\n\n/**\n * Builds a fresh plugin instance. The host calls this once per configured\n * installation, then calls `init(host)` on the result. Do no I/O here: keep\n * the constructor cheap and put setup in `init`.\n */\nexport type PluginFactory<TInstance extends PluginInstance = PluginInstance> = () => TInstance;\n\n/** What a plugin package default-exports. */\nexport interface DeadairPlugin<TInstance extends PluginInstance = PluginInstance> {\n manifest: PluginManifest;\n factory: PluginFactory<TInstance>;\n}\n\n/**\n * Instance shape for a `music-provider` plugin. Every capability method is\n * optional: implement the subset you declared in `manifest.capabilities`.\n */\nexport type MusicProviderPluginInstance = PluginLifecycle &\n Partial<MusicProviderCatalog> &\n Partial<MusicProviderStream> &\n Partial<MusicProviderSteer> &\n Partial<MusicProviderOAuth>;\n\n/** Instance shape for an `enrichment` plugin. */\nexport type EnrichmentPluginInstance = PluginLifecycle & EnrichmentProvider;\n\n/** Instance shape for a `charts` plugin. */\nexport type ChartsPluginInstance = PluginLifecycle & ChartsProvider;\n\n/** Instance shape for a `news` plugin. */\nexport type NewsPluginInstance = PluginLifecycle & NewsProvider;\n\n/** Instance shape for a `narration` plugin. */\nexport type NarrationPluginInstance = PluginLifecycle & NarrationProvider;\n\n/** Instance shape for a `podcast` plugin. `searchShows` stays optional, as it is on the capability. */\nexport type PodcastPluginInstance = PluginLifecycle & PodcastProvider;\n\n/** Instance shape for a `similarity` plugin. */\nexport type SimilarityPluginInstance = PluginLifecycle & SimilarityProvider;\n\n/** Instance shape for a `search` plugin. */\nexport type SearchPluginInstance = PluginLifecycle & SearchProvider;\n\n/** Instance shape for a `weather` plugin. */\nexport type WeatherPluginInstance = PluginLifecycle & WeatherProvider;\n\n/** Instance shape for a `scrobble` plugin. */\nexport type ScrobblePluginInstance = PluginLifecycle & ScrobbleProvider;\n\n/**\n * Pairs a manifest with its factory and returns the object a plugin package\n * default-exports. Purely a typing helper: it does no validation, because the\n * host validates the manifest with `pluginManifestSchema` at load time.\n *\n * ```ts\n * export default definePlugin(manifest, () => new MyPlugin());\n * ```\n */\nexport function definePlugin<TInstance extends PluginInstance>(\n manifest: PluginManifest,\n factory: PluginFactory<TInstance>,\n): DeadairPlugin<TInstance> {\n return { manifest, factory };\n}\n","import { XMLParser } from 'fast-xml-parser';\nimport { plainText as asPlainText } from './html.text.js';\nimport { PluginError } from './plugin.error.js';\nimport { pluginCodeForStatus, retryAfterMs, upstreamDetail } from './plugin.http.js';\nimport type { HostFetchInit, PluginHost } from './plugin.host.js';\n\n/**\n * A syndicated feed, as one shape, whichever of the three formats it arrived in.\n *\n * The sibling of `plugin.http.ts`, and here for the same reason: every plugin\n * that reads a feed writes the same normalisation, and none of it is that\n * plugin's opinion. RSS 2.0 dates an item with `pubDate` in RFC-822, Atom uses\n * `published` in ISO-8601 and RSS 1.0 uses `dc:date`; a link is an element's\n * text in two of them and an attribute in the third; a title is a bare string\n * until somebody sets `type=\"html\"` on it and it becomes an object. None of\n * that is worth learning twice.\n *\n * ## It parses and it does not fetch\n *\n * {@link parseFeed} takes a string. That is what makes it testable against real\n * documents with no host in the way, and it is the half other plugins will\n * actually reuse. {@link fetchFeed} is the thin convenience on top, and it is\n * thin deliberately: `host.fetch` already carries the allowlist, the pacing and\n * the `Retry-After` back-off, so there is nothing left here but a status check.\n *\n * Caching and conditional GET (`ETag`, `Last-Modified`) are NOT here. They\n * belong to whoever is polling — a plugin knows how often its own feeds are\n * worth asking and this file does not — and putting a cache behind a pure\n * parser would hide it from the one caller that must not be surprised by it.\n *\n * ## A podcast is a feed with an attachment\n *\n * A podcast feed is RSS 2.0 with the iTunes namespace on top, and the part a\n * station needs from it is exactly the part a news reader throws away: the\n * `enclosure`, which is the audio, and `itunes:duration`, which is how long it\n * runs. Both are read here onto {@link FeedItem.enclosure} and\n * {@link FeedItem.durationMs}, and the channel's own description, artwork and\n * language onto {@link ParsedFeed}, because a second plugin reading a podcast\n * would otherwise write the same namespace handling again. Every one of those\n * fields is optional, so a reader that wants none of them (`plugins/rss`) reads\n * exactly what it read before.\n *\n * What an enclosure is NOT is a link. {@link FeedItem.url} is still only ever\n * the page a person reads, and the audio never arrives there: a news reader\n * that followed an enclosure as though it were the story would fetch a\n * sixty-megabyte file to look for paragraphs in it.\n *\n * ## Tolerant in one specific direction\n *\n * A feed is somebody else's file, served by somebody else's edge, and the ways\n * it goes wrong are not interesting: a truncated body, an HTML error page with\n * a 200 on it, an item with no title. So a document that will not parse answers\n * with no items rather than throwing, and an item missing the one field that\n * makes it an item is dropped rather than guessed at. What is NOT tolerated is\n * a bad status, because that is the upstream saying so itself.\n */\n\n/** One entry, whatever the feed called it. */\nexport interface FeedItem {\n /**\n * A stable identifier for this entry, across polls and across restarts.\n *\n * The load-bearing field, and the reason it is never absent. Anything that\n * polls a feed has to tell an arrival from something it has already seen,\n * and the only alternative to an id is comparing timestamps — which fails\n * on a publisher that back-dates, re-dates or omits them.\n *\n * Taken from `guid`, then Atom's `id`, then the link, and only then a hash\n * of the title and date. The fallback is what makes the guarantee hold for\n * a feed that supplies none of the three, and it is a hash rather than an\n * index because a position in a list changes every time the list does.\n */\n id: string;\n title: string;\n /** The entry's own words, as plain text. See {@link FEED_SUMMARY_MAX_CHARS}. */\n summary?: string;\n url?: string;\n /** ISO-8601. Never a `Date`, and absent when the feed gave no readable one. */\n publishedAt?: string;\n author?: string;\n categories?: string[];\n /**\n * The file attached to this entry, which for a podcast is the episode\n * itself. See {@link FeedEnclosure}.\n *\n * Absent for an entry that attaches nothing, which is every entry of an\n * ordinary news feed, and for one whose attachment is not at an http(s)\n * address somebody could fetch.\n */\n enclosure?: FeedEnclosure;\n /**\n * How long the attachment runs, in whole milliseconds, as the PUBLISHER\n * says: `itunes:duration`, written as `HH:MM:SS`, `MM:SS` or bare seconds.\n *\n * A claim rather than a measurement, and absent where the feed made none\n * or made one this cannot read. Nothing here guesses one from the\n * enclosure's size, because a byte count divided by a bitrate nobody\n * stated is a number that looks measured and is not.\n */\n durationMs?: number;\n /** This entry's own artwork, where it has some (`itunes:image`). Always http(s). */\n imageUrl?: string;\n /**\n * Whether the publisher marked this entry explicit (`itunes:explicit`).\n *\n * Absent means the feed did not say, which is not the same as clean: a\n * reader enforcing a clean-only policy has to demand a positive `false`,\n * on `ProviderTrack.advisory`'s argument.\n */\n explicit?: boolean;\n /** `itunes:season`, a positive whole number, when the publisher numbers them. */\n season?: number;\n /** `itunes:episode`, a positive whole number, when the publisher numbers them. */\n episode?: number;\n}\n\n/**\n * A file attached to an entry: RSS 2.0's `<enclosure>` or an Atom\n * `<link rel=\"enclosure\">`.\n *\n * Every part except the address is the publisher's claim and is passed on as\n * one. `type` in particular is whatever the publisher's CMS wrote, and\n * `audio/x-m4a`, `audio/mp3` and an empty string are all ordinary: a reader\n * deciding what it can play reads this and the address's extension together.\n */\nexport interface FeedEnclosure {\n /** Always http(s). An enclosure at any other address is not reported at all. */\n url: string;\n /** The declared media type, lower-cased, e.g. `audio/mpeg`. */\n type?: string;\n /**\n * The declared size in bytes. Absent when the feed wrote nothing, zero, or\n * something that is not a whole number, all three of which are common: a\n * great many feeds write `length=\"0\"` because the element requires the\n * attribute and the publisher did not know.\n */\n lengthBytes?: number;\n}\n\n/** A feed, and what it holds. */\nexport interface ParsedFeed {\n title?: string;\n /** Where the publication itself lives, as opposed to any one entry. */\n homeUrl?: string;\n /** What the publication says about itself, as plain text. See {@link FEED_SUMMARY_MAX_CHARS}. */\n description?: string;\n /** Who publishes it: `itunes:author`, or an Atom feed's own `author`. */\n author?: string;\n /** The publication's artwork: `itunes:image`, or RSS 2.0's `<image><url>`. Always http(s). */\n imageUrl?: string;\n /** The language the publication declares, as written (`en`, `en-us`). */\n language?: string;\n /** The publication's own labels, including iTunes categories. Deduplicated, order kept. */\n categories?: string[];\n /** Whether the publisher marked the whole publication explicit. See {@link FeedItem.explicit}. */\n explicit?: boolean;\n /** Newest first where the feed said, and otherwise in the order it listed them. */\n items: FeedItem[];\n}\n\n/**\n * How much of an entry's own words are kept.\n *\n * A description is written for a browser and its destination here is a model's\n * context and possibly a voice, neither of which wants three paragraphs of a\n * press release. Generous enough to hold a real first paragraph, short enough\n * that twenty of them still leave room to think.\n */\nexport const FEED_SUMMARY_MAX_CHARS = 500;\n\n/**\n * `removeNSPrefix` is what makes `dc:date`, `content:encoded` and `rdf:RDF`\n * readable without a namespace table, and the cost of it is that a feed\n * carrying both `link` and `atom:link` collapses them — which every reader\n * below already handles, because a repeated element arrives as an array.\n *\n * Module-level because it is stateless and building one per document would be\n * the most expensive part of parsing a small feed.\n */\nconst parser = new XMLParser({\n ignoreAttributes: false,\n attributeNamePrefix: '@_',\n removeNSPrefix: true,\n trimValues: true,\n // A guid of `12345` is an id, not a number, and a `<title>2024</title>` is a\n // title. Every field here is read as text, so parsing any of it as a number\n // only produces a value whose `.trim` is missing.\n parseTagValue: false,\n parseAttributeValue: false,\n});\n\n/**\n * A feed document, read.\n *\n * Answers with an empty feed rather than throwing, for anything that is not one\n * — a parse failure, an HTML page, an empty body. The caller's next move is the\n * same in every case, and a plugin fanning out over five feeds must not lose\n * four of them to the fifth.\n */\nexport function parseFeed(xml: string): ParsedFeed {\n let document: unknown;\n try {\n document = parser.parse(xml);\n } catch {\n return { items: [] };\n }\n\n if (!isRecord(document)) return { items: [] };\n\n // RSS 2.0, Atom, RSS 1.0/RDF. The `channel` of an RDF document is a sibling\n // of its items rather than their parent, which is the one structural\n // difference between the three and the reason `items` is looked up in both\n // places rather than only under the channel.\n const rss = record(document.rss);\n const rdf = record(document.RDF);\n const atom = record(document.feed);\n const channel = record(rss?.channel) ?? record(rdf?.channel) ?? atom;\n\n if (channel === undefined) return { items: [] };\n\n const entries = [...asArray(record(rss?.channel)?.item), ...asArray(rdf?.item), ...asArray(atom?.entry)];\n\n const feed: ParsedFeed = { items: entries.flatMap(entry => (isRecord(entry) ? (readItem(entry) ?? []) : [])) };\n\n const title = speakable(channel.title);\n if (title !== undefined) feed.title = title;\n\n const homeUrl = readLink(channel.link);\n if (homeUrl !== undefined) feed.homeUrl = homeUrl;\n\n // `itunes:summary` arrives as `summary` once the prefix is gone, and is the\n // longer of the two where a podcast carries both; `subtitle` is the last\n // resort, being a line rather than a description.\n const description = plainText(channel.description ?? channel.summary ?? channel.subtitle);\n if (description !== undefined) feed.description = description;\n\n const author = readAuthor(channel.author);\n if (author !== undefined) feed.author = author;\n\n const imageUrl = readImage(channel.image);\n if (imageUrl !== undefined) feed.imageUrl = imageUrl;\n\n const language = text(channel.language);\n if (language !== undefined) feed.language = language;\n\n const categories = readCategories(channel.category);\n if (categories.length > 0) feed.categories = categories;\n\n const explicit = readExplicit(channel.explicit);\n if (explicit !== undefined) feed.explicit = explicit;\n\n return feed;\n}\n\n/**\n * A feed off the network.\n *\n * Everything about the request except the status check is `host.fetch`'s: the\n * allowlist, the pacing, the redirect re-checks, the body caps. What is left is\n * the one decision a plugin should not be making differently from its\n * neighbours — which {@link PluginErrorCode} a status means — and that is\n * `plugin.http.ts`'s ladder, unmodified. A service with its own reading of a\n * status layers that in front by calling {@link parseFeed} itself.\n */\nexport async function fetchFeed(host: PluginHost, url: string, init?: HostFetchInit): Promise<ParsedFeed> {\n const response = await host.fetch(url, init);\n\n if (!response.ok) {\n // The body is an error page nobody wants quoted, and reading it costs a\n // round trip against the same budget the retry would want.\n await response.body?.cancel();\n\n const error = new PluginError(`feed request failed: ${upstreamDetail(response.status, response.statusText)}`).withCode(\n pluginCodeForStatus(response.status),\n );\n error.upstreamStatus = response.status;\n\n const advice = retryAfterMs(response.headers.get('retry-after'));\n if (advice !== undefined) error.withRetry(advice);\n\n throw error;\n }\n\n return parseFeed(await response.text());\n}\n\n/**\n * One entry, or nothing when it is not one.\n *\n * A title is the only field an entry cannot be without: everything downstream\n * either names it or reads it aloud, and an entry with no title is one that\n * would air as a pause. Every other field is genuinely optional, including the\n * link, because plenty of feeds carry announcements that point nowhere.\n */\nfunction readItem(entry: Record<string, unknown>): FeedItem | undefined {\n const title = speakable(entry.title);\n if (title === undefined) return undefined;\n\n const url = readLink(entry.link);\n const publishedAt = readDate(entry.pubDate ?? entry.published ?? entry.date ?? entry.updated ?? entry.issued);\n // `content:encoded` is the full article and `description` is usually the\n // teaser, so the teaser is preferred and the article is the fallback: this\n // is a summary field and truncating an article into one produces half a\n // sentence where a publisher had already written a whole one.\n const summary = plainText(entry.description ?? entry.summary ?? entry.encoded ?? entry.content);\n const author = readAuthor(entry.author ?? entry.creator);\n const categories = readCategories(entry.category);\n\n const item: FeedItem = { id: readId(entry, title, publishedAt, url), title };\n\n if (summary !== undefined) item.summary = summary;\n if (url !== undefined) item.url = url;\n if (publishedAt !== undefined) item.publishedAt = publishedAt;\n if (author !== undefined) item.author = author;\n if (categories.length > 0) item.categories = categories;\n\n const enclosure = readEnclosure(entry.enclosure) ?? readEnclosure(entry.link);\n if (enclosure !== undefined) item.enclosure = enclosure;\n\n const durationMs = readDuration(entry.duration);\n if (durationMs !== undefined) item.durationMs = durationMs;\n\n const imageUrl = readImage(entry.image);\n if (imageUrl !== undefined) item.imageUrl = imageUrl;\n\n const explicit = readExplicit(entry.explicit);\n if (explicit !== undefined) item.explicit = explicit;\n\n const season = readOrdinal(entry.season);\n if (season !== undefined) item.season = season;\n\n const episode = readOrdinal(entry.episode);\n if (episode !== undefined) item.episode = episode;\n\n return item;\n}\n\n/** See {@link FeedItem.id} for why the ladder is in this order and why it ends in a hash. */\nfunction readId(entry: Record<string, unknown>, title: string, publishedAt: string | undefined, url: string | undefined): string {\n return text(entry.guid) ?? text(entry.id) ?? url ?? `hash:${hash(`${title}\\u0000${publishedAt ?? ''}`)}`;\n}\n\n/**\n * A link, from either of the two places a feed puts one.\n *\n * RSS writes the URL as the element's text; Atom writes it as an `href`\n * attribute and may write several, distinguished by `rel`. `alternate` (or an\n * absent `rel`, which means `alternate`) is the human-readable page, which is\n * the only one anything here wants — `self` is the feed itself and `enclosure`\n * is an attachment, and returning either would have a reader link a listener\n * back to the XML.\n */\nfunction readLink(value: unknown): string | undefined {\n const candidates = asArray(value);\n\n for (const candidate of candidates) {\n if (typeof candidate === 'string') return trimmed(candidate);\n if (!isRecord(candidate)) continue;\n\n const rel = text(candidate['@_rel']);\n if (rel !== undefined && rel !== 'alternate') continue;\n\n const href = text(candidate['@_href']) ?? text(candidate['#text']);\n if (href !== undefined) return href;\n }\n\n return undefined;\n}\n\n/**\n * A date as ISO-8601, or nothing.\n *\n * `Date` parses RFC-822 (RSS) and ISO-8601 (Atom) alike, which is the whole\n * reason this is three lines rather than a format table. Anything it cannot\n * read answers `undefined` rather than a guess or an epoch: \"when this was\n * published is unknown\" is a fact a reader can act on, and 1970 is not.\n */\nfunction readDate(value: unknown): string | undefined {\n const raw = text(value);\n if (raw === undefined) return undefined;\n\n const at = new Date(raw);\n return Number.isNaN(at.getTime()) ? undefined : at.toISOString();\n}\n\n/** RSS writes a name or an address; Atom nests a `name` inside an `author` element. */\nfunction readAuthor(value: unknown): string | undefined {\n const first = asArray(value)[0];\n if (typeof first === 'string') return trimmed(first);\n if (!isRecord(first)) return undefined;\n\n return text(first.name) ?? text(first['#text']);\n}\n\n/**\n * RSS writes a category as text, Atom as a `term` attribute, and iTunes as a\n * `text` attribute with its subcategories nested inside it. Deduplicated,\n * order kept, parents before their children.\n */\nfunction readCategories(value: unknown): string[] {\n const seen = new Set<string>();\n\n const visit = (candidates: unknown[]): void => {\n for (const candidate of candidates) {\n const name =\n typeof candidate === 'string'\n ? trimmed(candidate)\n : isRecord(candidate)\n ? (text(candidate['@_term']) ?? text(candidate['@_text']) ?? text(candidate['#text']))\n : undefined;\n if (name !== undefined) seen.add(name);\n if (isRecord(candidate)) visit(asArray(candidate.category));\n }\n };\n\n visit(asArray(value));\n return [...seen];\n}\n\n/**\n * The first attachment that is at a fetchable address, from either place a\n * feed writes one.\n *\n * RSS 2.0 permits one `<enclosure>` per item and a good number of feeds write\n * several anyway; Atom writes any number of `<link rel=\"enclosure\">` beside the\n * page link. Both arrive as a list here, and an AUDIO attachment is preferred\n * over whatever came first, because a podcast that also attaches its cover as\n * a second enclosure is ordinary and the cover is not the episode.\n *\n * Read off `link` as well as `enclosure`, and only ever the entries whose\n * `rel` says enclosure: an Atom link with no `rel` is the page, which\n * `readLink` answers and this must not.\n */\nfunction readEnclosure(value: unknown): FeedEnclosure | undefined {\n const found: FeedEnclosure[] = [];\n\n for (const candidate of asArray(value)) {\n if (!isRecord(candidate)) continue;\n\n // An RSS `<enclosure>` has no `rel`; an Atom link has to say it is one.\n const rel = text(candidate['@_rel']);\n const isAtomLink = candidate['@_href'] !== undefined;\n if (isAtomLink && rel !== 'enclosure') continue;\n\n const url = webAddress(text(candidate['@_url']) ?? text(candidate['@_href']));\n if (url === undefined) continue;\n\n const enclosure: FeedEnclosure = { url };\n const type = text(candidate['@_type'])?.toLowerCase();\n if (type !== undefined) enclosure.type = type;\n const lengthBytes = positiveWhole(text(candidate['@_length']));\n if (lengthBytes !== undefined) enclosure.lengthBytes = lengthBytes;\n\n found.push(enclosure);\n }\n\n return found.find(enclosure => enclosure.type?.startsWith('audio/') === true) ?? found[0];\n}\n\n/**\n * `itunes:duration` as whole milliseconds, or nothing.\n *\n * Three spellings are in the wild and all three are read: `HH:MM:SS`, `MM:SS`,\n * and bare seconds, any of them with a fraction on the last part. Anything\n * else — `1h 2m`, `about an hour`, an empty element — answers `undefined`\n * rather than a guess, on {@link readDate}'s argument: an unknown length is a\n * fact a reader can act on and a wrong one is not.\n *\n * A duration of zero is absent too. Publishers write `0` and `00:00:00` when\n * their CMS did not know, and a zero-length episode is not a thing anything\n * should plan around.\n */\nfunction readDuration(value: unknown): number | undefined {\n const raw = text(asArray(value)[0]);\n if (raw === undefined) return undefined;\n\n const parts = raw.split(':').map(part => part.trim());\n if (parts.length > 3) return undefined;\n if (!parts.every((part, at) => (at === parts.length - 1 ? /^\\d+(\\.\\d+)?$/ : /^\\d+$/).test(part))) return undefined;\n\n // Only the LEADING unit may run past 59: `90:00` is an ordinary way to write an hour and a\n // half, where `1:90:00` and `12:75` are typos, and reading either would be a guess.\n if (parts.slice(1).some(part => Number(part) >= 60)) return undefined;\n\n const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);\n const ms = Math.round(seconds * 1_000);\n return Number.isFinite(ms) && ms > 0 ? ms : undefined;\n}\n\n/**\n * Artwork, from either of the two shapes a feed writes it in.\n *\n * `itunes:image` is an `href` attribute; RSS 2.0's own `<image>` nests a\n * `<url>`. A podcast channel commonly carries both, which arrive together as\n * a list once the prefix is gone, and the iTunes one is preferred: it is the\n * square the directories show, where RSS's is a small banner.\n */\nfunction readImage(value: unknown): string | undefined {\n const candidates = asArray(value);\n\n for (const candidate of candidates) {\n if (isRecord(candidate)) {\n const href = webAddress(text(candidate['@_href']));\n if (href !== undefined) return href;\n }\n }\n\n for (const candidate of candidates) {\n const url = isRecord(candidate) ? webAddress(text(candidate.url)) : webAddress(text(candidate));\n if (url !== undefined) return url;\n }\n\n return undefined;\n}\n\n/**\n * `itunes:explicit`, which has been spelled four ways across the spec's\n * history. Anything else is absent rather than either answer.\n */\nfunction readExplicit(value: unknown): boolean | undefined {\n const raw = text(asArray(value)[0])?.toLowerCase();\n if (raw === 'yes' || raw === 'true' || raw === 'explicit') return true;\n if (raw === 'no' || raw === 'false' || raw === 'clean') return false;\n return undefined;\n}\n\n/** A season or episode number: a positive whole number, or nothing. */\nconst readOrdinal = (value: unknown): number | undefined => positiveWhole(text(asArray(value)[0]));\n\n/** A positive whole number written as text, or nothing. */\nfunction positiveWhole(raw: string | undefined): number | undefined {\n if (raw === undefined || !/^\\d+$/.test(raw)) return undefined;\n const parsed = Number(raw);\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n\n/**\n * An address somebody could fetch, or nothing.\n *\n * Art and audio are both handed on to be fetched by something that is not\n * this plugin, and the SDK's rule for an art URL is that it is http(s) only;\n * the same rule is applied to the audio for the same reason. `file:`,\n * `data:` and a relative path are all things a feed can contain.\n */\nfunction webAddress(raw: string | undefined): string | undefined {\n if (raw === undefined) return undefined;\n try {\n const { protocol } = new URL(raw);\n return protocol === 'http:' || protocol === 'https:' ? raw : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * An element's markup as something a person could be read aloud.\n *\n * The stripping, decoding and capping are `html.text.ts`'s, shared with the\n * article reader; what is left here is reaching the element's text first, which\n * is this file's own problem. See that file for why the decode runs after the\n * strip and not before.\n */\nfunction plainText(value: unknown): string | undefined {\n const raw = text(value);\n return raw === undefined ? undefined : asPlainText(raw, FEED_SUMMARY_MAX_CHARS);\n}\n\n/**\n * FNV-1a, as hex.\n *\n * Not a cryptographic hash and not asked to be one: it identifies an entry\n * within one feed, where a collision costs one repeated headline. Written out\n * rather than taken from `node:crypto` so this file stays a pure string\n * function with no platform import, which is what lets a plugin call it from\n * anywhere.\n */\nfunction hash(value: string): string {\n let accumulated = 0x811c9dc5;\n\n for (let at = 0; at < value.length; at += 1) {\n accumulated ^= value.charCodeAt(at);\n accumulated = Math.imul(accumulated, 0x01000193);\n }\n\n return (accumulated >>> 0).toString(16).padStart(8, '0');\n}\n\n/** A repeated element arrives as an array and a single one does not. This is that, flattened. */\nfunction asArray(value: unknown): unknown[] {\n if (value === undefined || value === null) return [];\n return Array.isArray(value) ? value : [value];\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst record = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);\n\nconst trimmed = (value: string): string | undefined => (value.trim().length === 0 ? undefined : value.trim());\n\n/**\n * An element's text, wherever the parser put it.\n *\n * A field is a bare string until it carries an attribute, at which point it\n * becomes `{ '#text': …, '@_type': … }`. Reading only the first shape is the\n * single easiest way to lose a title, because `type=\"html\"` on one is entirely\n * ordinary.\n *\n * And it is an ARRAY when the element is repeated, which `removeNSPrefix`\n * makes far commoner than any feed intends: a podcast writes both `<title>`\n * and `<itunes:title>`, and with the prefix gone those are two `title`s. The\n * first readable one is the answer, which is the plain RSS element wherever a\n * publisher wrote both in the usual order. Before this, every entry of such a\n * feed was dropped as having no title at all — measured on NPR's Planet Money\n * feed, 355 entries and none of them read.\n */\nfunction text(value: unknown): string | undefined {\n if (typeof value === 'string') return trimmed(value);\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (Array.isArray(value)) {\n for (const candidate of value) {\n const found = text(candidate);\n if (found !== undefined) return found;\n }\n return undefined;\n }\n if (isRecord(value)) return text(value['#text']);\n\n return undefined;\n}\n\n/**\n * A TITLE, which is text a voice will say rather than a field something matches on.\n *\n * The same treatment {@link FeedItem.summary} already gets, and it has to be: the XML parser\n * decodes the document's own escaping ONCE, which is right for a title written as `AT&T` and\n * not enough for one written as `it&#8217;s` — an apostrophe a publisher escaped twice, which\n * is entirely ordinary in a feed whose titles came out of a CMS. What arrives here is then\n * `it’s`, and a bulletin read that out on air with the entity still in it.\n *\n * Tags come out for the same reason: `type=\"html\"` on a title is ordinary, and a `<em>` reaching a\n * speaking voice is the failure `NewsItem.summary` documents.\n */\nconst speakable = (value: unknown): string | undefined => {\n const raw = text(value);\n return raw === undefined ? undefined : asPlainText(raw);\n};\n","/**\n * Comparison forms for matching a title or artist name across two sources\n * that never spell either one the same way: a personal library's tags, a\n * provider's catalog, and MusicBrainz's own recording titles all disagree on\n * case, accents, punctuation, and which decorations belong on a title at all.\n *\n * Both `normalize` and `baseForm` were defined identically in more than one\n * plugin, because both feed match SCORING: a difference between the copies\n * would silently make two plugins match the same input differently.\n */\n\n/** Everything that is decoration rather than identity once a string is lowercased. */\nconst PUNCTUATION = /[^\\p{L}\\p{N}\\s]/gu;\n\n/** A parenthesised or bracketed suffix: `(2011 Remaster)`, `[Live]`. */\nconst PARENTHETICAL = /[([{][^)\\]}]*[)\\]}]/g;\n\n/**\n * Comparison form: lowercased, unaccented, stripped of punctuation, with runs\n * of whitespace collapsed. `Beyoncé` and `Beyonce`, `Mr. Brightside` and\n * `Mr Brightside` are the same string here.\n */\nexport function normalize(value: string): string {\n return value\n .normalize('NFD')\n .replace(/\\p{Diacritic}/gu, '')\n .toLowerCase()\n .replace(PUNCTUATION, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * {@link normalize} with a parenthesised or dashed suffix removed, so\n * `Roads (2011 Remaster)` and `Roads - Live` compare equal to `Roads`.\n *\n * This is where re-issues are caught. A personal library and a provider's\n * catalog are both full of these: the tags on a rip say what the pressing\n * said, MusicBrainz holds the recording under its plain name, and the same\n * song ends up appearing three times under three decorations. An exact-only\n * comparison misses most of a real library.\n */\nexport function baseForm(value: string): string {\n return normalize(value.replace(PARENTHETICAL, ' ').split(/\\s+[-–—]\\s+/)[0] ?? value);\n}\n","/**\n * The version of the deadair plugin API that this SDK implements.\n *\n * A plugin declares the range of API versions it is compatible with in its\n * manifest's `apiVersion` field (a semver range, e.g. `^1.0.0`). The host\n * refuses to load a plugin whose declared range does not satisfy this value.\n */\nexport const PLUGIN_API_VERSION = '1.0.0';\n","import { PluginError } from './plugin.error.js';\nimport type { PluginHost } from './plugin.host.js';\nimport type { PluginLifecycle } from './plugin.lifecycle.js';\n\n/** Undoes one thing a plugin set up. Run in reverse order of registration when the plugin unloads. */\nexport type PluginDisposer = () => void | Promise<void>;\n\n/**\n * The base a plugin extends, so unloading is correct by construction.\n *\n * Two jobs, both of them chores every plugin was otherwise doing by hand:\n *\n * 1. **`this.host` is there or it throws with a sentence.** Each plugin used to\n * hold `host?: PluginHost` and write its own `hostOrThrow()`, or forget to\n * and get a `TypeError` about reading a property of undefined instead.\n * 2. **Teardown is registered next to setup.** {@link Plugin.register} takes an\n * undo, and the base runs every one on unload, last registered first,\n * whether or not {@link Plugin.onUnload} throws.\n *\n * The second matters MORE in this host, not less. Plugins run inside the API process\n * (`packages/plugin-sdk/CLAUDE.md` § \"Trust and egress\"), so a timer or a socket a plugin forgets\n * is not confined to a sandbox that gets torn down: it lives in the server until somebody restarts\n * it. Reloading a plugin on every config change is a normal thing an operator does, so \"forgets on\n * unload\" compounds.\n *\n * ```ts\n * class MyPlugin extends Plugin implements EnrichmentPluginInstance {\n * protected async onLoad(): Promise<void> {\n * const timer = setInterval(() => void this.refresh(), 60_000);\n * this.register(() => clearInterval(timer));\n * }\n * }\n * ```\n *\n * Extending this is optional: the host only ever asks for `PluginLifecycle`, so\n * a plugin that implements `init` and `dispose` itself is as valid as it was.\n */\nexport abstract class Plugin implements PluginLifecycle {\n private currentHost?: PluginHost;\n private readonly disposers: PluginDisposer[] = [];\n\n /**\n * The host, once `init` has run.\n *\n * A getter rather than a field so the failure is a sentence naming the\n * plugin instead of a `TypeError` from somewhere three calls deeper.\n *\n * @throws {PluginError} `internal` when read before `init` or after\n * `dispose`. Both are host bugs rather than operator ones, which is what\n * that code means.\n */\n protected get host(): PluginHost {\n if (this.currentHost === undefined) {\n throw new PluginError(`${this.constructor.name} was used before init() or after dispose()`).withCode('internal');\n }\n return this.currentHost;\n }\n\n /**\n * Registers something to undo when this plugin unloads.\n *\n * Call it next to the setup it undoes, which is the whole point: a\n * `clearInterval` written beside its `setInterval` is one that cannot drift\n * away from it as the file grows.\n */\n protected register(disposer: PluginDisposer): void {\n this.disposers.push(disposer);\n }\n\n /** {@link Plugin.register} for the common case. Works for `setTimeout` and `setInterval` alike. */\n protected registerTimer(timer: ReturnType<typeof setTimeout>): void {\n this.register(() => clearTimeout(timer));\n }\n\n /** Your setup. Called once, after `this.host` is available and before any capability method. */\n protected onLoad(): Promise<void> {\n return Promise.resolve();\n }\n\n /**\n * Anything left to undo that `register` could not express, such as dropping\n * a cache. Runs after every registered disposer.\n */\n protected onUnload(): Promise<void> {\n return Promise.resolve();\n }\n\n async init(host: PluginHost): Promise<void> {\n this.currentHost = host;\n await this.onLoad();\n }\n\n /**\n * Undoes everything, in reverse.\n *\n * Every disposer runs even if an earlier one throws, because one broken\n * undo must not strand the rest: the failure is logged and the walk\n * continues. `onUnload` runs afterwards for the same reason it exists, and\n * `this.host` is released last so both still have it.\n *\n * Idempotent. The host calls it on unload, on a config change and at\n * shutdown, and those can overlap.\n */\n async dispose(): Promise<void> {\n if (this.currentHost === undefined) return;\n\n for (const disposer of this.disposers.splice(0).reverse()) {\n try {\n await disposer();\n } catch (error) {\n this.currentHost.logger.warn('a plugin disposer failed', {\n plugin: this.constructor.name,\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n try {\n await this.onUnload();\n } finally {\n this.currentHost = undefined;\n }\n }\n}\n","import { z } from 'zod';\n\n/**\n * The kinds of input a plugin can ask the operator for.\n *\n * - `string` free text, one line\n * - `text` free text over several lines, for anything a person writes\n * rather than pastes: a prompt, a persona, a list of phrasings.\n * Stored exactly like a `string`, so nothing downstream has to\n * know it exists; the difference is the box the operator types\n * into, and a one-line box for a paragraph is the reason a\n * setting like that ends up being edited by hand in psql\n * - `url` free text validated/normalised as a URL\n * - `secret` write-only: the host encrypts it, the settings UI never reads\n * it back, and only `host.secrets.get()` sees the plaintext\n * - `number` numeric input\n * - `boolean` toggle\n * - `select` one of `options`\n * - `multiselect` any number of `options`, stored as a JSON array of the chosen\n * values. Read it back with {@link parseMultiSelect}\n * - `list` any number of ROWS with the same `columns`, stored as a JSON\n * array of objects. Read it back with {@link parseRows}. For a\n * list whose entries have parts — a feed with a name and a\n * category — where the alternative is a `text` field with a\n * separator in it and a line an operator can mistype into\n * silence\n * - `note` not an input at all: static help text rendered in the form\n */\nexport type ConfigFieldType = 'string' | 'text' | 'url' | 'secret' | 'number' | 'boolean' | 'select' | 'multiselect' | 'list' | 'note';\n\n/**\n * What a `number` field's value is measured in, so the form can offer a control a person can use.\n *\n * The VALUE is always stored in the unit named here — `bytes` means the row holds bytes — and the\n * console converts on the way in and out. That is the whole point: a byte count is the right thing\n * for code to compare against and a terrible thing to type, and the alternative to declaring it is\n * either storing a friendlier unit (and doing the multiplication at every reader) or special-casing\n * a particular setting key inside the form, which is the kind of thing nobody finds later.\n *\n * `fraction` is the second member and the one the first paragraph predicted: the row holds a share\n * between 0 and 1, because that is what the code multiplying by it wants, and the console says\n * `40%`, because that is what a person means. Honoured by a `slider`, which is the control every\n * share in this station asks for; a fraction drawn as an ordinary spinner is still shown as the\n * fraction it stores, since a number typed exactly is unambiguous either way.\n *\n * An enum rather than a boolean because the third member is obvious (a duration in milliseconds has\n * exactly the same problem) and because a closed set is what the contract mirroring this can\n * express.\n */\nexport type ConfigFieldUnit = 'bytes' | 'fraction';\n\n/**\n * The control a field asks for, where the default one for its type is not the readable one.\n *\n * {@link ConfigFieldUnit}'s sibling, and the same bargain: what is STORED does not change, only the\n * thing the operator touches. A `slider` over a share between 0 and 1 stores a fraction, and every\n * reader still reads a fraction.\n *\n * Opt-in per field rather than inferred from `min` and `max` being present, which is the whole\n * point. A slider is right for a value somebody feels for (a percentage, a trim in decibels, one\n * pad every N breaks) and wrong for one they have to hit exactly: 3500 out of 0 to 600000 is a\n * pixel, and a pause in milliseconds is a number an operator types rather than aims at. Declaring\n * it makes that a judgement per setting instead of a rule that is right eight times and wrong six.\n *\n * A `slider` must declare both `min` and `max`, since a range with no ends is not one a track can\n * be drawn for. A field asking for one without them falls back to the ordinary input rather than\n * failing: this is a hint about drawing, and a form that renders nothing is worse than a form that\n * renders a spinner.\n *\n * `tags` is for a `string` that is really a SET, stored as one comma-separated line because that is\n * what the reader behind it splits. It changes nothing about the value: the form splits on the way\n * in and joins on the way out, so `dialogueKinds()` keeps parsing exactly the string it always did.\n * What it buys is that a set of names is added to and removed from one at a time, rather than by\n * editing punctuation in a sentence — a stray comma in a one-line box is a kind the station simply\n * does not have, and nothing says so. The cost is that a value CONTAINING a comma cannot be one\n * tag, which is why this is asked for per field rather than being what a `string` does.\n */\nexport type ConfigFieldControl = 'slider' | 'tags';\n\n/** One choice in a `select`, a `multiselect`, or a suggestion list. */\nexport interface ConfigFieldOption {\n value: string;\n label: string;\n}\n\n/**\n * Where a field's or a column's choices come from when neither the plugin nor the operator's own\n * server is the one that knows them.\n *\n * A closed HOST vocabulary, and the third of three ways a choice can be offered: `options` is what\n * the PLUGIN decided when its manifest was written, `suggestConfigOptions()` is what the operator's\n * own server currently says, and this is what the STATION says. It exists because a plugin cannot\n * ask — a news plugin has no way to learn which categories this station holds, and the alternative\n * is a free-text cell where `sports` and `sport` are a silent miss nobody sees until bulletins start\n * declining.\n *\n * `intl.timeZones` is the second member and stretches the name slightly: it is the PLATFORM's list\n * rather than the station's, out of `Intl.supportedValuesOf('timeZone')`. It belongs here anyway,\n * because the property is about who can answer rather than about where the answer is kept, and the\n * console is again the only side that can — a zone name has to be one the browser and the server\n * both know, and a server that enumerated its own would be answering for a different machine.\n *\n * `station.newsFeeds` answers the feeds the installed plugins currently offer, by their qualified id\n * and the operator's own name for each. It is the one source whose value is minted by a PLUGIN and\n * whose list only the station can assemble, which is why it is here rather than being something a\n * plugin could answer: the ids are qualified with the plugin that offered them, and no plugin knows\n * what the others are called.\n *\n * `station.podcastShows` is `station.newsFeeds`' twin for the programmes the station carries: every\n * show every podcast plugin currently lists, by its qualified id and its title, for the topic that\n * says which show a `syndicated` band on the format clock means. Here for the same reason: the ids\n * are qualified with the plugin that carries the show, and no plugin knows what the others carry.\n *\n * `station.narrationSeries` is the same shape once more, for the books and columns the station reads\n * out: every series every narration plugin currently offers, for the topic that says which one a\n * `narration` band means. Third instance of one argument, which is what makes it a pattern rather\n * than three special cases: an id a plugin minted and only the station can collect.\n *\n * The four `plugins.*` members answer the enabled plugins that declare a given capability — speech,\n * llm, mixer, analysis — by id and name, for the settings that pick which plugin a capability with\n * several installed candidates uses. Those settings stay free text (`selectPlugin` in\n * `plugin.selection.ts` accepts an id that is not currently a candidate without falling back), so\n * this is a suggestion list rather than a closed `select` — the console resolves it the same way as\n * the other sources here, against the plugin list rather than a static enum.\n *\n * `llm.models` is the odd one and the only member whose answer comes from a PLUGIN rather than from\n * a station table: the models the selected model plugin currently offers, for the settings that say\n * which model a particular writer should use. It resolves through the suggestions route every plugin\n * settings form already uses, against whichever plugin `llm.pluginId` names, so the six writer\n * settings offer the same list the plugin's own form does — including which provider each model\n * lives on, since a model name carries that.\n *\n * An enum rather than a boolean for {@link ConfigFieldUnit}'s reason: the next one (voices,\n * personas) is obvious, and a closed set is what the contract mirroring this can express. Whatever\n * it names is resolved by the CONSOLE; nothing here reaches a plugin.\n */\nexport type ConfigFieldOptionSource =\n | 'station.newsCategories'\n | 'station.newsFeeds'\n | 'station.podcastShows'\n | 'station.narrationSeries'\n | 'intl.timeZones'\n | 'plugins.speech'\n | 'plugins.llm'\n | 'plugins.mixer'\n | 'plugins.analysis'\n | 'llm.models';\n\n/**\n * One column of a `list` field.\n *\n * Still a smaller vocabulary than {@link ConfigFieldType} — no nested `list`, because a table inside\n * a table is a form nobody can fill in — but `secret` is now in it, and the reason it was not is\n * worth keeping because it is what had to be built to allow it. A row was stored as plain JSON with\n * nothing to encrypt one cell against: a ciphertext has to belong to a ROW, and a row had no\n * identity beyond its position in an array the console rewrites whole on every save. {@link ROW_ID_KEY}\n * is that identity, and {@link rowSecretKey} is where the cell's ciphertext lives.\n *\n * Every ordinary cell is stored as a STRING in the row, so a column is about the control the\n * operator gets rather than about the shape of what is kept. A `secret` cell is the exception and\n * the exception is the point: it is never in the row at all, and the row an author reads back has\n * no trace of it. See {@link readRowSecret}.\n */\nexport interface ConfigFieldColumn {\n /**\n * Key this cell is stored under inside the row object.\n *\n * Whatever suits the plugin, dots included, exactly like {@link ConfigField.key}. A row editor\n * addresses a cell by a path and a dot in a path means a step into a nested object, but that is\n * the FORM's problem and it solves it the way it already solves the same problem for a\n * dot-keyed station setting: it names its inputs positionally and puts the real keys back on\n * the way out. Nothing a plugin author has to know about.\n *\n * One character is refused, and only because {@link rowSecretKey} joins on it: a `/`. That\n * separator has to be unambiguous or a cell's ciphertext could be addressed by two different\n * keys, so it is refused at the schema rather than escaped.\n */\n key: string;\n\n /** Column heading. */\n label: string;\n\n /**\n * `string` free text, `url` free text meant to be an address, `select` one of `options`, and\n * `secret` a credential this row holds — write-only, encrypted per cell, and never in the row.\n */\n type: 'string' | 'url' | 'select' | 'secret';\n\n /** Whether a row is only counted once this cell is filled in. */\n required?: boolean;\n\n /** Ghost text inside the cell. */\n placeholder?: string;\n\n /** Choices, fixed when the manifest is written. See {@link ConfigField.options}. */\n options?: ConfigFieldOption[];\n\n /** Choices only the station can enumerate. See {@link ConfigFieldOptionSource}. */\n optionsFrom?: ConfigFieldOptionSource;\n\n /**\n * Key of another column in the same list. This cell only applies to a row whose cell there\n * holds one of {@link dependsOnValues}.\n *\n * For the table whose columns are not all about the same row: a provider list where the\n * address belongs to a self-hosted server and the API key to a vendor, a supplier list where\n * one kind is reached by URL and another by account id. Without it every column is drawn on\n * every row, so an operator meets a cell their row has no use for, with no way to tell it from\n * one they have not filled in yet.\n *\n * **This is not the rendering hint {@link ConfigField.dependsOn} is**, and the difference is\n * the whole reason it is here. A field-level `dependsOn` hides a control and the server never\n * reads it. This says the cell DOES NOT APPLY, so the console declines to send it and the host\n * declines to derive anything from it — which for a `url` column means the row contributes no\n * hostname to the plugin's allowlist (`addressCells` in `plugin.host.factory.ts`). An address\n * typed on a row before its kind was changed would otherwise widen the allowlist by a host the\n * plugin can never call, which is exactly the quiet widening that path exists to refuse.\n *\n * Forgiving in three places, all of them the same instinct as `isVisible` in the console's\n * form: a target this list does not declare shows the cell, a target cell that is EMPTY shows\n * the cell, and values without a target are ignored. The empty case is the load-bearing one. A\n * column has no `default`, so a row somebody has just added holds `''` in every cell, and a\n * rule that hid a cell there would hide it on the one row that most needs filling in.\n *\n * A `secret` cell that does not apply keeps whatever is stored rather than being cleared.\n * Absent already means \"keep it\" in `plugin.config.rows.ts`, and reading a visibility rule as\n * an instruction to destroy a credential would be a surprise nobody asked for.\n */\n dependsOn?: string;\n\n /**\n * The values of the {@link dependsOn} cell that this one applies to. Ignored without a target.\n *\n * Omitted means \"any value at all\", which is what a field-level `dependsOn` already means, so\n * an author who knows that one knows this.\n */\n dependsOnValues?: string[];\n}\n\n/**\n * The chosen values of a `multiselect`, out of the string it is stored as.\n *\n * Stored as a JSON array because plugin config is a string map, and read back\n * through here so every plugin agrees on the encoding. Tolerant on purpose: a\n * value hand-edited into something unreadable answers empty rather than failing\n * a load, which for a field like \"which models can use tools\" is the difference\n * between a degraded station and one that will not start.\n */\nexport function parseMultiSelect(raw: unknown): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return [];\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed.filter((value): value is string => typeof value === 'string' && value.trim().length > 0).map(value => value.trim());\n } catch {\n return [];\n }\n}\n\n/**\n * The cell key a row's own identity is stored under.\n *\n * A reserved name rather than a column an author declares, because it is not data: nobody types it,\n * nothing renders it, and a plugin that ignores it entirely is a plugin that behaves exactly as it\n * did before this existed. The host mints one when a row is first saved and preserves it forever\n * after.\n *\n * ## Why a row needs a name at all\n *\n * Only so a {@link ConfigFieldColumn} may be a `secret`. A secret is encrypted and kept out of the\n * row, which means something has to say which row a given ciphertext belongs to — and until this\n * existed the only answer was \"the third one\", from a console that rewrites the whole array on\n * every save and lets the operator reorder it. Position is not identity. A `$` leads it because no\n * sensible column key does, and because a row whose keys are printed somewhere reads as obviously\n * not-a-column.\n */\nexport const ROW_ID_KEY = '$id';\n\n/**\n * Where a `secret` cell's ciphertext lives, in the same flat map a `secret` FIELD's does.\n *\n * `field/row/column`, joined on the one character a field key and a column key may not contain\n * (both schemas refuse it below). That is what keeps this unambiguous against a plain secret\n * field's key, which is a bare field key and can therefore never collide with a three-part one.\n *\n * One map rather than a nested shape because everything already built for secrets — encrypting per\n * entry, reporting configured-ness as a boolean, the rule that no value ever leaves the server —\n * works on a flat `Record<string, string>` and needed no changes to carry these.\n */\nexport function rowSecretKey(fieldKey: string, rowId: string, columnKey: string): string {\n return `${fieldKey}/${rowId}/${columnKey}`;\n}\n\n/**\n * Whether a stored secret key belongs to a row rather than to a field.\n *\n * For the host, which merges stored secrets back into the form to validate it: a row's cells belong\n * inside their row and putting them at the top level would show a plugin's schema three keys it has\n * never declared.\n */\nexport function isRowSecretKey(key: string): boolean {\n return key.split('/').length === 3;\n}\n\n/**\n * The rows of a `list` field, out of the string it is stored as.\n *\n * A JSON array of objects in a string, for {@link parseMultiSelect}'s reason and encoded the same\n * way, so a `list` needs nothing of the storage path that a `string` did not already have. Tolerant\n * in the same way and for the same stakes: anything unreadable is no rows rather than a plugin that\n * will not load, and a cell that is not a string is dropped rather than stringified, since a number\n * where a URL was expected is a mistake worth seeing as an empty cell.\n *\n * A row with nothing in it is dropped, because the form leaves one behind whenever an operator adds\n * a row and thinks better of it.\n */\nexport function parseRows(raw: unknown): Record<string, string>[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return [];\n\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n\n return parsed.flatMap(entry => {\n if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];\n\n const row: Record<string, string> = {};\n for (const [key, value] of Object.entries(entry as Record<string, unknown>)) {\n if (typeof value === 'string' && value.trim().length > 0) row[key] = value.trim();\n }\n\n return Object.keys(row).length === 0 ? [] : [row];\n });\n } catch {\n return [];\n }\n}\n\n/**\n * A declarative description of one row in a plugin's settings form. The host\n * renders these; plugins never ship UI.\n */\nexport interface ConfigField {\n /** Key this value is stored under, and the key `host.config`/`host.secrets` reads it back by. */\n key: string;\n\n /** Human label shown next to the input. */\n label: string;\n\n type: ConfigFieldType;\n\n /** Whether the form refuses to save without a value. Defaults to false. */\n required?: boolean;\n\n /** Prefilled value. Never provide a default for a `secret`. */\n default?: string | number | boolean;\n\n /**\n * What a `number`'s value is measured in. Ignored on every other type.\n *\n * See {@link ConfigFieldUnit}: the stored value stays in this unit and only the control the\n * operator touches changes.\n */\n unit?: ConfigFieldUnit;\n\n /**\n * The control to draw this field with, where the ordinary one for its type reads badly.\n *\n * `slider` is for a `number` and is ignored without both `min` and `max`; `tags` is for a\n * `string`. See {@link ConfigFieldControl}. Nothing about the stored value changes either way.\n */\n control?: ConfigFieldControl;\n\n /**\n * How coarsely a `control` moves. Ignored without one, and defaults to 1.\n *\n * The unit is the field's own, so a share between 0 and 1 wants `0.05` and a word count wants\n * `10`. Worth setting on anything whose range is wider than the pixels it is drawn in, since\n * the alternative is a control that can express values nobody wants and cannot be stopped on\n * the ones they do.\n */\n step?: number;\n\n /**\n * The smallest and largest a `number` may be, inclusive. Ignored on every other type.\n *\n * A range the field is DECLARED with rather than one the reader clamps to, which is the whole\n * point: a resolver that clamps answers a legal number for an illegal one, so the station runs\n * on something the console never showed and the operator never chose. Declared here, the console\n * refuses it in front of them and the station's own settings route refuses it again for anything\n * that did not come through a console.\n *\n * A plugin's config is NOT validated against these — the host stores what it is handed and a\n * plugin's own schema is what judges it — so for a plugin these are a hint to the form. For a\n * station setting they are enforced, in `serializeSetting`.\n */\n min?: number;\n max?: number;\n\n /** Ghost text inside the input. */\n placeholder?: string;\n\n /** Longer explanation rendered under the input. */\n help?: string;\n\n /**\n * Choices, for a `select` or a `multiselect`.\n *\n * Fixed when the manifest is written, so this is for a closed set the plugin\n * decides. For anything the operator's own server decides, implement\n * `suggestConfigOptions()` instead: what it returns for this key replaces\n * these, and it can also turn a `string` into free text with suggestions.\n */\n options?: ConfigFieldOption[];\n\n /**\n * Choices only the console can enumerate. See {@link ConfigFieldOptionSource}.\n *\n * The field-level twin of {@link ConfigFieldColumn.optionsFrom}, and it resolves the same way\n * and merges at the same point: whatever `suggestConfigOptions()` says for this key wins, and\n * this is what is offered when it says nothing.\n */\n optionsFrom?: ConfigFieldOptionSource;\n\n /**\n * The columns of a `list`, in the order they are drawn. Ignored on every other type.\n *\n * A `list` with none is a field with nothing to fill in, so declare at least one.\n */\n columns?: ConfigFieldColumn[];\n\n /**\n * Key of another field in the same form. This field is only shown when\n * that field has a truthy value.\n */\n dependsOn?: string;\n\n /**\n * Key of the `number` field that is the UPPER end of the range this one opens. Declared on the\n * lower end only, and ignored on every other type.\n *\n * Two settings, still: each keeps its own key, its own row and its own validation, and\n * `serializeSetting` refuses each one by name exactly as it did before. What this changes is\n * that the console draws them as one control with two handles instead of two boxes that happen\n * to sit next to each other.\n *\n * The reason is legibility. Two boxes cannot say that one is the far end of the other, so a\n * range arrives as two settings whose labels have to carry the relationship (\"fewest\", \"most\",\n * \"the other end\") and an operator reads the pair rather than seeing it. One track with two\n * handles says it in the shape of the control.\n *\n * NOT correctness, which is worth stating because it is the plausible reason and it is wrong\n * here: every reader of a paired setting in this station takes the two ends as an UNORDERED\n * pair and sorts them, so a range stored the wrong way round has always been tolerated rather\n * than obeyed. The handles not crossing is a nicety on top, not the point.\n *\n * Worth declaring only where the declared range is narrow enough that the whole track is\n * usable. A pair bounded by a typo guard rather than by intent — one to a hundred and twenty\n * minutes, for a station that runs eight to twelve — puts both handles in the first tenth and\n * makes the exact figure somebody has in mind a pixel. Those stay two boxes.\n *\n * A rendering hint like {@link ConfigField.dependsOn}, and forgiving in the same way: if the\n * named key is not in this form, both ends fall back to their own controls rather than one of\n * them disappearing. A settings page that draws one group of a larger set is the ordinary case\n * for that.\n */\n rangeWith?: string;\n}\n\nexport const configFieldOptionSchema = z.object({\n value: z.string(),\n label: z.string(),\n});\n\nexport const configFieldTypeSchema = z.enum(['string', 'text', 'url', 'secret', 'number', 'boolean', 'select', 'multiselect', 'list', 'note']);\n\nexport const configFieldUnitSchema = z.enum(['bytes', 'fraction']);\n\nexport const configFieldControlSchema = z.enum(['slider', 'tags']);\n\n/**\n * Every member of {@link ConfigFieldOptionSource}, and it has to stay every member: this validates\n * real manifests at load, so a source missing here is a plugin the host refuses to start. Two were\n * missing for exactly that reason and nothing caught it, because no bundled plugin had asked for\n * one yet.\n */\nexport const configFieldOptionSourceSchema = z.enum([\n 'station.newsCategories',\n 'station.newsFeeds',\n 'station.podcastShows',\n 'station.narrationSeries',\n 'intl.timeZones',\n 'plugins.speech',\n 'plugins.llm',\n 'plugins.mixer',\n 'plugins.analysis',\n 'llm.models',\n]);\n\n/** The one character a key may not hold, because {@link rowSecretKey} joins on it. */\nconst noSeparator = (key: string): boolean => !key.includes('/');\nconst separatorMessage = 'a key may not contain \"/\"';\n\nexport const configFieldColumnSchema = z.object({\n key: z.string().min(1).refine(noSeparator, separatorMessage),\n label: z.string().min(1),\n type: z.enum(['string', 'url', 'select', 'secret']),\n required: z.boolean().optional(),\n placeholder: z.string().optional(),\n options: z.array(configFieldOptionSchema).optional(),\n optionsFrom: configFieldOptionSourceSchema.optional(),\n // Named here or dropped: `plugin.loader.ts` stores the zod OUTPUT of this schema as the\n // manifest, and a `z.object` strips what it does not name. A column declaring a condition this\n // schema had not been told about would lose it at load, in silence, and present as a console\n // that ignores the declaration.\n //\n // No refinement tying the values to a target. The `.ck` contract mirroring this cannot express\n // one, and a schema that refuses what the contract accepts is the drift the generated-output\n // rule exists to prevent; values without a target are documented as ignored instead.\n dependsOn: z.string().min(1).optional(),\n dependsOnValues: z.array(z.string().min(1)).optional(),\n});\n\nexport const configFieldSchema = z.object({\n key: z.string().min(1).refine(noSeparator, separatorMessage),\n label: z.string().min(1),\n type: configFieldTypeSchema,\n required: z.boolean().optional(),\n default: z.union([z.string(), z.number(), z.boolean()]).optional(),\n unit: configFieldUnitSchema.optional(),\n control: configFieldControlSchema.optional(),\n step: z.number().optional(),\n min: z.number().optional(),\n max: z.number().optional(),\n placeholder: z.string().optional(),\n help: z.string().optional(),\n options: z.array(configFieldOptionSchema).optional(),\n optionsFrom: configFieldOptionSourceSchema.optional(),\n columns: z.array(configFieldColumnSchema).optional(),\n dependsOn: z.string().optional(),\n rangeWith: z.string().optional(),\n});\n","import { ROW_ID_KEY, rowSecretKey } from './plugin.config.fields.js';\nimport type { PluginHost } from './plugin.host.js';\n\n/**\n * Reading an operator's typed-in config field.\n *\n * `host.config.get()` answers `Record<string, unknown>`, because the values came\n * out of a database column an operator edits through a text box. Every plugin\n * therefore narrows each field itself, and every plugin was narrowing it the\n * same two ways: \"a string, and blank counts as unset\", and \"a base URL with no\n * trailing slash\".\n *\n * Blank counting as unset is the important half. A cleared text box stores `''`\n * rather than removing the row, so a plugin comparing against `undefined` alone\n * sees an empty string, treats it as a real value, and sends it upstream.\n */\n\n/**\n * A config field as a trimmed string, or `undefined` when it is not set.\n *\n * Whitespace-only is unset for the same reason blank is: an operator who\n * selected a value and deleted it has said \"none\", and a space is not a model\n * name.\n */\nexport function configString(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n}\n\n/**\n * A base URL with no trailing slash, so a path can be appended with one.\n *\n * Answers `''` rather than `undefined` for a field that is not set, which is\n * deliberate and is what every caller already expected: a plugin holds its base\n * URL as a plain `string` and reports \"not configured\" by testing whether it is\n * empty, in a sentence of its own naming the thing it cannot reach (\"No\n * analyzer URL set.\"). Making this optional would push a `?? ''` to every use\n * and change nothing else.\n */\nexport function configBaseUrl(value: unknown): string {\n return (configString(value) ?? '').replace(/\\/+$/, '');\n}\n\n/**\n * The credential one ROW of a `list` field holds, or `undefined` when the operator has not set it.\n *\n * The whole of what a plugin has to know about secret cells. A `secret` column is never in the row\n * that {@link parseRows} hands back — that is what makes it a secret rather than a JSON string with\n * a password in it — so this is how the value is reached, and the key it is stored under is nobody's\n * business but this function's.\n *\n * Answers `undefined` for a row the host has never saved, which is the honest answer: a row with no\n * {@link ROW_ID_KEY} has never been stored, so there is nothing under it.\n *\n * ```ts\n * for (const row of parseRows(config.providers)) {\n * const apiKey = await readRowSecret(this.host, 'providers', row, 'apiKey');\n * }\n * ```\n */\nexport async function readRowSecret(host: PluginHost, fieldKey: string, row: Record<string, string>, columnKey: string): Promise<string | undefined> {\n const rowId = row[ROW_ID_KEY];\n if (rowId === undefined || rowId.length === 0) return undefined;\n\n return await host.secrets.get(rowSecretKey(fieldKey, rowId, columnKey));\n}\n","/**\n * Sugar over the `Response` that `host.fetch` hands back.\n *\n * Free functions rather than a `Response` subclass with methods, because\n * `host.fetch` returns the platform's own `Response` and a plugin has to be\n * able to pass it to any library that takes one. Anything that made it a\n * special response would take that away for the sake of dot notation.\n *\n * What they buy over `await response.json()` is the error. The common failure\n * is an API answering 200 with an HTML error page or a rate-limit notice, and\n * `Unexpected token < in JSON at position 0` says nothing about which call did\n * it.\n */\n\n/** How much of an unparseable body to quote back. Enough to identify it, not enough to fill a log line. */\nconst BODY_SNIPPET_LENGTH = 120;\n\nconst snippet = (body: string): string => {\n const trimmed = body.trim();\n if (trimmed.length === 0) return '<empty body>';\n return trimmed.length <= BODY_SNIPPET_LENGTH ? trimmed : `${trimmed.slice(0, BODY_SNIPPET_LENGTH)}…`;\n};\n\n/**\n * The body parsed as JSON.\n *\n * Throws when it is not JSON, with the status, the URL and the start of the\n * body in the message. That detail is the point, and it is why this reads the\n * body as text and parses that rather than calling `response.json()`: the text\n * is what names what the server actually sent.\n *\n * A body that fails to READ rather than to parse (over the host's byte cap, a\n * socket that went quiet) rejects with the host's own `PluginError` untouched.\n * That is a different failure from a body that arrived and was not JSON, and\n * relabelling it would lose the code the caller branches on.\n *\n * Note this does not check `response.ok`. A 4xx with a JSON error body is worth\n * parsing, so deciding what a bad status means is left to the caller.\n */\nexport async function jsonBody<T>(response: Response): Promise<T> {\n const text = await response.text();\n try {\n return JSON.parse(text) as T;\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error);\n throw new Error(`expected JSON from ${response.url} (HTTP ${response.status}) but got ${snippet(text)}: ${reason}`, { cause: error });\n }\n}\n\n/**\n * {@link jsonBody}, but `undefined` instead of a throw when the body will not\n * parse. For the callers that treat an unparseable body the same as a missing\n * one and have nothing useful to add to the error.\n *\n * A body that fails to read still rejects, for the reason above: the host\n * refusing an oversized body is not the same event as a server answering with\n * something that is not JSON, and swallowing the first would report a\n * misconfiguration as an empty result.\n */\nexport async function tryJsonBody<T>(response: Response): Promise<T | undefined> {\n const text = await response.text();\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined;\n }\n}\n","import { z } from 'zod';\nimport { configFieldSchema, type ConfigField } from './plugin.config.fields.js';\nimport { pluginPermissionsSchema, type PluginPermissions } from './plugin.permissions.js';\n\n/**\n * What a plugin can do, and the only thing the host ever dispatches on.\n *\n * There is no second axis. A manifest used to also carry a `kind`\n * (`music-provider`, `enrichment`, `tts`) which nothing checked: every call\n * site asked the capability list, because a plugin that declares a kind and\n * forgets the method is a `TypeError` mid-request. So the label went and this\n * is what is left.\n */\nexport const PLUGIN_CAPABILITY_CATALOG = 'catalog';\n\n/**\n * The plugin can get the station audio to play (`resolveStreamUrl`). Separate\n * from {@link PLUGIN_CAPABILITY_CATALOG} so a manifest can say it: \"will fetch\n * audio from your server\" is a thing an operator should read before installing,\n * and it used to be invisible.\n */\nexport const PLUGIN_CAPABILITY_STREAM = 'stream';\n\n/**\n * The plugin owns its own audio output and deadair only tells it what to do.\n * The opposite of deadair's `playout` module, which is why it is not called\n * that.\n */\nexport const PLUGIN_CAPABILITY_STEER = 'steer';\nexport const PLUGIN_CAPABILITY_OAUTH = 'oauth';\nexport const PLUGIN_CAPABILITY_ENRICHMENT = 'enrichment';\n\n/** The plugin can say something out loud: text in, audio out. */\nexport const PLUGIN_CAPABILITY_SPEECH = 'speech';\n\n/**\n * The plugin can produce words: a conversation in, text out.\n *\n * A transport rather than a writer. What to say is the station's business, which\n * is why this capability knows nothing about breaks, shows or running orders.\n */\nexport const PLUGIN_CAPABILITY_LLM = 'llm';\n\n/**\n * The plugin can measure a track's audio: bytes in, offsets out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} because the two answer\n * different kinds of question. Enrichment asks an upstream what it knows and\n * merges several answers; this computes one answer from the samples, and no\n * upstream sells it.\n */\nexport const PLUGIN_CAPABILITY_ANALYSIS = 'analysis';\n\n/**\n * The plugin can make one piece of audio out of several: parts in, audio out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ANALYSIS}, and NOT because the work is\n * different: both need decoded PCM, and the bundled adapter serves both off one\n * sidecar. It is separate because **a capability is the unit of SELECTION**. The\n * host picks one plugin per capability, so a joiner carried as an optional method\n * on the analyzer is the analyzer the operator chose to MEASURE with — install\n * one that measures better and cannot join, name it, and joining stops with\n * nothing to do about it but choose a worse analyzer. Two keys is what lets a\n * station measure with one engine and mix with another.\n *\n * One plugin may of course declare both, and the bundled one does.\n */\nexport const PLUGIN_CAPABILITY_MIXER = 'mixer';\n\n/**\n * The plugin can say what is popular: a chart id in, an ordered list of names\n * out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} because it is not a fact\n * about a record the station holds — it is an opinion about records in general,\n * most of which the library has never seen. And separate from\n * {@link PLUGIN_CAPABILITY_CATALOG} because a chart is not a source of audio:\n * naming a record is the whole of what it does.\n */\nexport const PLUGIN_CAPABILITY_CHARTS = 'charts';\n\n/**\n * The plugin can say what happened outside the station: a feed in, published\n * entries out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_CHARTS} even though both read somebody\n * else's document, because what comes back is not about records at all. A chart\n * entry is a name the pick path can turn into something that airs; a news item\n * is a fact, and the only thing that can be done with it is say it.\n */\nexport const PLUGIN_CAPABILITY_NEWS = 'news';\n\n/**\n * The plugin can offer text for the station to read out: a series in, its\n * instalments out, and the words of one on request.\n *\n * Separate from {@link PLUGIN_CAPABILITY_NEWS}, the other capability that hands\n * over somebody else's words, and the line between them is what the station does\n * with them rather than where they came from. A news item is a fact to MENTION,\n * so what airs is a sentence a model wrote about it; a narration piece is read\n * VERBATIM, and reading it out is the whole programme. A source whose text would\n * need summarising is a news source even if it publishes books.\n *\n * And separate from {@link PLUGIN_CAPABILITY_PODCAST}, although both end in a\n * programme at a clock band, because a podcast plugin names audio somebody else\n * made and this one hands over words the STATION speaks: in its own voice, with\n * its own lexicon and cues, measured rather than assumed.\n * `capabilities/narration.ts` says why at length.\n */\nexport const PLUGIN_CAPABILITY_NARRATION = 'narration';\n\n/**\n * The plugin can say what programmes the station subscribes to, and what each\n * has published: a show in, episodes out, each with the address of its audio.\n *\n * Separate from {@link PLUGIN_CAPABILITY_NEWS} although both read somebody\n * else's feed, because what comes back is not a fact to say but a programme to\n * AIR, and the station carries it rather than reading it out. And separate from\n * {@link PLUGIN_CAPABILITY_CATALOG} although both end in audio, because an\n * episode is not a record: every rule the station applies to a record, from\n * rotation to scrobbling, is wrong for an hour of somebody else's programme.\n * `capabilities/podcast.ts` says why at length.\n */\nexport const PLUGIN_CAPABILITY_PODCAST = 'podcast';\n\n/**\n * The plugin can say who else sounds like this: an artist in, artists out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_ENRICHMENT} for the reason\n * `capabilities/similarity.ts` gives at length: enrichment describes rows the\n * catalog holds, and the artists worth asking about here are the ones it does\n * not.\n */\nexport const PLUGIN_CAPABILITY_SIMILARITY = 'similarity';\n\n/**\n * The plugin can ask the open web a question: words in, pages out.\n *\n * Separate from {@link PLUGIN_CAPABILITY_NEWS}, which is the other capability\n * that answers about the world, because the two are asked different things. News\n * serves a MENU an operator assembled and answers \"what happened\"; this is given\n * a subject the caller chose and answers \"what does the web say about it\", with\n * nothing stable to de-duplicate against because no two calls ask the same\n * question.\n *\n * And separate from {@link PLUGIN_CAPABILITY_CATALOG} for the reason\n * {@link PLUGIN_CAPABILITY_CHARTS} is: a result is a page, and a page cannot be\n * played. Looking for something to PLAY is `searchTracks` on the catalog.\n */\nexport const PLUGIN_CAPABILITY_SEARCH = 'search';\n\n/**\n * The plugin can say what it is like outside: a place in, measurements out.\n *\n * The third capability that answers about the world, and separate from both of\n * the others because it is asked a different question. News serves a menu\n * somebody assembled and answers \"what happened\"; search takes words a caller\n * made up and answers \"what does the web say\". This is asked about one PLACE,\n * and what comes back is numbers rather than sentences — the station does\n * arithmetic on them and then decides what to say, where a headline is already\n * the words.\n *\n * A capability rather than a general-purpose tool the model calls, which was the\n * older plan: a plugin answering a question the STATION has is a capability, and\n * this station wants a reading for the moment it is playing into as well as for\n * the presenter to mention. See [tool-plugins](https://github.com/robert-dean/deadair/discussions/44).\n */\nexport const PLUGIN_CAPABILITY_WEATHER = 'weather';\n\n/**\n * The plugin can report what the station played to somebody else's service.\n *\n * The only capability that SENDS. Everything else here reads an upstream; this\n * publishes the operator's own listening to an account they hold, which is why\n * the SDK gives it a way to be declined per installation rather than assuming\n * that installing a plugin is consent to broadcast from it.\n */\nexport const PLUGIN_CAPABILITY_SCROBBLE = 'scrobble';\n\nexport const KNOWN_PLUGIN_CAPABILITIES = [\n PLUGIN_CAPABILITY_CATALOG,\n PLUGIN_CAPABILITY_STREAM,\n PLUGIN_CAPABILITY_STEER,\n PLUGIN_CAPABILITY_OAUTH,\n PLUGIN_CAPABILITY_ENRICHMENT,\n PLUGIN_CAPABILITY_SPEECH,\n PLUGIN_CAPABILITY_LLM,\n PLUGIN_CAPABILITY_ANALYSIS,\n PLUGIN_CAPABILITY_MIXER,\n PLUGIN_CAPABILITY_CHARTS,\n PLUGIN_CAPABILITY_NEWS,\n PLUGIN_CAPABILITY_NARRATION,\n PLUGIN_CAPABILITY_PODCAST,\n PLUGIN_CAPABILITY_SIMILARITY,\n PLUGIN_CAPABILITY_SEARCH,\n PLUGIN_CAPABILITY_WEATHER,\n PLUGIN_CAPABILITY_SCROBBLE,\n] as const;\n\nexport type KnownPluginCapability = (typeof KNOWN_PLUGIN_CAPABILITIES)[number];\n\n/**\n * Known capabilities get autocomplete; the type stays open so a plugin built\n * against a newer host can declare one this SDK has never heard of without\n * failing manifest validation.\n */\nexport type PluginCapability = KnownPluginCapability | (string & Record<never, never>);\n\n/**\n * A zod schema instance. Typed loosely so a plugin can hand over any schema\n * shape (object, union, refined object) without fighting the compiler.\n */\nexport type PluginConfigSchema = z.ZodType;\n\n/**\n * Duck-typed \"is this a zod schema?\". Deliberately not a bare `instanceof`:\n * a plugin may resolve its own copy of zod, and `instanceof` fails across\n * module instances.\n */\nexport function isZodSchema(value: unknown): value is PluginConfigSchema {\n if (value instanceof z.ZodType) return true;\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as { parse?: unknown; safeParse?: unknown };\n return typeof candidate.parse === 'function' && typeof candidate.safeParse === 'function';\n}\n\n/**\n * Everything the host needs to know about a plugin before it runs any of the\n * plugin's code: who it is, what it can do, what it needs permission for, and\n * what to ask the operator for.\n */\nexport interface PluginManifest {\n /** Reverse-DNS identifier, e.g. `deadair.spotify`. Globally unique, stable across versions. */\n id: string;\n\n /** Display name, e.g. `Spotify`. */\n name: string;\n\n /** Semver version of the plugin itself. */\n version: string;\n\n /** Which capability interfaces the factory result actually implements. */\n capabilities: PluginCapability[];\n\n /**\n * Semver RANGE of the plugin API this plugin works against, e.g. `^1.0.0`.\n * Compared against {@link PLUGIN_API_VERSION} at load time.\n */\n apiVersion: string;\n\n description?: string;\n\n homepage?: string;\n\n /** Data URI or absolute https URL of a small square icon. */\n icon?: string;\n\n permissions: PluginPermissions;\n\n /** Declarative settings form. */\n configFields: ConfigField[];\n\n /**\n * Server-side validation of the submitted config. The host parses the\n * operator's submission with this before storing it, so a plugin never\n * has to defend against malformed config at runtime.\n */\n configSchema: PluginConfigSchema;\n}\n\n/** Reverse-DNS: at least two lowercase dot-separated segments. */\nconst PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9][a-z0-9-]*)+$/;\n\n/**\n * Validates everything in a manifest except the contents of `configSchema`,\n * which is only checked to be a zod schema instance.\n */\nexport const pluginManifestSchema = z.object({\n id: z.string().regex(PLUGIN_ID_PATTERN, 'plugin id must be reverse-DNS, e.g. \"deadair.spotify\"'),\n name: z.string().min(1),\n version: z.string().min(1),\n capabilities: z.array(z.string().min(1)),\n apiVersion: z.string().min(1),\n description: z.string().optional(),\n homepage: z.string().optional(),\n icon: z.string().optional(),\n permissions: pluginPermissionsSchema,\n configFields: z.array(configFieldSchema),\n configSchema: z.custom<PluginConfigSchema>(isZodSchema, { message: 'configSchema must be a zod schema' }),\n});\n","import { z } from 'zod';\n\n/** The pacing knobs, shared by both kinds of entry. */\ninterface NetworkPermissionPacing {\n /**\n * Requests per second the host will let through to this entry, capped by\n * the host's own ceiling. You can ask to be slower, never faster.\n *\n * Omit it and the host's default applies. Set it and `host.fetch` paces you\n * automatically, parking each call until there is headroom rather than\n * failing it, so there is nothing left for the plugin to implement. The\n * wait is spent from the call's budget, so see `host.remainingMs()` for\n * deciding whether the work still fits.\n */\n ratePerSecond?: number;\n\n /**\n * Name of the rate-limit bucket this entry draws from. Entries sharing a\n * bucket share one limiter; the default is the hostname the entry resolves\n * to.\n *\n * For when a published limit covers a service rather than a hostname.\n * `musicbrainz.org` and `*.musicbrainz.org` are two entries against one\n * 1 req/s policy, and without a shared bucket declaring both would quietly\n * buy 2 req/s and get the station blocked.\n */\n bucket?: string;\n}\n\n/**\n * One upstream a plugin may reach, named outright.\n *\n * The bare string shorthand means exactly this with no pacing set. Reach for\n * the object form when the upstream publishes a limit of its own: MusicBrainz\n * allows roughly one request per second to anonymous clients, a tenth of what\n * the host would otherwise let through.\n */\nexport interface NetworkPermissionHost extends NetworkPermissionPacing {\n /**\n * Bare hostname (`api.spotify.com`), no scheme and no path. A leading `*.`\n * marks a wildcard subdomain match (`*.example.com`) and does NOT match the\n * bare apex.\n */\n host: string;\n}\n\n/**\n * One upstream the operator names, not the plugin: the hostname comes from one\n * of your own config fields, resolved when the plugin is initialized.\n *\n * For anything self-hosted or mirrored, where there is no hostname to write\n * down at authoring time. A MusicBrainz mirror, a Navidrome server, an internal\n * API: the plugin declares which setting holds the address and the host reads\n * the hostname out of it.\n *\n * The value is read as a URL, or as a bare hostname if it does not look like\n * one. Empty, unparseable, or wildcard-bearing values simply contribute no\n * entry, so an unconfigured plugin is refused exactly as if it had asked for\n * an undeclared host. Because it resolves at init, changing the setting\n * reinitializes the plugin and the new address takes effect with it.\n *\n * ## One setting, several addresses\n *\n * A setting holding SEVERAL addresses contributes one entry each. That is for\n * the plugin whose upstreams are a list the operator pasted rather than one\n * server they run — a reader of feeds — where there is no honest number of\n * `url` fields to offer.\n *\n * The shape is one address per line (a `text` field), or the JSON array a\n * `multiselect` stores. Where a line carries more than the address, the address\n * is its last `|`-separated field, so `world|World news|https://…/feed.xml`\n * resolves to that host: a list wants labels, and fixing where they go keeps\n * the hostnames readable out of the operator's own text instead of out of a\n * plugin's private parser.\n *\n * Every rule above is applied per address rather than to the value as a whole,\n * so one mistyped line costs its own upstream and not the rest, and a wildcard\n * still cannot arrive from data. Repeats collapse: two feeds at one publisher\n * are one entry, or the second would install a limiter that doubles the rate\n * this entry asked to be paced at.\n */\nexport interface NetworkPermissionFromConfig extends NetworkPermissionPacing {\n /** Key of the config field holding the address, e.g. `baseUrl`. */\n fromConfig: string;\n}\n\n/** A hostname at the host's default rate, or an entry that says more. */\nexport type NetworkPermission = string | NetworkPermissionHost | NetworkPermissionFromConfig;\n\n/**\n * Something a plugin needs that only the OPERATOR can say yes to.\n *\n * The rest of {@link PluginPermissions} is disclosure: a manifest states what it\n * reaches and the host holds it to that, with nobody asked anything. A grant is\n * the other kind — a capability wide enough that a person should decide, per\n * install, with the plugin's own reason in front of them.\n *\n * ## The manifest is the request, and it is the only record of one\n *\n * Nothing is stored when a plugin asks. The host reads this on every discovery\n * and stores only the ANSWER, keyed by plugin and capability, so a plugin whose\n * manifest stops asking simply stops appearing and cannot be re-enabled by a row\n * nobody can see. A plugin that has not been answered is refused: undecided and\n * denied differ on the operator's page and nowhere else.\n *\n * There is no runtime ask. A `host.requestPermission()` would have to block a\n * plugin mid-work on a person who may be asleep, and a plugin that never runs\n * would never appear to be asked about.\n *\n * ## The vocabulary is the host's\n *\n * {@link capability} is one of a fixed list the HOST publishes, because a\n * capability only means something where the host enforces it. An id nothing\n * recognises is ignored with a warning rather than becoming a row that gates\n * nothing — a permission for a door that does not exist is worse than no\n * permission, since it reads on the page as though it were protecting something.\n */\nexport interface PluginGrantRequest extends NetworkPermissionPacing {\n /** A capability id the host publishes, e.g. `network.open`. */\n capability: string;\n /**\n * Why this plugin needs it, in one sentence, addressed to the operator.\n *\n * Required, and the field this whole shape exists to carry. It is what the\n * console shows beside the Allow control, and a manifest that cannot say why\n * it wants something does not get to ask for it: the alternative is an\n * operator deciding on a capability id alone, which is a decision nobody can\n * actually make.\n */\n reason: string;\n}\n\n/**\n * What a plugin is allowed to do. Declared up front in the manifest so the\n * host (and the operator installing the plugin) can see the full blast radius\n * before any plugin code runs.\n */\nexport interface PluginPermissions {\n /**\n * Hostname allowlist for `host.fetch()`: any request to a host not listed\n * here is rejected before it leaves the process.\n *\n * This describes what a plugin says it needs, and it is enforced on every\n * call (and every redirect hop) that goes through `host.fetch`. It is not\n * yet enforced against a plugin that reaches for global `fetch` instead,\n * because the host still imports plugin code into its own realm. Read this\n * field as a disclosure an operator can weigh before installing, not as a\n * containment guarantee.\n *\n * Entries are bare hostnames, {@link NetworkPermissionHost} objects when\n * the upstream needs pacing, or {@link NetworkPermissionFromConfig} when\n * the operator is the one who names it. Order matters only for pacing: the\n * first entry a hostname matches supplies its rate and bucket.\n */\n network: NetworkPermission[];\n\n /** Whether the plugin may use `host.storage` (namespaced key/value state). */\n storage: boolean;\n\n /** Whether the plugin may use `host.oauth` (redirect URI + token vault). */\n oauth: boolean;\n\n /**\n * Whether the plugin may use `host.trackFetcher` (lend the station's\n * fetcher a login, get back a URL).\n *\n * Optional, unlike the two above, because almost no plugin wants it: a\n * provider whose audio can be fetched with a URL mints one itself. Making\n * it required would put a `false` in every manifest to disclaim something\n * only one provider has ever needed.\n */\n trackFetcher?: boolean;\n\n /**\n * Capabilities this plugin is ASKING for, each with the reason an operator\n * reads before deciding. See {@link PluginGrantRequest}.\n *\n * Optional and usually absent: almost every plugin does its whole job inside\n * what it declares above, and a manifest that asks for nothing is the normal\n * case rather than a modest one.\n *\n * Nothing here is granted by declaring it. Until the operator answers, the\n * capability behaves exactly as it does for a plugin that never asked.\n */\n grants?: PluginGrantRequest[];\n}\n\n// Finite and positive: a zero or a NaN would compute a limiter window of\n// Infinity, which is a plugin that never gets to make a request.\nconst pacingShape = {\n ratePerSecond: z.number().positive().finite().optional(),\n bucket: z.string().min(1).optional(),\n};\n\nexport const networkPermissionSchema: z.ZodType<NetworkPermission> = z.union([\n z.string().min(1),\n z.object({ host: z.string().min(1), ...pacingShape }),\n z.object({ fromConfig: z.string().min(1), ...pacingShape }),\n]);\n\nexport const grantRequestSchema: z.ZodType<PluginGrantRequest> = z.object({\n capability: z.string().min(1),\n // Non-empty for the reason the field exists: an operator deciding on a\n // capability id with no sentence beside it is being asked a question they\n // cannot answer.\n reason: z.string().min(1),\n ...pacingShape,\n});\n\nexport const pluginPermissionsSchema = z.object({\n network: z.array(networkPermissionSchema),\n storage: z.boolean(),\n oauth: z.boolean(),\n trackFetcher: z.boolean().optional(),\n grants: z.array(grantRequestSchema).optional(),\n});\n"],"mappings":";;;;;AA6OO,IAAMA,0BAA0B;EACnC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAYG,IAAMC,wBAAwB;EACjC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAaG,IAAMC,6BAA6B;;;EAGtC;;;EAGA;;;;EAIA;;;;;EAKA;;;;AC3SG,IAAMC,0BAA0B;;;ACpChC,IAAMC,4BAA4B;AAClC,IAAMC,oCAAoC;AAE1C,IAAMC,8BAA8B;EAACF;EAA2BC;;;;ACMhE,IAAME,qBAAqB;EAC9B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAmBJ,IAAMC,wBAAsD,oBAAIC,IAAI;EAAC;EAAa;EAAa;CAAc;AAQtG,SAASC,qBAAqBC,MAAqB;AACtD,SAAOH,sBAAsBI,IAAID,IAAAA;AACrC;AAFgBD;AAahB,IAAMG,oBAAsD;EACxDC,MAAM;EACNC,QAAQ;EACRC,WAAW;EACXC,WAAW;EACXC,aAAa;EACbC,cAAc;EACdC,SAAS;EACTC,aAAa;EACbC,UAAU;EACVC,UAAU;AACd;AAmBA,IAAMC,qBAAqBC,uBAAOC,IAAI,yBAAA;AAqB/B,IAAMC,cAAN,cAA0BC,MAAAA;EAzJjC,OAyJiCA;;;;EAEpB,CAACJ,kBAAAA,IAAsB;;EAGhCb,OAAwB;;EAExBkB,YAAqBhB,kBAAkBU;;EAEvCO;;;;;EAKAC;EAEA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","names":["JSON_SAFE_PAYLOAD_TYPES","BOUNDARY_METHOD_TYPES","BOUNDARY_LIVE_OBJECT_TYPES","ANALYSIS_SCHEMA_VERSION","ENRICHMENT_MATCH_KEY_ISRC","ENRICHMENT_MATCH_KEY_ARTIST_TITLE","KNOWN_ENRICHMENT_MATCH_KEYS","PLUGIN_ERROR_CODES","RESOURCE_SCOPED_CODES","Set","isResourceScopedCode","code","has","RETRYABLE_BY_CODE","auth","config","not_found","forbidden","unsupported","rate_limited","timeout","unavailable","upstream","internal","PLUGIN_ERROR_BRAND","Symbol","for","PluginError","Error","retryable","retryAfterMs","upstreamStatus","message","options","Object","setPrototypeOf","prototype","name","withCode","withRetry","withUpstreamStatus","isPluginError","error","isForeignPluginError","value","toPluginError","fallback","known","includes","String","adopted","cause","Number","isFinite","errorText","collectGeneration","handle","signal","reader","text","getReader","result","catch","undefined","aborted","chunk","Promise","race","read","ABORTED","done","cancel","releaseLock","PluginError","withCode","Symbol","resolve","addEventListener","once","SPEECH_CUES","cuesIn","text","matchAll","cuePattern","map","match","toLowerCase","withoutCues","keep","kept","Set","replace","cue","has","trim","RegExp","sort","left","right","length","join","SPEECH_DELIVERIES","isSpeechDelivery","value","includes","MAX_UPSTREAM_MESSAGE","truncateUpstreamMessage","message","length","slice","upstreamField","body","pick","undefined","value","JSON","parse","retryAfterMs","header","seconds","Number","trim","isFinite","pluginCodeForStatus","status","upstreamDetail","statusText","reason","filter","part","join","HOST_FETCH_METHODS","hostFetchMethod","method","upper","toUpperCase","find","candidate","headersToRecord","headers","record","forEach","name","toLowerCase","NAMED_ENTITIES","amp","lt","gt","quot","apos","nbsp","ndash","mdash","hellip","lsquo","rsquo","ldquo","rdquo","decodeEntities","value","replace","whole","body","startsWith","code","toLowerCase","Number","parseInt","slice","isFinite","String","fromCodePoint","plainText","raw","maxChars","stripped","trim","length","undefined","truncateWords","cut","lastSpace","lastIndexOf","trimEnd","SENTENCE_END","ABBREVIATIONS","endsSentence","at","test","Math","max","firstSentence","text","lastIndex","match","exec","stop","index","truncateSentences","lastStop","wordsIn","split","filter","Boolean","closersIn","boundary","sentencesWithin","maxWords","fits","ending","ARTICLE_MAX_CHARS","MIN_PARAGRAPH_CHARS","FURNITURE","FURNITURE_CLASSES","REFERENCE_APPARATUS","SPONSOR_OPENERS","opensWithASponsor","text","said","toLowerCase","some","opener","startsWith","next","charAt","length","test","CONTAINERS","PARAGRAPH","extractArticle","html","maxChars","cleaned","replace","body","map","pattern","exec","find","found","undefined","paragraphs","match","matchAll","withoutApparatus","plainText","includes","push","truncateSentences","join","stripped","replacement","tidied","trim","fetchArticle","host","url","init","options","response","fetch","ok","cancel","error","PluginError","upstreamDetail","status","statusText","withCode","pluginCodeForStatus","upstreamStatus","advice","retryAfterMs","headers","get","withRetry","contentType","definePlugin","manifest","factory","XMLParser","FEED_SUMMARY_MAX_CHARS","parser","XMLParser","ignoreAttributes","attributeNamePrefix","removeNSPrefix","trimValues","parseTagValue","parseAttributeValue","parseFeed","xml","document","parse","items","isRecord","rss","record","rdf","RDF","atom","feed","channel","undefined","entries","asArray","item","entry","flatMap","readItem","title","speakable","homeUrl","readLink","link","description","plainText","summary","subtitle","author","readAuthor","imageUrl","readImage","image","language","text","categories","readCategories","category","length","explicit","readExplicit","fetchFeed","host","url","init","response","fetch","ok","body","cancel","error","PluginError","upstreamDetail","status","statusText","withCode","pluginCodeForStatus","upstreamStatus","advice","retryAfterMs","headers","get","withRetry","publishedAt","readDate","pubDate","published","date","updated","issued","encoded","content","creator","id","readId","enclosure","readEnclosure","durationMs","readDuration","duration","season","readOrdinal","episode","guid","hash","value","candidates","candidate","trimmed","rel","href","raw","at","Date","Number","isNaN","getTime","toISOString","first","name","seen","Set","visit","add","found","isAtomLink","webAddress","type","toLowerCase","lengthBytes","positiveWhole","push","find","startsWith","parts","split","map","part","trim","every","test","slice","some","seconds","reduce","total","ms","Math","round","isFinite","parsed","isSafeInteger","protocol","URL","asPlainText","accumulated","charCodeAt","imul","toString","padStart","Array","isArray","String","PUNCTUATION","PARENTHETICAL","normalize","value","replace","toLowerCase","trim","baseForm","split","PLUGIN_API_VERSION","Plugin","currentHost","disposers","host","undefined","PluginError","name","withCode","register","disposer","push","registerTimer","timer","clearTimeout","onLoad","Promise","resolve","onUnload","init","dispose","splice","reverse","error","logger","warn","plugin","Error","message","String","z","parseMultiSelect","raw","trim","length","parsed","JSON","parse","Array","isArray","filter","value","map","ROW_ID_KEY","rowSecretKey","fieldKey","rowId","columnKey","isRowSecretKey","key","split","parseRows","flatMap","entry","row","Object","entries","keys","configFieldOptionSchema","z","object","string","label","configFieldTypeSchema","enum","configFieldUnitSchema","configFieldControlSchema","configFieldOptionSourceSchema","noSeparator","includes","separatorMessage","configFieldColumnSchema","min","refine","type","required","boolean","optional","placeholder","options","array","optionsFrom","dependsOn","dependsOnValues","configFieldSchema","default","union","number","unit","control","step","max","help","columns","rangeWith","configString","value","undefined","trimmed","trim","length","configBaseUrl","replace","readRowSecret","host","fieldKey","row","columnKey","rowId","ROW_ID_KEY","secrets","get","rowSecretKey","BODY_SNIPPET_LENGTH","snippet","body","trimmed","trim","length","slice","jsonBody","response","text","JSON","parse","error","reason","Error","message","String","url","status","cause","tryJsonBody","undefined","z","z","pacingShape","ratePerSecond","number","positive","finite","optional","bucket","string","min","networkPermissionSchema","union","object","host","fromConfig","grantRequestSchema","capability","reason","pluginPermissionsSchema","network","array","storage","boolean","oauth","trackFetcher","grants","PLUGIN_CAPABILITY_CATALOG","PLUGIN_CAPABILITY_STREAM","PLUGIN_CAPABILITY_STEER","PLUGIN_CAPABILITY_OAUTH","PLUGIN_CAPABILITY_ENRICHMENT","PLUGIN_CAPABILITY_SPEECH","PLUGIN_CAPABILITY_LLM","PLUGIN_CAPABILITY_ANALYSIS","PLUGIN_CAPABILITY_MIXER","PLUGIN_CAPABILITY_CHARTS","PLUGIN_CAPABILITY_NEWS","PLUGIN_CAPABILITY_NARRATION","PLUGIN_CAPABILITY_PODCAST","PLUGIN_CAPABILITY_SIMILARITY","PLUGIN_CAPABILITY_SEARCH","PLUGIN_CAPABILITY_WEATHER","PLUGIN_CAPABILITY_SCROBBLE","KNOWN_PLUGIN_CAPABILITIES","isZodSchema","value","z","ZodType","candidate","parse","safeParse","PLUGIN_ID_PATTERN","pluginManifestSchema","object","id","string","regex","name","min","version","capabilities","array","apiVersion","description","optional","homepage","icon","permissions","pluginPermissionsSchema","configFields","configFieldSchema","configSchema","custom","message"]}
|