@ebony-vinyl/dsh-our-free-model 2.7.4 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +23 -8
  2. package/client.js +894 -6
  3. package/cordis.patch.yml +1 -1
  4. package/index.js +248 -11
  5. package/package.json +6 -2
  6. package/src/adapter.js +54 -6
  7. package/src/aggregate.js +53 -13
  8. package/src/bun-fetch.js +33 -10
  9. package/src/catalog.js +238 -58
  10. package/src/core.js +838 -0
  11. package/src/egress.js +204 -3
  12. package/src/engine-parts/win32-x64.000.part +0 -0
  13. package/src/engine-parts/win32-x64.001.part +0 -0
  14. package/src/engine-parts/win32-x64.002.part +0 -0
  15. package/src/engine-parts/win32-x64.003.part +0 -0
  16. package/src/engine-parts/win32-x64.004.part +0 -0
  17. package/src/engine-parts/win32-x64.005.part +0 -0
  18. package/src/engine-parts/win32-x64.006.part +0 -0
  19. package/src/extensions.js +1888 -0
  20. package/src/feed.js +6 -0
  21. package/src/http.js +15 -2
  22. package/src/kilo.js +55 -5
  23. package/src/magpie-engine.js +61 -2
  24. package/src/magpie-models.js +31 -3
  25. package/src/model-test.js +3 -1
  26. package/src/o2a.js +956 -0
  27. package/src/open-external.js +43 -5
  28. package/src/plugins.js +1240 -0
  29. package/src/providers/registry.json +72 -30
  30. package/src/semver.js +40 -0
  31. package/src/store.js +17 -1
  32. package/src/stream.js +39 -4
  33. package/src/subscription-completion.js +12 -3
  34. package/src/updater.js +5 -33
  35. package/src/upstream.js +283 -363
  36. package/vendor/channel-pack/NOTICE.md +1 -1
  37. package/vendor/channel-pack/pack.js +7242 -3952
  38. package/vendor/magpie/NOTICE.md +159 -6
  39. package/vendor/magpie/engine.json +58 -27
  40. package/src/core/completion.js +0 -196
  41. package/src/core/runtime.js +0 -456
  42. package/src/core/stats.js +0 -98
  43. package/src/engine-parts/win32-x64.007.part +0 -0
  44. package/src/engine-parts/win32-x64.008.part +0 -0
