@maci0/dsh-chatjimmy 0.11.4

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/lib/client.js ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * dsh-chatjimmy: browser half.
3
+ *
4
+ * One surface: the ChatJimmy card on the Plugins page, keyed on the
5
+ * `chatjimmy` settings namespace the host half declares as its row id. Every
6
+ * field of the row that the host marks `volatile()` is editable here, and the
7
+ * adapter resolves the row per read, so a save lands on the next request
8
+ * without remounting the provider route.
9
+ *
10
+ * The form stages edits locally and writes them in one `scope.mutate` call:
11
+ * a half-typed base URL must not reach the settings document field by field.
12
+ * Chrome is a stylesheet, not inline style objects: the module system claims
13
+ * every `<style>` tag a factory appends while it materializes and removes it
14
+ * when the package unloads. Classes are `cj-`-prefixed because that sheet
15
+ * lands in the page's own document.
16
+ *
17
+ * This file is plain JavaScript on purpose. The client module system serves a
18
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
19
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
20
+ * plugin can author it directly instead of reproducing the repository's tsdown
21
+ * client preset. `react` is provided by the module system; nothing else is
22
+ * required here.
23
+ */
24
+
25
+ window.__ModuleLoader__.load({
26
+ id: '@maci0/dsh-chatjimmy',
27
+
28
+ factory: (require) => {
29
+ var module = { exports: {} }
30
+ var exports = module.exports
31
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
32
+
33
+ const React = require('react')
34
+
35
+ /** Settings namespace shared with the host half; also this card's slot key. */
36
+ const NAMESPACE = 'chatjimmy'
37
+
38
+ /** Locale namespace for this plugin's copy. */
39
+ const LOCALE_NS = 'chatjimmy'
40
+
41
+ /**
42
+ * Every editable row field, in display order. `integer` fields are sent as
43
+ * numbers and must be positive integers; the host schema bounds each one,
44
+ * and the card refuses what it can see before the write.
45
+ */
46
+ const FIELDS = [
47
+ { key: 'baseUrl', kind: 'text', label: 'labelBaseUrl', hint: 'hintBaseUrl' },
48
+ { key: 'model', kind: 'text', label: 'labelModel', hint: 'hintModel' },
49
+ { key: 'topK', kind: 'integer', label: 'labelTopK', hint: 'hintTopK' },
50
+ { key: 'contextWindow', kind: 'integer', label: 'labelContextWindow', hint: 'hintContextWindow' },
51
+ { key: 'streamIdleTimeoutMs', kind: 'integer', label: 'labelStreamIdleTimeoutMs', hint: 'hintStreamIdleTimeoutMs' },
52
+ ]
53
+
54
+ /** Every class is `cj-`-prefixed: the sheet lands in the page's own document. */
55
+ const CSS = [
56
+ '.cj-page{display:flex;flex-direction:column;gap:12px}',
57
+ '.cj-field{display:flex;flex-direction:column;gap:4px}',
58
+ '.cj-label{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
59
+ '.cj-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
60
+ '.cj-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
61
+ '.cj-input:disabled{cursor:default;opacity:.5}',
62
+ '.cj-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}',
63
+ '.cj-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
64
+ '.cj-button:disabled{cursor:default;opacity:.5}',
65
+ '.cj-button-quiet{padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
66
+ '.cj-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
67
+ '.cj-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
68
+ ].join('')
69
+
70
+ // Appended while the factory materializes: the module system claims the tag
71
+ // for this package and disposes it on unload. Guarded because the node unit
72
+ // tests evaluate this file without a DOM.
73
+ if (typeof document !== 'undefined') {
74
+ const style = document.createElement('style')
75
+ style.textContent = CSS
76
+ document.head.append(style)
77
+ }
78
+
79
+ /** Plugin version, shown in the card footer. Kept in lockstep with package.json. */
80
+ const VERSION = '0.11.4'
81
+
82
+ const en = {
83
+ title: 'ChatJimmy',
84
+ summary: 'ChatJimmy text provider: model {model} at {baseUrl}.',
85
+ summaryEmpty: 'ChatJimmy text provider (not configured yet).',
86
+ description: 'ChatJimmy text provider: {model}',
87
+ labelBaseUrl: 'Base URL',
88
+ labelModel: 'Model',
89
+ labelTopK: 'topK',
90
+ labelContextWindow: 'Context window (tokens)',
91
+ labelStreamIdleTimeoutMs: 'Stream idle timeout (ms)',
92
+ hintBaseUrl: 'Deployment origin, no trailing slash needed.',
93
+ hintModel: 'Model id sent as chatOptions.selectedModel.',
94
+ hintTopK: 'Sampling breadth forwarded to the service. The site itself sends 8.',
95
+ hintContextWindow: 'Prompt + completion tokens; the service enforces 6144.',
96
+ hintStreamIdleTimeoutMs: 'A stream silent for this long ends with the TIMEOUT failure.',
97
+ overridden: 'overridden',
98
+ save: 'Save',
99
+ saving: 'Saving…',
100
+ saved: 'Saved. The next request uses these values.',
101
+ reset: 'Reset to defaults',
102
+ persists: 'Stored in your profile; the adapter reads the row on every request.',
103
+ readOnly: 'Read-only: this deployment does not persist settings.',
104
+ baseUrlInvalid: 'Base URL must be an absolute http(s) URL.',
105
+ modelInvalid: 'Model must be a non-empty id.',
106
+ numberInvalid: 'Must be a positive whole number.',
107
+ rejected: 'The Host refused the write; the previous values are still in effect.',
108
+ failed: 'Could not save: {message}',
109
+ version: 'v{version}',
110
+ }
111
+
112
+ const zh = {
113
+ title: 'ChatJimmy',
114
+ summary: 'ChatJimmy 文本提供方:模型 {model},地址 {baseUrl}。',
115
+ summaryEmpty: 'ChatJimmy 文本提供方(尚未配置)。',
116
+ description: 'ChatJimmy 文本提供方:{model}',
117
+ labelBaseUrl: '基础地址',
118
+ labelModel: '模型',
119
+ labelTopK: 'topK',
120
+ labelContextWindow: '上下文窗口(token)',
121
+ labelStreamIdleTimeoutMs: '流空闲超时(毫秒)',
122
+ hintBaseUrl: '部署地址,结尾的斜杠可省略。',
123
+ hintModel: '作为 chatOptions.selectedModel 发送的模型 id。',
124
+ hintTopK: '转发给服务的采样广度。站点自身发送 8。',
125
+ hintContextWindow: '提示词与回复合计的 token 数;服务上限为 6144。',
126
+ hintStreamIdleTimeoutMs: '流在该时长内无输出即以 TIMEOUT 结束。',
127
+ overridden: '已覆盖',
128
+ save: '保存',
129
+ saving: '保存中…',
130
+ saved: '已保存。下一个请求将使用这些值。',
131
+ reset: '恢复默认',
132
+ persists: '保存在你的配置中;适配器每次请求都会读取该行。',
133
+ readOnly: '只读:此部署不持久化设置。',
134
+ baseUrlInvalid: '基础地址必须是绝对的 http(s) URL。',
135
+ modelInvalid: '模型必须是非空 id。',
136
+ numberInvalid: '必须是正整数。',
137
+ rejected: 'Host 拒绝了写入;原先的值仍然有效。',
138
+ failed: '保存失败:{message}',
139
+ version: 'v{version}',
140
+ }
141
+
142
+ /**
143
+ * Bind one settings scope to a React subscription.
144
+ * @param scope - the scope bound to the chatjimmy settings namespace.
145
+ * @returns a hook reading that scope's current snapshot.
146
+ */
147
+ function useScope(scope) {
148
+ const subscribe = (listener) => scope.subscribe(listener)
149
+ const getSnapshot = () => scope.getSnapshot()
150
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
151
+ }
152
+
153
+ /**
154
+ * Read a snapshot's resolved row. A namespace this deployment does not
155
+ * serve reports no row, which the card renders as nothing at all.
156
+ * @param snapshot - the settings scope snapshot.
157
+ * @returns the resolved row, or `undefined` when unreadable.
158
+ */
159
+ function rowOf(snapshot) {
160
+ if (snapshot.status !== 'ready') return undefined
161
+ return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
162
+ }
163
+
164
+ /** One editable field: label, hint, input, and the override marker. */
165
+ function Field(props) {
166
+ const { t, field, value, overridden, disabled, onChange } = props
167
+ return React.createElement(
168
+ 'div',
169
+ { className: 'cj-field' },
170
+ React.createElement(
171
+ 'div',
172
+ { className: 'cj-row' },
173
+ React.createElement('span', { className: 'cj-label' }, t(field.label)),
174
+ overridden === true
175
+ ? React.createElement('span', { className: 'cj-hint' }, `(${t('overridden')})`)
176
+ : null,
177
+ ),
178
+ React.createElement('input', {
179
+ className: 'cj-input',
180
+ type: field.kind === 'integer' ? 'number' : 'text',
181
+ value: value,
182
+ disabled,
183
+ 'aria-label': t(field.label),
184
+ onChange: (event) => { onChange(field.key, event.target.value) },
185
+ }),
186
+ React.createElement('span', { className: 'cj-hint' }, t(field.hint)),
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Build the card component over one bound settings scope.
192
+ * @param scope - the scope bound to the chatjimmy settings namespace.
193
+ * @param t - translate function bound to this plugin's locale namespace.
194
+ * @returns the component the slot renders.
195
+ */
196
+ function createCard(scope, t) {
197
+ const useChatJimmy = useScope(scope)
198
+
199
+ return function ChatJimmyCard(props) {
200
+ const snapshot = useChatJimmy()
201
+ const [draft, setDraft] = React.useState(null)
202
+ const [error, setError] = React.useState(null)
203
+ const [status, setStatus] = React.useState(null)
204
+ const [pending, setPending] = React.useState(false)
205
+
206
+ const row = rowOf(snapshot)
207
+ // A namespace this deployment does not serve renders no trace of itself.
208
+ if (row === undefined) return null
209
+
210
+ if (props != null && props.view === 'summary') {
211
+ return row.model === undefined
212
+ ? t('summaryEmpty')
213
+ : t('summary', { model: String(row.model), baseUrl: String(row.baseUrl) })
214
+ }
215
+
216
+ const disabled = !snapshot.writable || pending
217
+ const shown = draft ?? row
218
+ const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
219
+
220
+ /**
221
+ * Validate and collect the staged edits.
222
+ * @returns an error key, or the ordered path operations to write.
223
+ */
224
+ const collect = () => {
225
+ const ops = []
226
+ for (const field of FIELDS) {
227
+ const value = shown[field.key]
228
+ const original = row[field.key]
229
+ if (String(value) === String(original)) continue
230
+ if (field.kind === 'integer') {
231
+ const parsed = Number(value)
232
+ if (!Number.isInteger(parsed) || parsed <= 0) return { error: 'numberInvalid' }
233
+ ops.push({ op: 'set', path: [field.key], value: parsed })
234
+ continue
235
+ }
236
+ const text = String(value).trim()
237
+ if (field.key === 'model') {
238
+ if (text === '') return { error: 'modelInvalid' }
239
+ ops.push({ op: 'set', path: [field.key], value: text })
240
+ continue
241
+ }
242
+ if (text === '') return { error: 'baseUrlInvalid' }
243
+ // A scheme prefix is not a URL: the host resolver parses with
244
+ // `new URL` and refuses a root carrying credentials, a query, or a
245
+ // fragment, so the card rejects exactly what that throws on.
246
+ let parsedUrl
247
+ try {
248
+ parsedUrl = new URL(text)
249
+ } catch {
250
+ return { error: 'baseUrlInvalid' }
251
+ }
252
+ if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
253
+ return { error: 'baseUrlInvalid' }
254
+ }
255
+ // `resolveConfig` refuses the same four cases: the adapter appends
256
+ // `/api/chat` to this root, so credentials (which `fetch` rejects
257
+ // outright), a query, or a fragment can never address the endpoint.
258
+ if (parsedUrl.username.length > 0 || parsedUrl.password.length > 0
259
+ || parsedUrl.search.length > 0 || parsedUrl.hash.length > 0) {
260
+ return { error: 'baseUrlInvalid' }
261
+ }
262
+ ops.push({ op: 'set', path: [field.key], value: text })
263
+ }
264
+ return { ops }
265
+ }
266
+
267
+ const write = (ops) => new Promise((resolve) => { resolve(scope.mutate(ops)) })
268
+
269
+ const save = () => {
270
+ if (disabled) return
271
+ setError(null)
272
+ const result = collect()
273
+ if (result.error !== undefined) {
274
+ setError(t(result.error))
275
+ return
276
+ }
277
+ if (result.ops.length === 0) {
278
+ // Nothing moved: an edit that landed back on the stored value is
279
+ // not a change, and the row already holds it.
280
+ setStatus(null)
281
+ return
282
+ }
283
+ setStatus(t('saving'))
284
+ setPending(true)
285
+ write(result.ops)
286
+ .then((accepted) => {
287
+ if (accepted === false) {
288
+ setError(t('rejected'))
289
+ setStatus(null)
290
+ return
291
+ }
292
+ setDraft(null)
293
+ setStatus(t('saved'))
294
+ })
295
+ .catch((cause) => {
296
+ setStatus(null)
297
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
298
+ })
299
+ .finally(() => { setPending(false) })
300
+ }
301
+
302
+ const reset = () => {
303
+ if (disabled) return
304
+ setDraft(null)
305
+ setStatus(null)
306
+ setError(null)
307
+ const ops = Object.keys(user)
308
+ .filter((key) => FIELDS.some((field) => field.key === key))
309
+ .map((key) => ({ op: 'unset', path: [key] }))
310
+ if (ops.length === 0) return
311
+ setPending(true)
312
+ write(ops)
313
+ .then((accepted) => {
314
+ // A refused reset is as visible as a refused save: silence here
315
+ // reads as "reset worked" while the override is still in effect.
316
+ if (accepted === false) setError(t('rejected'))
317
+ })
318
+ .catch((cause) => {
319
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
320
+ })
321
+ .finally(() => { setPending(false) })
322
+ }
323
+
324
+ const overridden = Object.keys(user).some((key) => FIELDS.some((field) => field.key === key))
325
+
326
+ return React.createElement(
327
+ 'div',
328
+ { className: 'cj-page' },
329
+ ...FIELDS.map((field) => React.createElement(Field, {
330
+ key: field.key,
331
+ t,
332
+ field,
333
+ value: String(shown[field.key] ?? ''),
334
+ overridden: Object.hasOwn(user, field.key),
335
+ disabled,
336
+ onChange: (key, value) => {
337
+ setStatus(null)
338
+ setDraft({ ...shown, [key]: value })
339
+ },
340
+ })),
341
+ React.createElement(
342
+ 'div',
343
+ { className: 'cj-row' },
344
+ React.createElement(
345
+ 'button',
346
+ { type: 'button', className: 'cj-button', disabled, onClick: save },
347
+ t('save'),
348
+ ),
349
+ overridden
350
+ ? React.createElement(
351
+ 'button',
352
+ { type: 'button', className: 'cj-button cj-button-quiet', disabled, onClick: reset },
353
+ t('reset'),
354
+ )
355
+ : null,
356
+ ),
357
+ React.createElement(
358
+ 'div',
359
+ { className: 'cj-status' },
360
+ status ?? (snapshot.writable ? t('persists') : t('readOnly')),
361
+ ' ',
362
+ t('version', { version: VERSION }),
363
+ ),
364
+ error === null ? null : React.createElement('div', { className: 'cj-error' }, error),
365
+ )
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Mount the browser surface: the ChatJimmy card on the Plugins page.
371
+ * @param ctx - the browser plugin context.
372
+ */
373
+ function apply(ctx) {
374
+ const t = ctx.locale.bind(LOCALE_NS)
375
+ ctx.effect(
376
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
377
+ 'dsh-chatjimmy: locale dictionary',
378
+ )
379
+
380
+ const scope = ctx.configForms.get(NAMESPACE)
381
+ const Card = createCard(scope, t)
382
+
383
+ // The owner declares its own slot; injecting waits for it to exist, so
384
+ // this registration does not depend on plugin load order. The card takes
385
+ // no injected props (it closes over its own bound scope), so the entry
386
+ // declares the documented `locale` namespace and no `inject`.
387
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
388
+ name: 'plugins.row.config',
389
+ key: '@maci0/dsh-chatjimmy#chatjimmy',
390
+ locale: LOCALE_NS,
391
+ }, Card))
392
+ }
393
+
394
+ exports.apply = apply
395
+ exports.inject = ['slots', 'configForms', 'locale']
396
+ return module.exports
397
+ },
398
+ })
package/lib/host.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The slice of the DeepSeek Harness host surface this plugin uses, declared
3
+ * structurally.
4
+ *
5
+ * Like the other plugins under `~/dsh-plugins`, this package declares the host
6
+ * surface it consumes rather than depending on the harness's own classes: its
7
+ * one runtime `@deepseek-ai/*` dependency is `@deepseek-ai/dsh-llm`'s pure
8
+ * `attributionHeaders()` / `resolveRetryPolicy()` helpers. The harness reaches
9
+ * its adapters through plain method calls on the registered object (there is
10
+ * no `instanceof LlmAdapter` check anywhere in `LlmRuntime`), so a duck-typed
11
+ * adapter is a supported shape, not a workaround.
12
+ *
13
+ * Each declaration here is deliberately narrowed to what this adapter reads or
14
+ * emits. Mirroring a foreign API in full is not documentation: a field we never
15
+ * touch is a field whose absence goes unnoticed, which is how the missing
16
+ * `providerRetryPolicy` reached an integration test instead of a compiler.
17
+ * Widen a declaration when the adapter starts using it, not before.
18
+ *
19
+ * @module dsh-chatjimmy/host
20
+ */
21
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * dsh-chatjimmy: use the chatjimmy.ai model inside DeepSeek Harness.
3
+ *
4
+ * One capability: an `ctx.llm` provider adapter for the reconstructed chat API
5
+ * (see `API.md`). Registering it makes the route selectable in the Web client's
6
+ * model picker, because `buildModelCatalog()` enumerates `ctx.llm.listProviders()`
7
+ * and asks each adapter for `listModels()` / `resolveModel()`.
8
+ *
9
+ * See README.md for the known limits: the service has no tool-calling, no
10
+ * image input, and a 6144-token total context.
11
+ *
12
+ * @module dsh-chatjimmy
13
+ */
14
+ import Schema from '@deepseek-ai/schemastery';
15
+ import { RetryPolicySchema } from '@deepseek-ai/dsh-llm';
16
+ import { ChatJimmyAdapter } from './adapter.js';
17
+ /** Plugin name as it appears in the loader. */
18
+ export const name = 'chatjimmy';
19
+ /** The `ctx.llm` provider route this adapter owns. */
20
+ export const PROVIDER = 'chatjimmy';
21
+ /** The one service this plugin needs mounted. */
22
+ export const inject = ['llm'];
23
+ /**
24
+ * Total context the backend enforces, in tokens, measured empirically:
25
+ * a request whose reported `prefill_tokens` reached 6141 with 2 output tokens
26
+ * succeeded, while one needing 6143 + 2 returned a zero-byte body. The site's
27
+ * own client caps at the same number (`NEXT_PUBLIC_TOKEN_LIMIT`, default 6144).
28
+ */
29
+ const CONTEXT_WINDOW = 6144;
30
+ /** Model the service serves. `/api/models` advertises exactly this one id. */
31
+ const DEFAULT_MODEL = 'llama3.1-8B';
32
+ /** Base URL of the deployment. */
33
+ const DEFAULT_BASE_URL = 'https://chatjimmy.ai';
34
+ /** Per-read stream idle watchdog default, matching the shipped remote adapters. */
35
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000;
36
+ /** `setTimeout`'s maximum delay; a larger configured timeout is rejected at load. */
37
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
38
+ /**
39
+ * Field defaults and bounds with no volatility wrapper.
40
+ *
41
+ * {@link resolveConfig} parses a plain row through this schema, so its output
42
+ * is plain values; the loader-facing {@link Config} below is the same shape
43
+ * with every editable field made `volatile()`, which is what hands the plugin
44
+ * a live reference. The pair is asserted equal in the suite.
45
+ */
46
+ const ValueSchema = Schema.object({
47
+ baseUrl: Schema.string().default(DEFAULT_BASE_URL),
48
+ model: Schema.string().default(DEFAULT_MODEL),
49
+ topK: Schema.number().step(1).min(1).default(8),
50
+ contextWindow: Schema.number().step(1).min(1).default(CONTEXT_WINDOW),
51
+ streamIdleTimeoutMs: Schema.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS)
52
+ .default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
53
+ retryPolicy: RetryPolicySchema,
54
+ });
55
+ /**
56
+ * Row schema as Cordis resolves it: defaults live here, so a deployment only
57
+ * states what it changes.
58
+ *
59
+ * Every field a user may edit is `volatile()`: the settings document accepts
60
+ * only volatile paths, and the browser half's card edits exactly these. The
61
+ * adapter resolves the row per read, so an edit lands on the next request
62
+ * instead of waiting for a remount. `retryPolicy` stays ordinary
63
+ * configuration: patch-only, as its docs say.
64
+ */
65
+ export const Config = Schema.object({
66
+ baseUrl: Schema.string().default(DEFAULT_BASE_URL).volatile(),
67
+ model: Schema.string().default(DEFAULT_MODEL).volatile(),
68
+ topK: Schema.number().step(1).min(1).default(8).volatile(),
69
+ contextWindow: Schema.number().step(1).min(1).default(CONTEXT_WINDOW).volatile(),
70
+ streamIdleTimeoutMs: Schema.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS)
71
+ .default(DEFAULT_STREAM_IDLE_TIMEOUT_MS).volatile(),
72
+ retryPolicy: RetryPolicySchema,
73
+ });
74
+ /**
75
+ * Validate and normalize one configuration row.
76
+ *
77
+ * The row is fed back through the exported `Config` schema, which is the one
78
+ * source of the defaults and the numeric bounds. Cordis already ran the same
79
+ * schema before `apply`, so this only makes `resolveConfig` usable on its own.
80
+ * What the schema cannot express is checked here: invalid values throw rather
81
+ * than being silently defaulted, because a typo'd base URL would otherwise
82
+ * present as an opaque transport failure on the first message.
83
+ *
84
+ * @param config - raw row configuration.
85
+ * @returns the resolved adapter configuration.
86
+ */
87
+ export function resolveConfig(config = {}) {
88
+ const resolved = ValueSchema(config);
89
+ const baseUrl = resolved.baseUrl.replace(/\/+$/, '');
90
+ let parsed;
91
+ try {
92
+ parsed = new URL(baseUrl);
93
+ }
94
+ catch {
95
+ throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" is not a valid absolute URL`);
96
+ }
97
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
98
+ throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" must use http or https`);
99
+ }
100
+ // The adapter appends `/api/chat` to this root. Credentials make `fetch`
101
+ // refuse the URL (and hide the real host behind a look-alike userinfo), and a
102
+ // query or fragment swallows the appended path, so no such URL can ever
103
+ // address the documented endpoint. Same rule as the harness's own providers.
104
+ if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.search.length > 0 || parsed.hash.length > 0) {
105
+ throw new Error(`chatjimmy: baseUrl "${resolved.baseUrl}" must be an HTTP(S) root without credentials, query, or fragment`);
106
+ }
107
+ // Any string is a valid model id to the schema, but the empty one is not a model.
108
+ if (resolved.model.trim().length === 0)
109
+ throw new Error('chatjimmy: model must be a non-empty string');
110
+ // `min`/`max` cannot see NaN (every comparison against it is false, so NaN
111
+ // passes both), and schemastery has no refinement hook. One explicit check.
112
+ for (const field of ['topK', 'contextWindow', 'streamIdleTimeoutMs']) {
113
+ if (!Number.isFinite(resolved[field])) {
114
+ throw new Error(`chatjimmy: ${field} must be a finite number, got ${String(resolved[field])}`);
115
+ }
116
+ }
117
+ return { ...resolved, baseUrl };
118
+ }
119
+ /**
120
+ * Read the live row out of the references the loader resolved.
121
+ *
122
+ * Every editable field arrives as a `Volatile<T>`; this is the one place that
123
+ * turns them back into the plain values the schema and the adapter understand,
124
+ * so a caller cannot forget one.
125
+ *
126
+ * @param config - the resolved row.
127
+ * @returns plain row values, with absent references left undefined.
128
+ */
129
+ export function liveOptions(config) {
130
+ return {
131
+ baseUrl: config.baseUrl.get(),
132
+ model: config.model.get(),
133
+ topK: config.topK.get(),
134
+ contextWindow: config.contextWindow.get(),
135
+ streamIdleTimeoutMs: config.streamIdleTimeoutMs.get(),
136
+ retryPolicy: config.retryPolicy,
137
+ };
138
+ }
139
+ /**
140
+ * Mount the adapter.
141
+ *
142
+ * The adapter is handed the resolver itself, not one resolved row: every
143
+ * configurable field is `volatile()`, so a settings write from the Plugins
144
+ * card changes what the next request uses without remounting the provider
145
+ * route (which would drop the model picker's selection). The row is resolved
146
+ * once here anyway, so an unusable one still fails at mount the way a
147
+ * non-volatile row would.
148
+ *
149
+ * @param ctx - host context; `ctx.llm` must be mounted (`inject` guarantees it).
150
+ * @param config - this plugin's row configuration.
151
+ */
152
+ export function apply(ctx, config) {
153
+ const live = () => resolveConfig(liveOptions(config));
154
+ const resolved = live();
155
+ ctx.llm.registerAdapter([PROVIDER], new ChatJimmyAdapter(live));
156
+ ctx.logger.info(`chatjimmy: provider "${PROVIDER}" registered for model "${resolved.model}" at ${resolved.baseUrl}`
157
+ + ` (text only, ${resolved.contextWindow}-token total context)`);
158
+ // Report edits rather than swallowing them: a card write that made the row
159
+ // unusable has to be visible somewhere, and this is the only surface the
160
+ // host owns. The value stays on the row, so the request that needs it will
161
+ // raise the same error with the same message.
162
+ ctx.on('loader/volatile-update', () => {
163
+ try {
164
+ const next = live();
165
+ ctx.logger.info(`chatjimmy: configuration updated: model "${next.model}" at ${next.baseUrl}`
166
+ + `, topK ${String(next.topK)}, ${next.contextWindow}-token context`);
167
+ }
168
+ catch (error) {
169
+ ctx.logger.warn(`chatjimmy: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
171
+ });
172
+ }