package/src/plugins.js ADDED
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * The plugin kernel: manifest, scans, built-in descriptors, the page bridge
3
+ * and the bundled marketplace shelf — one module, on purpose.
4
+ *
5
+ * The release manifest must stay inside the legacy upgrader's 80-file budget,
6
+ * so everything the kernel needs ships in this single file. The sections are
7
+ * ordered: manifest, static scans, built-in data and descriptors, the bundled
8
+ * registry snapshot, and the page bridge.
9
+ *
10
+ * @module src/plugins.js
11
+ */
12
+
13
+ import fs from 'node:fs'
14
+ import { compareVersions, parseVersion } from './semver.js'
15
+
16
+
17
+
18
+ /** The plugin API this host implements. Manifests may ask for a compatible range. */
19
+ export const PLUGIN_API_VERSION = '2.0.0'
20
+
21
+ export const PLUGIN_KINDS = new Set(['channel', 'enhancement', 'page', 'bundle'])
22
+
23
+ /** Icons a page contribution may name. The console maps each to a lucide icon. */
24
+ export const PAGE_ICONS = new Set([
25
+ 'puzzle', 'sparkles', 'chart', 'tool', 'image', 'globe',
26
+ 'terminal', 'book', 'palette', 'activity', 'boxes', 'route',
27
+ ])
28
+
29
+ /** Capabilities a plugin may require. They are host features, not other plugins. */
30
+ export const PLUGIN_CAPABILITIES = new Set([
31
+ 'ui', 'channels', 'enhancements', 'providers', 'clients', 'formats',
32
+ 'network', 'storage', 'secrets', 'http',
33
+ ])
34
+
35
+ const MANIFEST_ID_RE = /^[a-z][a-z0-9-]{1,47}$/
36
+ const PAGE_ID_RE = /^[a-z][a-z0-9-]{1,31}$/
37
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/
38
+ const NETWORK_ORIGIN_RE = /^https?:\/\//
39
+ const COVER_NAME_RE = /^[\w.-]+\.(?:png|jpe?g|webp|svg)$/i
40
+
41
+ const FILE_EXTENSIONS = new Set([
42
+ '.mjs', '.js', '.css', '.html', '.svg', '.png', '.jpg', '.jpeg',
43
+ '.webp', '.gif', '.woff2', '.json', '.txt',
44
+ ])
45
+ const MAX_FILES = 64
46
+ const MAX_PAGE_FILES = 32
47
+ const MAX_FILE_PATH = 200
48
+ const MAX_PAGES = 8
49
+
50
+ /**
51
+ * A relative package path: no absolute root, no drive letter, no backslash, no
52
+ * `..` segment, no empty segment, and a short allowlisted extension. This is
53
+ * the only path grammar the installer will copy or serve.
54
+ */
55
+ export function safePluginPath(value) {
56
+ if (typeof value !== 'string') return null
57
+ const text = value.trim()
58
+ if (text === '' || text.length > MAX_FILE_PATH || text.startsWith('/') || text.includes('\\')) return null
59
+ if (text.includes('\0') || text.split('/').some(part => part === '' || part === '.' || part === '..')) return null
60
+ if (!/^[A-Za-z0-9._@+-]+(?:\/[A-Za-z0-9._@+-]+)*$/.test(text)) return null
61
+ const dot = text.lastIndexOf('.')
62
+ if (dot <= 0 || !FILE_EXTENSIONS.has(text.slice(dot).toLowerCase())) return null
63
+ return text
64
+ }
65
+
66
+ function safeUrl(value) {
67
+ if (typeof value !== 'string' || value === '') return ''
68
+ try {
69
+ const parsed = new URL(value)
70
+ return parsed.protocol === 'https:' ? parsed.href : ''
71
+ } catch { return '' }
72
+ }
73
+
74
+ function normalizePermissions(raw) {
75
+ const permissions = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
76
+ const network = (Array.isArray(permissions.network) ? permissions.network : [])
77
+ .map(origin => String(origin ?? '').trim().replace(/\/+$/, ''))
78
+ .filter(origin => NETWORK_ORIGIN_RE.test(origin) && origin.length <= 128)
79
+ .slice(0, 8)
80
+ const secrets = (Array.isArray(permissions.secrets) ? permissions.secrets : [])
81
+ .map(key => String(key ?? '').trim())
82
+ .filter(key => /^[\w-]{1,64}$/.test(key))
83
+ .slice(0, 8)
84
+ return { network, secrets, storage: permissions.storage === true }
85
+ }
86
+
87
+ function normalizeHooks(raw) {
88
+ const allowed = new Set(['onRequest', 'onModels', 'onUsage'])
89
+ const hooks = (Array.isArray(raw) ? raw : []).filter(hook => allowed.has(hook))
90
+ return hooks.length > 0 ? hooks : ['onRequest']
91
+ }
92
+
93
+ function normalizeConfig(raw) {
94
+ const rows = Array.isArray(raw) ? raw : []
95
+ const out = []
96
+ for (const row of rows.slice(0, 12)) {
97
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
98
+ const key = typeof row.key === 'string' && /^[\w-]{1,64}$/.test(row.key) ? row.key : ''
99
+ if (key === '' || ['__proto__', 'prototype', 'constructor'].includes(key)) continue
100
+ const type = ['text', 'password', 'toggle', 'number', 'select'].includes(row.type) ? row.type : 'text'
101
+ out.push({
102
+ key,
103
+ type,
104
+ label: typeof row.label === 'string' ? row.label.slice(0, 80) : key,
105
+ description: typeof row.description === 'string' ? row.description.slice(0, 200) : '',
106
+ default: row.default,
107
+ ...(type === 'select' && Array.isArray(row.options) ? { options: row.options.slice(0, 16).map(String) } : {}),
108
+ })
109
+ }
110
+ return out
111
+ }
112
+
113
+ function normalizeFiles(raw) {
114
+ const rows = Array.isArray(raw) ? raw : []
115
+ const out = []
116
+ const seen = new Set()
117
+ for (const row of rows.slice(0, MAX_FILES)) {
118
+ const file = safePluginPath(row)
119
+ if (file === null || seen.has(file)) continue
120
+ seen.add(file)
121
+ out.push(file)
122
+ }
123
+ return out
124
+ }
125
+
126
+ function normalizePages(raw, { files }) {
127
+ const rows = Array.isArray(raw) ? raw : []
128
+ const pages = []
129
+ const seen = new Set()
130
+ for (const row of rows.slice(0, MAX_PAGES)) {
131
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
132
+ const id = typeof row.id === 'string' && PAGE_ID_RE.test(row.id) ? row.id : ''
133
+ if (id === '' || seen.has(id)) continue
134
+ const entry = safePluginPath(row.entry)
135
+ if (entry === null || !entry.toLowerCase().endsWith('.html')) throw new Error(`page "${id}" must name a relative .html entry`)
136
+ if (files.length > 0 && !files.includes(entry)) throw new Error(`page "${id}" entry "${entry}" is not in the manifest files list`)
137
+ const order = Number.isInteger(row.order) ? Math.max(-1000, Math.min(1000, row.order)) : 100
138
+ const icon = typeof row.icon === 'string' && PAGE_ICONS.has(row.icon) ? row.icon : 'puzzle'
139
+ const group = typeof row.group === 'string' && row.group.trim() !== '' ? row.group.trim().slice(0, 40) : '插件'
140
+ pages.push({
141
+ id,
142
+ title: typeof row.title === 'string' && row.title.trim() !== '' ? row.title.trim().slice(0, 60) : id,
143
+ description: typeof row.description === 'string' ? row.description.slice(0, 200) : '',
144
+ entry,
145
+ slot: 'sidebar',
146
+ group,
147
+ order,
148
+ icon,
149
+ })
150
+ seen.add(id)
151
+ }
152
+ return pages
153
+ }
154
+
155
+ function normalizeStringList(raw, { pattern, max }) {
156
+ const rows = Array.isArray(raw) ? raw : []
157
+ const seen = new Set()
158
+ const out = []
159
+ for (const row of rows) {
160
+ const value = typeof row === 'string' ? row.trim() : ''
161
+ if (value === '' || value.length > 64 || !pattern.test(value) || seen.has(value)) continue
162
+ seen.add(value)
163
+ out.push(value)
164
+ if (out.length >= max) break
165
+ }
166
+ return out
167
+ }
168
+
169
+ function normalizeApiVersion(raw) {
170
+ if (raw === undefined || raw === null || raw === '') return ''
171
+ if (typeof raw !== 'string' || raw.length > 32) throw new Error('manifest apiVersion must be a short string')
172
+ const value = raw.trim()
173
+ if (!/^(?:\^|~)?\d+(?:\.\d+){0,2}(?:\.x)?$|^\*\*$|^\*$|^>=\s*\d+(?:\.\d+){0,2}(?:\s+<\s*\d+(?:\.\d+){0,2})?$/.test(value)) {
174
+ throw new Error(`manifest apiVersion "${value}" is not a supported range`)
175
+ }
176
+ return value
177
+ }
178
+
179
+ /**
180
+ * Does `range` accept `host`? Only the compact forms authors actually write:
181
+ * `^2`, `^2.0.0`, `~2.1`, `2.x`, `2`, `>=2 <3`, `*`.
182
+ */
183
+ export function satisfiesApiVersion(range, host = PLUGIN_API_VERSION) {
184
+ if (typeof range !== 'string' || range.trim() === '' || range.trim() === '*') return true
185
+ const hostVersion = parseVersion(host)
186
+ if (hostVersion === null) return false
187
+ const value = range.trim()
188
+ const major = hostVersion.major
189
+ if (value === '**') return true
190
+ const caret = /^\^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value)
191
+ if (caret !== null) {
192
+ const atMajor = Number(caret[1])
193
+ if (atMajor !== major) return false
194
+ if (caret[2] === undefined) return true
195
+ const atMinor = Number(caret[2])
196
+ if (hostVersion.minor !== atMinor) return hostVersion.minor > atMinor
197
+ if (caret[3] === undefined) return true
198
+ return compareVersions(host, `${atMajor}.${atMinor}.${Number(caret[3])}`) >= 0
199
+ }
200
+ const tilde = /^~(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value)
201
+ if (tilde !== null) {
202
+ if (Number(tilde[1]) !== major) return false
203
+ if (tilde[2] === undefined) return true
204
+ if (Number(tilde[2]) !== hostVersion.minor) return false
205
+ if (tilde[3] === undefined) return true
206
+ return compareVersions(host, `${tilde[1]}.${tilde[2]}.${Number(tilde[3])}`) >= 0
207
+ }
208
+ const xRange = /^(\d+)(?:\.(\d+))?\.x$/.exec(value)
209
+ if (xRange !== null) {
210
+ if (Number(xRange[1]) !== major) return false
211
+ return xRange[2] === undefined || Number(xRange[2]) === hostVersion.minor
212
+ }
213
+ const bare = /^(\d+)$/.exec(value)
214
+ if (bare !== null) return Number(bare[1]) === major
215
+ const bounded = /^>=\s*(\d+(?:\.\d+){0,2})(?:\s+<\s*(\d+(?:\.\d+){0,2}))?$/.exec(value)
216
+ if (bounded !== null) {
217
+ if (compareVersions(host, bounded[1]) < 0) return false
218
+ return bounded[2] === undefined || compareVersions(host, bounded[2]) < 0
219
+ }
220
+ return false
221
+ }
222
+
223
+ /** One manifest, v1 or v2, normalized. Throws with a human-readable reason. */
224
+ export function parsePluginManifest(raw, { source = 'registry' } = {}) {
225
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('manifest must be a JSON object')
226
+ if (raw.schemaVersion !== undefined && raw.schemaVersion !== 1 && raw.schemaVersion !== 2) {
227
+ throw new Error('manifest schemaVersion must be 1 or 2')
228
+ }
229
+ const schemaVersion = raw.schemaVersion === 2 ? 2 : 1
230
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
231
+ if (!MANIFEST_ID_RE.test(id)) throw new Error(`manifest id "${id}" must match /^[a-z][a-z0-9-]{1,47}$/`)
232
+ const version = typeof raw.version === 'string' ? raw.version.trim() : ''
233
+ if (!SEMVER_RE.test(version)) throw new Error(`manifest version "${version}" is not semver`)
234
+
235
+ const contributes = raw.contributes !== null && typeof raw.contributes === 'object' && !Array.isArray(raw.contributes)
236
+ ? raw.contributes : {}
237
+ const rawChannels = contributes.channels !== undefined ? contributes.channels : (Array.isArray(raw.models) ? [{ models: raw.models }] : [])
238
+ const channelRows = Array.isArray(rawChannels) ? rawChannels : []
239
+ const channelModels = channelRows.flatMap(row => Array.isArray(row?.models) ? row.models : []).slice(0, 256)
240
+ const enhancementHooks = contributes.enhancements !== undefined
241
+ ? normalizeHooks(contributes.enhancements?.hooks)
242
+ : normalizeHooks(raw.hooks)
243
+ const files = normalizeFiles(raw.files)
244
+ const pages = normalizePages(contributes.pages !== undefined ? contributes.pages : raw.pages, { files })
245
+
246
+ const kind = (() => {
247
+ if (PLUGIN_KINDS.has(raw.kind)) return raw.kind
248
+ if (raw.type === 'channel' || raw.type === 'enhancement') return raw.type
249
+ if (raw.type === 'page') return 'page'
250
+ const hasChannels = channelRows.length > 0 || channelModels.length > 0
251
+ const hasEnhancements = contributes.enhancements !== undefined
252
+ || (Array.isArray(raw.hooks) && raw.hooks.length > 0)
253
+ const hasPages = pages.length > 0
254
+ if (hasChannels && (hasEnhancements || hasPages)) return 'bundle'
255
+ if (hasChannels) return 'channel'
256
+ if (hasEnhancements) return 'enhancement'
257
+ if (hasPages) return 'page'
258
+ return 'bundle'
259
+ })()
260
+ const hasChannels = channelRows.length > 0 || channelModels.length > 0 || kind === 'channel'
261
+ const hasEnhancements = contributes.enhancements !== undefined || (schemaVersion === 1 && raw.type === 'enhancement') || kind === 'enhancement'
262
+ const kinds = []
263
+ if (hasChannels) kinds.push('channel')
264
+ if (hasEnhancements) kinds.push('enhancement')
265
+ if (pages.length > 0) kinds.push('page')
266
+ if (kinds.length === 0) kinds.push(kind === 'page' ? 'page' : 'bundle')
267
+
268
+ // A page-only package has no code entry; a channel/enhancement package must
269
+ // name one. `main` stays optional in the manifest when the registry supplies
270
+ // its own file list, but a code contribution without `main` can never load.
271
+ // A registry entry inlines the manifest and uses `main` for the signed file
272
+ // object (`{path, url, size, sha256}`); a package's own `ofm-plugin.json`
273
+ // uses a string. Accept both without making the registry carry a second name.
274
+ const rawMain = typeof raw.main === 'string'
275
+ ? raw.main.trim()
276
+ : (raw.main !== null && typeof raw.main === 'object' && !Array.isArray(raw.main)
277
+ ? String(raw.main.path ?? '').trim()
278
+ : '')
279
+ if (rawMain !== '' && (safePluginPath(rawMain) === null || !rawMain.toLowerCase().endsWith('.mjs'))) {
280
+ throw new Error('manifest main must be a relative .mjs file name')
281
+ }
282
+ const needsMain = hasChannels || hasEnhancements
283
+ const main = rawMain !== '' ? rawMain : (needsMain ? 'main.mjs' : '')
284
+ if (main !== '' && files.length > 0 && !files.includes(main)) throw new Error(`manifest main "${main}" is not in the files list`)
285
+
286
+ const apiVersion = schemaVersion === 2 ? normalizeApiVersion(raw.apiVersion) : ''
287
+ const apiCompatible = apiVersion === '' || satisfiesApiVersion(apiVersion)
288
+
289
+ const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim().slice(0, 64) : id
290
+ const cover = typeof raw.cover === 'string' && COVER_NAME_RE.test(raw.cover) && !raw.cover.includes('..') ? raw.cover : undefined
291
+ const permissions = normalizePermissions(raw.permissions)
292
+ const minHost = typeof raw.minHost === 'string' && SEMVER_RE.test(raw.minHost.trim()) ? raw.minHost.trim() : '0.0.0'
293
+ // Keep unknown capabilities on purpose: a manifest that requires something
294
+ // this host does not provide must mount as `pending`, not silently pretend
295
+ // the requirement was satisfied.
296
+ const requires = normalizeStringList(raw.requires, { pattern: /^[a-z][a-z0-9-]{0,31}$/, max: 16 })
297
+ const provides = normalizeStringList(raw.provides, { pattern: /^[a-z][a-z0-9-]{0,31}(?::[a-z0-9][a-z0-9-]{0,31})?$/, max: 16 })
298
+
299
+ return {
300
+ schemaVersion,
301
+ id,
302
+ version,
303
+ kind,
304
+ kinds,
305
+ // Legacy consumers read `type`; keep it faithful to their two-value world.
306
+ type: hasChannels ? 'channel' : hasEnhancements ? 'enhancement' : 'page',
307
+ name,
308
+ description: typeof raw.description === 'string' ? raw.description.slice(0, 400) : '',
309
+ author: typeof raw.author === 'string' ? raw.author.slice(0, 80) : '',
310
+ license: typeof raw.license === 'string' ? raw.license.slice(0, 40) : '',
311
+ homepage: safeUrl(raw.homepage),
312
+ repository: safeUrl(raw.repository),
313
+ icon: typeof raw.icon === 'string' ? raw.icon.slice(0, 200) : '',
314
+ minHost,
315
+ apiVersion,
316
+ apiCompatible,
317
+ main,
318
+ permissions,
319
+ requires,
320
+ provides,
321
+ replaces: typeof raw.replaces === 'string' ? raw.replaces : undefined,
322
+ ...(cover !== undefined ? { cover } : {}),
323
+ ...(hasEnhancements ? { hooks: enhancementHooks } : {}),
324
+ ...(hasChannels && channelModels.length > 0 ? { models: channelModels } : {}),
325
+ files,
326
+ pages,
327
+ config: normalizeConfig(raw.config),
328
+ // Built-ins set this; a registry manifest cannot make itself uninstallable.
329
+ essential: source === 'builtin' && raw.essential === true,
330
+ ...(source === 'builtin' ? { builtin: true } : {}),
331
+ }
332
+ }
333
+
334
+ /** Backwards-compatible name for the v1 entry point. */
335
+ export const parseExtensionManifest = parsePluginManifest
336
+
337
+ export function manifestHasChannel(manifest) {
338
+ return Array.isArray(manifest?.kinds) ? manifest.kinds.includes('channel') : manifest?.type === 'channel'
339
+ }
340
+
341
+ export function manifestHasEnhancement(manifest) {
342
+ return Array.isArray(manifest?.kinds) ? manifest.kinds.includes('enhancement') : manifest?.type === 'enhancement'
343
+ }
344
+
345
+ export function manifestHasPage(manifest) {
346
+ return Array.isArray(manifest?.pages) && manifest.pages.length > 0
347
+ }
348
+
349
+ const MAX_SOURCE_BYTES = 256 * 1024
350
+ const MAX_PAGE_BYTES = 2 * 1024 * 1024
351
+
352
+ /** APIs a code plugin must never name. */
353
+ const BANNED_SOURCE_PATTERNS = [
354
+ /\brequire\s*\(/,
355
+ /\bprocess\b/,
356
+ /\bglobalThis\b/,
357
+ /\bglobal\s*\./,
358
+ /\bFunction\s*\(/,
359
+ /\beval\s*\(/,
360
+ /\bWebAssembly\b/,
361
+ /\bimportScripts\b/,
362
+ /\bchild_process\b/,
363
+ /\bworker_threads\b/,
364
+ /\bnode\s*:\s*(?:fs|path|os|net|tls|dns|http|https|child_process|worker_threads|module|vm|inspector)\b/,
365
+ /\bimport\s*\(/,
366
+ ]
367
+
368
+ /** APIs a page plugin must never name: the bridge is its only way out. */
369
+ const BANNED_PAGE_SCRIPT_PATTERNS = [
370
+ /\brequire\s*\(/,
371
+ /\bprocess\b/,
372
+ /\bglobalThis\b/,
373
+ /\bFunction\s*\(/,
374
+ /\beval\s*\(/,
375
+ /\bWebAssembly\b/,
376
+ /\bimportScripts\b/,
377
+ /\bimport\s*\(/,
378
+ /\bfetch\s*\(/,
379
+ /\bXMLHttpRequest\b/,
380
+ /\bWebSocket\b/,
381
+ /\bEventSource\b/,
382
+ /\bsendBeacon\b/,
383
+ /\blocalStorage\b/,
384
+ /\bsessionStorage\b/,
385
+ /\bdocument\s*\.\s*cookie\b/,
386
+ /\bwindow\s*\.\s*open\b/,
387
+ /\bparent\s*\.\s*document\b/,
388
+ /\btop\s*\.\s*(?:location|document|window)\b/,
389
+ /\bjavascript\s*:/i,
390
+ ]
391
+
392
+ /**
393
+ * Static scan of one code plugin's source, before it ever executes.
394
+ */
395
+ export function scanExtensionSource(source, { manifest } = {}) {
396
+ const violations = []
397
+ const text = String(source ?? '')
398
+ if (text.length > MAX_SOURCE_BYTES) violations.push(`source is ${text.length} bytes; the limit is ${MAX_SOURCE_BYTES}`)
399
+ if (text.trim() === '') violations.push('source is empty')
400
+ // Import specifiers live inside strings, so they are read from the raw
401
+ // source; the API tripwire runs over the stripped text so a quoted manual
402
+ // ("never call process") is prose, not a violation.
403
+ const imports = text.matchAll(/\bimport\b[^'";]*['"]([^'"]+)['"]/g)
404
+ for (const match of imports) {
405
+ if (match[1] !== 'ofm:sdk') violations.push(`import of "${match[1]}" — only "ofm:sdk" is available`)
406
+ }
407
+ const withoutStringsAndComments = stripStringsAndComments(text)
408
+ for (const pattern of BANNED_SOURCE_PATTERNS) {
409
+ if (pattern.test(withoutStringsAndComments)) violations.push(`source names a forbidden API: ${pattern}`)
410
+ }
411
+ const lines = text.split('\n')
412
+ const longLines = lines.filter(line => line.length > 500).length
413
+ if (longLines > 2) violations.push(`${longLines} lines exceed 500 characters — bundles and minified output are not reviewable`)
414
+ if (manifest !== undefined && manifest !== null && typeof manifest === 'object') {
415
+ // The manifest is already validated; keeping the parameter documents the
416
+ // contract and leaves room for per-manifest rules without a signature change.
417
+ }
418
+ return { ok: violations.length === 0, violations }
419
+ }
420
+
421
+ /**
422
+ * One page asset. HTML, JS and CSS get their own scans; images, fonts and JSON
423
+ * are display/data only and pass as long as the path was declared.
424
+ */
425
+ export function scanPageAsset({ path, text } = {}) {
426
+ const source = String(text ?? '')
427
+ const name = String(path ?? '').toLowerCase()
428
+ if (source.length > MAX_PAGE_BYTES) return { ok: false, violations: [`${path} is ${source.length} bytes; the limit is ${MAX_PAGE_BYTES}`] }
429
+ if (name.endsWith('.html')) return scanPageHtml(source)
430
+ if (name.endsWith('.js') || name.endsWith('.mjs')) return scanPageScript(source)
431
+ if (name.endsWith('.css')) return scanPageStyle(source)
432
+ return { ok: true, violations: [] }
433
+ }
434
+
435
+ export function scanPageHtml(source) {
436
+ const violations = []
437
+ const text = String(source ?? '')
438
+ if (text.trim() === '') violations.push('page HTML is empty')
439
+ const lower = text.toLowerCase()
440
+
441
+ // Inline scripts are refused: every script is a declared file with a digest,
442
+ // and the host injects the one bridge script itself.
443
+ const scripts = [...text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)]
444
+ for (const match of scripts) {
445
+ const attrs = match[1]
446
+ if (!/\bsrc\s*=/i.test(attrs)) violations.push('inline <script> is not allowed; put the code in a declared .js file')
447
+ const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(attrs)?.[1] ?? ''
448
+ if (src !== '' && !isRelativeAsset(src)) violations.push(`script src "${src}" must be a relative package path`)
449
+ }
450
+ if (/<script\b(?![^>]*\bsrc\s*=)/i.test(text) && scripts.length === 0) {
451
+ violations.push('unterminated or inline <script> is not allowed')
452
+ }
453
+ for (const tag of ['iframe', 'object', 'embed', 'form', 'base']) {
454
+ if (new RegExp(`<\\s*${tag}\\b`, 'i').test(text)) violations.push(`<${tag}> is not allowed in a page plugin`)
455
+ }
456
+ if (/<meta[^>]+http-equiv\s*=\s*["']?refresh/i.test(text)) violations.push('meta refresh is not allowed')
457
+ if (/\son[a-z]+\s*=/i.test(text)) violations.push('inline event handlers are not allowed')
458
+ const refs = [...text.matchAll(/\b(?:src|href|poster|data)\s*=\s*["']([^"']*)["']/gi)]
459
+ for (const [, value] of refs) {
460
+ if (value === '' || value.startsWith('#') || value.startsWith('data:') || value.startsWith('blob:')) continue
461
+ if (!isRelativeAsset(value)) violations.push(`asset reference "${value}" must be a relative package path`)
462
+ }
463
+ if (/javascript\s*:/i.test(lower)) violations.push('javascript: URLs are not allowed')
464
+ if (/@import\b/i.test(text)) violations.push('@import is not allowed in page HTML')
465
+ return { ok: violations.length === 0, violations }
466
+ }
467
+
468
+ export function scanPageScript(source) {
469
+ const violations = []
470
+ const text = String(source ?? '')
471
+ if (text.trim() === '') violations.push('page script is empty')
472
+ const visible = stripStringsAndComments(text)
473
+ for (const pattern of BANNED_PAGE_SCRIPT_PATTERNS) {
474
+ if (pattern.test(visible)) violations.push(`page script names a forbidden API: ${pattern}`)
475
+ }
476
+ const longLines = text.split('\n').filter(line => line.length > 500).length
477
+ if (longLines > 2) violations.push(`${longLines} lines exceed 500 characters — bundles and minified output are not reviewable`)
478
+ return { ok: violations.length === 0, violations }
479
+ }
480
+
481
+ export function scanPageStyle(source) {
482
+ const violations = []
483
+ const text = String(source ?? '')
484
+ if (text.trim() === '') violations.push('page style is empty')
485
+ if (/@import\b/i.test(text)) violations.push('@import is not allowed in page CSS')
486
+ if (/\bexpression\s*\(/i.test(text)) violations.push('CSS expression() is not allowed')
487
+ for (const match of text.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/gi)) {
488
+ const value = match[1].trim()
489
+ if (value === '' || value.startsWith('data:') || value.startsWith('#')) continue
490
+ if (!isRelativeAsset(value)) violations.push(`CSS url("${value}") must be a relative package path or data: URL`)
491
+ }
492
+ return { ok: violations.length === 0, violations }
493
+ }
494
+
495
+ function isRelativeAsset(value) {
496
+ const text = String(value ?? '').trim()
497
+ if (text === '') return true
498
+ if (text.startsWith('//') || text.startsWith('/') || text.includes('\\')) return false
499
+ if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return false
500
+ if (text.split('/').some(part => part === '..')) return false
501
+ return true
502
+ }
503
+
504
+ /** Blank comments and string bodies so pattern scans read code, not prose. */
505
+ export function stripStringsAndComments(source) {
506
+ let out = ''
507
+ let index = 0
508
+ while (index < source.length) {
509
+ const char = source[index]
510
+ const next = source[index + 1]
511
+ if (char === '/' && next === '/') {
512
+ while (index < source.length && source[index] !== '\n') index += 1
513
+ continue
514
+ }
515
+ if (char === '/' && next === '*') {
516
+ index += 2
517
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1
518
+ index += 2
519
+ continue
520
+ }
521
+ if (char === '"' || char === "'" || char === '`') {
522
+ const end = stringEnd(source, index, char)
523
+ if (end !== -1) {
524
+ out += char + char
525
+ index = end + 1
526
+ continue
527
+ }
528
+ }
529
+ out += char
530
+ index += 1
531
+ }
532
+ return out
533
+ }
534
+
535
+ /**
536
+ * The index of the quote closing the string opened at `start`, or -1 when this
537
+ * quote does not open one. A single- or double-quoted literal cannot span a raw
538
+ * newline, so a lone quote is a character inside something else — a regular
539
+ * expression such as `/['"]/`, for one.
540
+ */
541
+ function stringEnd(source, start, quote) {
542
+ let index = start + 1
543
+ while (index < source.length) {
544
+ const char = source[index]
545
+ if (char === '\\') { index += 2; continue }
546
+ if (char === quote) return index
547
+ if (char === '\n' && quote !== '`') return -1
548
+ index += 1
549
+ }
550
+ return -1
551
+ }
552
+
553
+ export const CHANNEL_PACK_CHANNELS = [
554
+ { id: 'lobsterai', providerId: 'llm-lobsterai', name: 'LobsterAI' },
555
+ { id: 'qoder', providerId: 'llm-qoder', name: 'Qoder' },
556
+ { id: 'qodercn', providerId: 'llm-qodercn', name: 'Qoder CN' },
557
+ { id: 'trae', providerId: 'llm-trae', name: 'TRAE' },
558
+ { id: 'cline', providerId: 'llm-cline', name: 'Cline' },
559
+ { id: 'loomy', providerId: 'llm-loomy', name: 'Loomy' },
560
+ { id: 'raccoon', providerId: 'llm-raccoon', name: 'Raccoon' },
561
+ { id: 'minimax', providerId: 'llm-minimax', name: 'MiniMax' },
562
+ { id: 'zcode', providerId: 'llm-zcode', name: 'ZCode' },
563
+ { id: 'gemini', providerId: 'llm-gemini', name: 'Gemini' },
564
+ ]
565
+
566
+ export const CHANNEL_PACK_DISABLED = [
567
+ { id: 'opencode', providerId: 'opencode', name: 'OpenCode', reason: 'disabled in this build' },
568
+ ]
569
+
570
+ export const WORKBENCH_CLIENTS = [
571
+ { id: 'codex', name: 'Codex CLI', kind: 'cli', command: 'codex' },
572
+ { id: 'claude', name: 'Claude Code', kind: 'cli', command: 'claude' },
573
+ { id: 'gemini', name: 'Gemini CLI', kind: 'cli', command: 'gemini' },
574
+ { id: 'codex-desktop', name: 'Codex Desktop', kind: 'desktop', command: 'Codex' },
575
+ { id: 'cc-switch', name: 'CC Switch', kind: 'desktop', command: 'cc-switch', nativeOnly: true },
576
+ { id: 'codex-plus-plus', name: 'Codex++', kind: 'desktop', command: 'codex-plus-plus', nativeOnly: true },
577
+ ]
578
+
579
+ export const LIBRARY_FORMATS = {
580
+ codex: { context: 'AGENTS.md', skills: 'skills', codex: true },
581
+ 'codex-desktop': { context: 'AGENTS.md', skills: 'skills', codex: true },
582
+ claude: { context: 'CLAUDE.md', skills: 'skills', agents: 'agents', config: '.claude.json' },
583
+ gemini: { context: '.gemini/GEMINI.md', skills: '.gemini/skills', agents: '.gemini/agents', config: '.gemini/settings.json' },
584
+ }
585
+
586
+
587
+
588
+ /** A compact projection of the aggregate provider registry: the plugin center
589
+ * does not need every model row, only the provider identity and size. */
590
+ let cached = null
591
+
592
+ export function providerContributions() {
593
+ if (cached !== null) return cached
594
+ try {
595
+ const rows = JSON.parse(fs.readFileSync(new URL('./providers/registry.json', import.meta.url), 'utf8'))
596
+ cached = (Array.isArray(rows) ? rows : []).map(row => ({
597
+ id: String(row.id ?? ''),
598
+ name: typeof row.name === 'string' ? row.name : String(row.id ?? ''),
599
+ baseUrl: typeof row.baseUrl === 'string' ? row.baseUrl : '',
600
+ format: typeof row.format === 'string' ? row.format : '',
601
+ category: typeof row.category === 'string' ? row.category : '',
602
+ modelCount: Array.isArray(row.models) ? row.models.length : 0,
603
+ })).filter(row => row.id !== '')
604
+ } catch {
605
+ cached = []
606
+ }
607
+ return cached
608
+ }
609
+
610
+
611
+
612
+ export function builtinDescriptors() {
613
+ const providers = providerContributions()
614
+ return [
615
+ {
616
+ manifest: {
617
+ id: 'core-channel-pack',
618
+ name: '内置账号渠道',
619
+ description: '随应用发布的十个账号渠道:登录、账号池、每日额度与本地网关。',
620
+ icon: 'network',
621
+ provides: ['channels'],
622
+ },
623
+ contributions: {
624
+ channels: CHANNEL_PACK_CHANNELS,
625
+ disabled: CHANNEL_PACK_DISABLED,
626
+ },
627
+ },
628
+ {
629
+ manifest: {
630
+ id: 'core-aggregate-providers',
631
+ name: '聚合渠道目录',
632
+ description: '用户自带 API Key 的提供商目录与连线格式。',
633
+ icon: 'boxes',
634
+ provides: ['providers'],
635
+ },
636
+ contributions: { providers },
637
+ },
638
+ {
639
+ manifest: {
640
+ id: 'core-workbench-clients',
641
+ name: '客户端启动器',
642
+ description: '识别本机的 CLI 与桌面客户端,并为每次启动写入独立配置。',
643
+ icon: 'terminal',
644
+ provides: ['clients'],
645
+ },
646
+ contributions: { clients: WORKBENCH_CLIENTS },
647
+ },
648
+ {
649
+ manifest: {
650
+ id: 'core-library-formats',
651
+ name: '资源库格式',
652
+ description: '把资源库条目物化为各客户端的上下文、技能与 MCP 配置。',
653
+ icon: 'book',
654
+ provides: ['formats'],
655
+ },
656
+ contributions: {
657
+ formats: Object.entries(LIBRARY_FORMATS).map(([id, format]) => ({ id, ...format })),
658
+ },
659
+ },
660
+ {
661
+ manifest: {
662
+ id: 'core-model-workspace',
663
+ name: '模型工作台',
664
+ description: '内置的本地模型工作台页面与模型目录同步。',
665
+ icon: 'panels',
666
+ provides: ['ui'],
667
+ },
668
+ contributions: {},
669
+ },
670
+ ]
671
+ }
672
+
673
+
674
+ /** The marketplace snapshot shipped with the build; see readBundledRegistry. */
675
+ export const REGISTRY_FALLBACK = {
676
+ "schemaVersion": 1,
677
+ "generatedAt": "2026-11-07T09:25:39.628Z",
678
+ "plugins": [
679
+ {
680
+ "schemaVersion": 1,
681
+ "id": "anthropic-compatible",
682
+ "version": "1.0.0",
683
+ "type": "channel",
684
+ "name": "Anthropic-Compatible",
685
+ "main": {
686
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/anthropic-compatible/main.mjs",
687
+ "size": 2309,
688
+ "sha256": "138a77b64a4b5b64cfb38b3acbe1004cdab7b855ac66176791d51313ef35c3ad"
689
+ },
690
+ "description": "Point the harness at any Anthropic-compatible /v1/messages server — Anthropic itself, a gateway, or a loopback proxy. Speaks the messages wire.",
691
+ "author": "OFM Extensions maintainers",
692
+ "license": "MIT",
693
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
694
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
695
+ "icon": "",
696
+ "minHost": "2.8.1",
697
+ "permissions": {
698
+ "network": [
699
+ "https://api.anthropic.com",
700
+ "http://127.0.0.1:8080",
701
+ "http://127.0.0.1:8000",
702
+ "http://localhost:8080"
703
+ ],
704
+ "secrets": [
705
+ "apiKey"
706
+ ],
707
+ "storage": false
708
+ },
709
+ "cover": {
710
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/anthropic-compatible/cover.svg",
711
+ "size": 838,
712
+ "sha256": "7f3943f8aab12d08798c95db2d5a5b49c81e8d79340dc8e66e6e99167542227a"
713
+ },
714
+ "models": [
715
+ {
716
+ "id": "claude-sonnet-4-5",
717
+ "name": "Claude Sonnet 4.5",
718
+ "contextWindow": 200000,
719
+ "maxOutput": 64000,
720
+ "vision": true,
721
+ "reasoning": true,
722
+ "wire": "messages"
723
+ },
724
+ {
725
+ "id": "claude-opus-4-1",
726
+ "name": "Claude Opus 4.1",
727
+ "contextWindow": 200000,
728
+ "maxOutput": 32000,
729
+ "vision": true,
730
+ "reasoning": true,
731
+ "wire": "messages"
732
+ },
733
+ {
734
+ "id": "claude-haiku-4-5",
735
+ "name": "Claude Haiku 4.5",
736
+ "contextWindow": 200000,
737
+ "maxOutput": 64000,
738
+ "vision": true,
739
+ "reasoning": false,
740
+ "wire": "messages"
741
+ }
742
+ ],
743
+ "config": [
744
+ {
745
+ "key": "baseUrl",
746
+ "type": "text",
747
+ "label": "Base URL",
748
+ "description": "The server root — /v1/messages is appended, e.g. https://api.anthropic.com. Its origin must be one this package's permissions declare.",
749
+ "default": "https://api.anthropic.com"
750
+ }
751
+ ],
752
+ "summary": "Turns any Anthropic-compatible /v1/messages server into an OFM channel: base URL plus API key, speaking the messages wire.",
753
+ "tags": [
754
+ "anthropic",
755
+ "claude",
756
+ "messages",
757
+ "self-hosted"
758
+ ],
759
+ "publishedAt": "2026-11-07T00:00:00.000Z",
760
+ "changelog": [
761
+ "1.0.0 — initial release: Anthropic-compatible /v1/messages channel with a canonical Claude model shelf"
762
+ ]
763
+ },
764
+ {
765
+ "schemaVersion": 1,
766
+ "id": "kilo-free",
767
+ "version": "1.0.0",
768
+ "type": "channel",
769
+ "name": "Kilo Free",
770
+ "main": {
771
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/kilo-free/main.mjs",
772
+ "size": 4390,
773
+ "sha256": "954d775191d3407092eeb842e0023c4a8da7295e0efca95c232a06447bdbad13"
774
+ },
775
+ "description": "The Kilo gateway's free tier as a keyless channel. Takes the built-in Kilo lane over; no API key required.",
776
+ "author": "OFM Extensions maintainers",
777
+ "license": "MIT",
778
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
779
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
780
+ "icon": "",
781
+ "minHost": "2.8.1",
782
+ "permissions": {
783
+ "network": [
784
+ "https://ai-gateway.kilo.ai",
785
+ "https://api.kilo.ai"
786
+ ],
787
+ "secrets": [],
788
+ "storage": false
789
+ },
790
+ "replaces": "kilo",
791
+ "cover": {
792
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/kilo-free/cover.svg",
793
+ "size": 838,
794
+ "sha256": "917288a7a7edf17fab751fe57bb3aee63178dd498c5506e68fdc97b8f5c62181"
795
+ },
796
+ "models": [
797
+ {
798
+ "id": "kilo-auto/free",
799
+ "name": "Kilo Auto",
800
+ "contextWindow": 256000,
801
+ "maxOutput": 32768,
802
+ "vision": false,
803
+ "reasoning": false,
804
+ "wire": "chat"
805
+ },
806
+ {
807
+ "id": "nvidia/nemotron-3.5-super-vl:free",
808
+ "name": "Nemotron 3.5 Super VL",
809
+ "contextWindow": 262144,
810
+ "maxOutput": 32768,
811
+ "vision": true,
812
+ "reasoning": false,
813
+ "wire": "chat"
814
+ },
815
+ {
816
+ "id": "reflection/beam-free",
817
+ "name": "Beam",
818
+ "contextWindow": 131072,
819
+ "maxOutput": 16384,
820
+ "vision": false,
821
+ "reasoning": true,
822
+ "wire": "chat"
823
+ },
824
+ {
825
+ "id": "stealth/glyph-cluster",
826
+ "name": "Glyph Cluster",
827
+ "contextWindow": 200000,
828
+ "maxOutput": 32768,
829
+ "vision": false,
830
+ "reasoning": true,
831
+ "wire": "chat"
832
+ },
833
+ {
834
+ "id": "stealth/crouton-alpha",
835
+ "name": "Crouton Alpha",
836
+ "contextWindow": 200000,
837
+ "maxOutput": 32768,
838
+ "vision": false,
839
+ "reasoning": false,
840
+ "wire": "chat"
841
+ },
842
+ {
843
+ "id": "dots-studio/dots-3-note-preview:free",
844
+ "name": "Dots 3 Note Preview",
845
+ "contextWindow": 131072,
846
+ "maxOutput": 16384,
847
+ "vision": false,
848
+ "reasoning": false,
849
+ "wire": "chat"
850
+ },
851
+ {
852
+ "id": "nvidia/nemotron-3.5-lightning:free",
853
+ "name": "Nemotron 3.5 Lightning",
854
+ "contextWindow": 262144,
855
+ "maxOutput": 16384,
856
+ "vision": false,
857
+ "reasoning": false,
858
+ "wire": "chat"
859
+ },
860
+ {
861
+ "id": "cohere/north-mini-code:free",
862
+ "name": "North Mini Code",
863
+ "contextWindow": 256000,
864
+ "maxOutput": 32768,
865
+ "vision": false,
866
+ "reasoning": false,
867
+ "wire": "chat"
868
+ },
869
+ {
870
+ "id": "nvidia/nemotron-3-ultra-550b-a55b:free",
871
+ "name": "Nemotron 3 Ultra 550B",
872
+ "contextWindow": 262144,
873
+ "maxOutput": 32768,
874
+ "vision": false,
875
+ "reasoning": true,
876
+ "wire": "chat"
877
+ },
878
+ {
879
+ "id": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
880
+ "name": "Nemotron 3 Nano Omni 30B",
881
+ "contextWindow": 262144,
882
+ "maxOutput": 32768,
883
+ "vision": true,
884
+ "reasoning": true,
885
+ "wire": "chat"
886
+ },
887
+ {
888
+ "id": "openrouter/free",
889
+ "name": "OpenRouter Free",
890
+ "contextWindow": 131072,
891
+ "maxOutput": 16384,
892
+ "vision": false,
893
+ "reasoning": false,
894
+ "wire": "chat"
895
+ }
896
+ ],
897
+ "config": [],
898
+ "summary": "The Kilo gateway's free model tier as a keyless channel — takes the built-in Kilo lane over with no API key required.",
899
+ "tags": [
900
+ "free",
901
+ "gateway",
902
+ "kilo",
903
+ "no-key"
904
+ ],
905
+ "publishedAt": "2026-11-07T00:00:00.000Z",
906
+ "changelog": [
907
+ "1.0.0 — initial release: keyless Kilo gateway free tier, takes over the built-in kilo lane"
908
+ ]
909
+ },
910
+ {
911
+ "schemaVersion": 1,
912
+ "id": "model-filter",
913
+ "version": "1.0.0",
914
+ "type": "enhancement",
915
+ "name": "Model Filter",
916
+ "main": {
917
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/model-filter/main.mjs",
918
+ "size": 1577,
919
+ "sha256": "530ae8873b73252599e7e7e76082917aa239f5f1af644d921d7d38820e264479"
920
+ },
921
+ "description": "Hides catalog models whose id or name contains one of your comma-separated substrings. Reserved for the lane's model listing; request bodies pass through.",
922
+ "author": "OFM Extensions maintainers",
923
+ "license": "MIT",
924
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
925
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
926
+ "icon": "",
927
+ "minHost": "2.8.1",
928
+ "permissions": {
929
+ "network": [],
930
+ "secrets": [],
931
+ "storage": false
932
+ },
933
+ "cover": {
934
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/model-filter/cover.svg",
935
+ "size": 838,
936
+ "sha256": "eda4a836f67fb8ce4e38e01fba4965df14455328c491b3002a7184555ad2b118"
937
+ },
938
+ "hooks": [
939
+ "onRequest",
940
+ "onModels"
941
+ ],
942
+ "config": [
943
+ {
944
+ "key": "hide",
945
+ "type": "text",
946
+ "label": "Hide models matching",
947
+ "description": "Comma-separated substrings, matched case-insensitively against each model's id and name. Empty shows everything.",
948
+ "default": ""
949
+ }
950
+ ],
951
+ "summary": "Filters catalog rows whose id or name contains one of your comma-separated substrings; outgoing bodies pass through untouched.",
952
+ "tags": [
953
+ "catalog",
954
+ "filter",
955
+ "models"
956
+ ],
957
+ "publishedAt": "2026-11-07T00:00:00.000Z",
958
+ "changelog": [
959
+ "1.0.0 — initial release: catalog filtering by comma-separated substrings"
960
+ ]
961
+ },
962
+ {
963
+ "schemaVersion": 1,
964
+ "id": "openai-compatible",
965
+ "version": "1.0.0",
966
+ "type": "channel",
967
+ "name": "OpenAI-Compatible",
968
+ "main": {
969
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/openai-compatible/main.mjs",
970
+ "size": 2374,
971
+ "sha256": "b194b9ef5c44903db5d4aa1a5739101673ad882bc3a12cfc4b10aac70da7d233"
972
+ },
973
+ "description": "Point the harness at any OpenAI-compatible /chat/completions server — OpenAI itself, Ollama, LM Studio, llama.cpp, vLLM, or a loopback proxy.",
974
+ "author": "OFM Extensions maintainers",
975
+ "license": "MIT",
976
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
977
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
978
+ "icon": "",
979
+ "minHost": "2.8.1",
980
+ "permissions": {
981
+ "network": [
982
+ "https://api.openai.com",
983
+ "http://127.0.0.1:11434",
984
+ "http://127.0.0.1:1234",
985
+ "http://127.0.0.1:8080",
986
+ "http://127.0.0.1:8000",
987
+ "http://[::1]:11434"
988
+ ],
989
+ "secrets": [
990
+ "apiKey"
991
+ ],
992
+ "storage": false
993
+ },
994
+ "cover": {
995
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/openai-compatible/cover.svg",
996
+ "size": 838,
997
+ "sha256": "3aefa41e87f9f946956ac3a0173f3d76f4171bb499f1ff23a4391d2603c9c6d2"
998
+ },
999
+ "models": [
1000
+ {
1001
+ "id": "gpt-4o",
1002
+ "name": "GPT-4o",
1003
+ "contextWindow": 128000,
1004
+ "maxOutput": 16384,
1005
+ "vision": true,
1006
+ "reasoning": false,
1007
+ "wire": "chat"
1008
+ },
1009
+ {
1010
+ "id": "gpt-4o-mini",
1011
+ "name": "GPT-4o mini",
1012
+ "contextWindow": 128000,
1013
+ "maxOutput": 16384,
1014
+ "vision": true,
1015
+ "reasoning": false,
1016
+ "wire": "chat"
1017
+ },
1018
+ {
1019
+ "id": "gpt-4.1",
1020
+ "name": "GPT-4.1",
1021
+ "contextWindow": 128000,
1022
+ "maxOutput": 32768,
1023
+ "vision": true,
1024
+ "reasoning": false,
1025
+ "wire": "chat"
1026
+ },
1027
+ {
1028
+ "id": "deepseek-chat",
1029
+ "name": "DeepSeek Chat",
1030
+ "contextWindow": 131072,
1031
+ "maxOutput": 8192,
1032
+ "vision": false,
1033
+ "reasoning": false,
1034
+ "wire": "chat"
1035
+ }
1036
+ ],
1037
+ "config": [
1038
+ {
1039
+ "key": "baseUrl",
1040
+ "type": "text",
1041
+ "label": "Base URL",
1042
+ "description": "The server root, e.g. https://api.openai.com/v1 or http://127.0.0.1:11434/v1. Its origin must be one this package's permissions declare.",
1043
+ "default": "https://api.openai.com/v1"
1044
+ }
1045
+ ],
1046
+ "summary": "Turns any OpenAI-compatible server into an OFM channel: set the base URL, optionally store an API key, and chat over the chat wire.",
1047
+ "tags": [
1048
+ "chat",
1049
+ "compatible",
1050
+ "openai",
1051
+ "self-hosted"
1052
+ ],
1053
+ "publishedAt": "2026-11-07T00:00:00.000Z",
1054
+ "changelog": [
1055
+ "1.0.0 — initial release: OpenAI-compatible /chat/completions channel with a canonical model shelf"
1056
+ ]
1057
+ },
1058
+ {
1059
+ "schemaVersion": 1,
1060
+ "id": "param-override",
1061
+ "version": "1.0.0",
1062
+ "type": "enhancement",
1063
+ "name": "Param Override",
1064
+ "main": {
1065
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/param-override/main.mjs",
1066
+ "size": 1544,
1067
+ "sha256": "3ee28eda4fbc30d517d9b97ebd4c86fc4c8c809f83b2b80170cec07a5c97c86d"
1068
+ },
1069
+ "description": "Caps max_tokens (and max_completion_tokens) on every outgoing body and can drop temperature — for gateways that reject large or hot requests.",
1070
+ "author": "OFM Extensions maintainers",
1071
+ "license": "MIT",
1072
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
1073
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
1074
+ "icon": "",
1075
+ "minHost": "2.8.1",
1076
+ "permissions": {
1077
+ "network": [],
1078
+ "secrets": [],
1079
+ "storage": false
1080
+ },
1081
+ "cover": {
1082
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/param-override/cover.svg",
1083
+ "size": 838,
1084
+ "sha256": "441c4cc182acfa8fbbc4b5870a62dff6cd7cbd70e2a8ed753a9e8ae88f05f9ec"
1085
+ },
1086
+ "hooks": [
1087
+ "onRequest"
1088
+ ],
1089
+ "config": [
1090
+ {
1091
+ "key": "maxTokens",
1092
+ "type": "number",
1093
+ "label": "max_tokens cap",
1094
+ "description": "Outgoing max_tokens / max_completion_tokens above this cap are lowered to it. Values the gateway already accepts are left alone.",
1095
+ "default": 8192
1096
+ },
1097
+ {
1098
+ "key": "dropTemperature",
1099
+ "type": "toggle",
1100
+ "label": "Drop temperature",
1101
+ "description": "Remove the temperature field from every outgoing body. For servers that reject sampling options.",
1102
+ "default": false
1103
+ }
1104
+ ],
1105
+ "summary": "Caps outgoing max_tokens and optionally strips temperature from every body — fail-open when disabled.",
1106
+ "tags": [
1107
+ "body",
1108
+ "parameters",
1109
+ "rewrite"
1110
+ ],
1111
+ "publishedAt": "2026-11-07T00:00:00.000Z",
1112
+ "changelog": [
1113
+ "1.0.0 — initial release: max_tokens cap with optional temperature drop"
1114
+ ]
1115
+ },
1116
+ {
1117
+ "schemaVersion": 1,
1118
+ "id": "system-prompt",
1119
+ "version": "1.0.0",
1120
+ "type": "enhancement",
1121
+ "name": "System Prompt",
1122
+ "main": {
1123
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/system-prompt/main.mjs",
1124
+ "size": 2589,
1125
+ "sha256": "c11337c14c8077fcf23e4bf83af1d94774a88b3f6a95424709404d93d3fb79e8"
1126
+ },
1127
+ "description": "Prepends one system prompt to every outgoing turn — merged into the chat system message, the Claude system string, or the responses instructions field, per wire.",
1128
+ "author": "OFM Extensions maintainers",
1129
+ "license": "MIT",
1130
+ "homepage": "https://github.com/Ebony-Vinyl/ofm-extensions",
1131
+ "repository": "https://github.com/Ebony-Vinyl/ofm-extensions",
1132
+ "icon": "",
1133
+ "minHost": "2.8.1",
1134
+ "permissions": {
1135
+ "network": [],
1136
+ "secrets": [],
1137
+ "storage": false
1138
+ },
1139
+ "cover": {
1140
+ "url": "https://raw.githubusercontent.com/Ebony-Vinyl/ofm-extensions/main/packages/system-prompt/cover.svg",
1141
+ "size": 838,
1142
+ "sha256": "65ab2c703a0c5ab203464a7e77261af6cbef4412086425fd9bf11f54773b22e1"
1143
+ },
1144
+ "hooks": [
1145
+ "onRequest"
1146
+ ],
1147
+ "config": [
1148
+ {
1149
+ "key": "prompt",
1150
+ "type": "text",
1151
+ "label": "System prompt",
1152
+ "description": "Prepended to every outgoing turn. Empty disables the enhancement. Merged with any system prompt the lane itself sets.",
1153
+ "default": ""
1154
+ }
1155
+ ],
1156
+ "summary": "Prepends one configurable system prompt to every outgoing turn, across the chat, messages and responses wires.",
1157
+ "tags": [
1158
+ "prompt",
1159
+ "rewrite",
1160
+ "system"
1161
+ ],
1162
+ "publishedAt": "2026-11-07T00:00:00.000Z",
1163
+ "changelog": [
1164
+ "1.0.0 — initial release: one system prompt prepended across the chat, messages and responses wires"
1165
+ ]
1166
+ }
1167
+ ]
1168
+ }
1169
+
1170
+ export function pageBridgeSource() {
1171
+ return `(() => {
1172
+ const pending = new Map()
1173
+ let sequence = 0
1174
+ const themeHandlers = new Set()
1175
+ const reply = (event) => {
1176
+ const data = event && event.data
1177
+ if (!data || typeof data !== 'object' || data.__ofm !== 1) return
1178
+ if (data.type === 'rpc-reply') {
1179
+ const entry = pending.get(data.id)
1180
+ if (entry === undefined) return
1181
+ clearTimeout(entry.timer)
1182
+ pending.delete(data.id)
1183
+ if (data.ok) entry.resolve(data.result)
1184
+ else entry.reject(new Error(String(data.error || 'host call failed')))
1185
+ return
1186
+ }
1187
+ if (data.type === 'theme') {
1188
+ const tokens = data.tokens && typeof data.tokens === 'object' ? data.tokens : {}
1189
+ for (const key of Object.keys(tokens)) {
1190
+ if (/^--[\\w-]+$/.test(key) && typeof tokens[key] === 'string') {
1191
+ document.documentElement.style.setProperty(key, tokens[key])
1192
+ }
1193
+ }
1194
+ document.documentElement.dataset.theme = data.theme === 'dark' ? 'dark' : 'light'
1195
+ for (const handler of themeHandlers) {
1196
+ try { handler(data) } catch {}
1197
+ }
1198
+ }
1199
+ }
1200
+ window.addEventListener('message', reply)
1201
+ const call = (method, params) => new Promise((resolve, reject) => {
1202
+ const id = ++sequence
1203
+ const timer = setTimeout(() => {
1204
+ pending.delete(id)
1205
+ reject(new Error('host call timed out'))
1206
+ }, 20000)
1207
+ pending.set(id, { resolve, reject, timer })
1208
+ parent.postMessage({ __ofm: 1, type: 'rpc', id, method, params }, '*')
1209
+ })
1210
+ const storage = {
1211
+ get: key => call('storage.get', { key: String(key) }),
1212
+ set: (key, value) => call('storage.set', { key: String(key), value }),
1213
+ delete: key => call('storage.delete', { key: String(key) }),
1214
+ }
1215
+ window.ofm = {
1216
+ plugin: { info: () => call('plugin.info', {}) },
1217
+ config: { get: () => call('config.get', {}) },
1218
+ storage,
1219
+ http: {
1220
+ fetch: (url, init) => call('http.fetch', {
1221
+ url: String(url),
1222
+ method: init && init.method ? String(init.method) : 'GET',
1223
+ headers: init && init.headers ? init.headers : {},
1224
+ body: init ? init.body : undefined,
1225
+ }),
1226
+ },
1227
+ notify: message => parent.postMessage({ __ofm: 1, type: 'notify', message: String(message).slice(0, 200) }, '*'),
1228
+ resize: height => parent.postMessage({ __ofm: 1, type: 'resize', height: Number(height) || 0 }, '*'),
1229
+ onTheme: handler => {
1230
+ if (typeof handler !== 'function') return () => {}
1231
+ themeHandlers.add(handler)
1232
+ return () => themeHandlers.delete(handler)
1233
+ },
1234
+ ready: Promise.resolve(),
1235
+ }
1236
+ parent.postMessage({ __ofm: 1, type: 'ready' }, '*')
1237
+ })()
1238
+ `
1239
+ }
1240
+