mohdel 0.117.3 → 0.119.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.
Files changed (44) hide show
  1. package/README.md +4 -2
  2. package/js/client/call.js +4 -32
  3. package/js/client/call_image.js +5 -33
  4. package/js/client/call_transcription.js +5 -33
  5. package/js/client/ndjson.js +8 -4
  6. package/js/client/response.js +40 -0
  7. package/js/client/transport.js +6 -1
  8. package/js/core/envelope.js +46 -1
  9. package/js/core/errors.js +30 -14
  10. package/js/core/framing.js +29 -0
  11. package/js/core/image.js +1 -1
  12. package/js/core/model-id.js +7 -30
  13. package/js/core/status.js +0 -8
  14. package/js/core/transcription.js +1 -1
  15. package/js/factory/bridge.js +48 -54
  16. package/js/session/_idle_heartbeat.js +53 -24
  17. package/js/session/adapters/_cancelled.js +1 -1
  18. package/js/session/adapters/_chat_completions.js +1 -1
  19. package/js/session/adapters/_errors.js +43 -8
  20. package/js/session/adapters/_images.js +26 -17
  21. package/js/session/adapters/_lazy_json_cache.js +32 -12
  22. package/js/session/adapters/_media.js +205 -0
  23. package/js/session/adapters/_providers.js +0 -8
  24. package/js/session/adapters/_videos.js +67 -28
  25. package/js/session/adapters/anthropic.js +5 -3
  26. package/js/session/adapters/gemini.js +6 -3
  27. package/js/session/adapters/image/novita.js +1 -17
  28. package/js/session/adapters/openai.js +5 -3
  29. package/js/session/adapters/transcription/index.js +0 -9
  30. package/js/session/adapters/transcription/openai_compatible.js +13 -38
  31. package/js/session/driver.js +69 -28
  32. package/js/session/run.js +7 -1
  33. package/package.json +31 -10
  34. package/src/cli/_chalk.js +41 -0
  35. package/src/cli/colored-logger.js +1 -1
  36. package/src/cli/colors.js +1 -1
  37. package/src/cli/onboard.js +8 -3
  38. package/src/lib/cache.js +0 -73
  39. package/src/lib/catalog/gemini.js +3 -2
  40. package/src/lib/common.js +20 -11
  41. package/src/lib/curated-cache.js +0 -16
  42. package/src/lib/providers.js +1 -12
  43. package/src/lib/cooldown.js +0 -63
  44. package/src/lib/errors.js +0 -43
@@ -23,7 +23,8 @@
23
23
  import { run } from '../session/run.js'
24
24
  import { runImage } from '../session/run_image.js'
25
25
  import { runTranscription } from '../session/run_transcription.js'
26
- import { MohdelError, Severity } from '../../src/lib/errors.js'
26
+ import { markTrustedMedia } from '../session/adapters/_media.js'
27
+ import { MohdelError, validateIds } from '#core'
27
28
  import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
28
29
 
29
30
  /**
@@ -60,20 +61,20 @@ import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
60
61
  * | `configuration.apiKey` | envelope.auth.key |
61
62
  * | **`parentSpan`** | **dropped** — use `traceparent` instead |
62
63
  * | **`maybeThrowHandler`** | **dropped** — no factory-side validation hook |
63
- * | **`configuration.baseURL` / `defaultHeaders` / …**| **rejected** — adapters own baseURL (F24) |
64
+ * | **`configuration.baseURL` / `defaultHeaders` / …**| **rejected** — adapters own baseURL |
64
65
  *
65
66
  * @param {object} args
66
67
  * @param {string} args.provider Resolved provider name (e.g. 'openai').
67
68
  * @param {string} args.model Provider-native model id (no provider prefix).
68
69
  * @param {string} args.modelKey Catalog key `<provider>/<model>` — for spec/pricing.
69
- * @param {any} args.configuration Provider config. Only `apiKey` is threaded; other fields are rejected (F24).
70
+ * @param {any} args.configuration Provider config. Only `apiKey` is threaded; other fields are rejected.
70
71
  * @param {string | any[] | {system?: any, messages: any[]}} args.prompt
71
72
  * @param {any} [args.options] Factory `answer()` options.
72
73
  * @param {BridgeDeps} [deps]
73
74
  * @returns {Promise<any>} AnswerResult (matches the factory's return shape).
74
75
  */
75
76
  export async function runAnswer ({ provider, model, modelKey, configuration, prompt, options = {} }, deps = {}) {
76
- const envelope = toEnvelope({ modelKey, configuration, prompt, options })
77
+ const envelope = markTrustedMedia(toEnvelope({ modelKey, configuration, prompt, options }))
77
78
 
78
79
  // If the caller passed a `realtimeHandler`, feed every `delta`
79
80
  // event into a buffer that invokes the handler on batches matching
@@ -81,7 +82,7 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
81
82
  // never fire — `mo ask --stream` and any integration that relies
82
83
  // on streaming callbacks stops working.
83
84
  //
84
- // F54: skip the buffer allocation entirely when no handler was
85
+ // Skip the buffer allocation entirely when no handler was
85
86
  // supplied — the common case.
86
87
  const deltaBuffer = options.realtimeHandler
87
88
  ? createRealtimeDeltaBuffer(options.realtimeHandler, options.bufferOpts)
@@ -105,15 +106,14 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
105
106
  }
106
107
 
107
108
  if (!terminal) {
108
- throw new MohdelError('SESSION_NO_TERMINAL', {
109
- severity: Severity.ERROR,
110
- detail: 'session run produced no terminal event',
109
+ throw new MohdelError('session run produced no terminal event', {
110
+ type: 'SESSION_NO_TERMINAL',
111
111
  retryable: false
112
112
  })
113
113
  }
114
114
 
115
115
  if (terminal.type === 'error') {
116
- throw fromTypedError(terminal.error, { provider, model, modelKey })
116
+ throw MohdelError.fromJSON(terminal.error, { provider, model, modelKey })
117
117
  }
118
118
 
119
119
  return terminal.result
@@ -135,9 +135,13 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
135
135
  * @returns {Promise<any>}
136
136
  */
137
137
  export async function runAnswerImage ({ provider, model, configuration, prompt, options = {}, spec }) {
138
+ const callId = options.callId || newCallId()
139
+ const authId = options.authId || 'local'
140
+ assertValidIds(callId, authId, `${provider}/${model}`)
141
+
138
142
  const envelope = {
139
- callId: options.callId || newCallId(),
140
- authId: options.authId || 'local',
143
+ callId,
144
+ authId,
141
145
  auth: configToAuth(configuration),
142
146
  model: `${provider}/${model}`,
143
147
  prompt
@@ -145,8 +149,8 @@ export async function runAnswerImage ({ provider, model, configuration, prompt,
145
149
  if (options.size) envelope.size = options.size
146
150
  if (options.seed != null) envelope.seed = options.seed
147
151
 
148
- const out = await runImage(envelope, spec ? { spec } : {})
149
- if (!out.ok) throw fromTypedError(out.error, { provider, model })
152
+ const out = await runImage(markTrustedMedia(envelope), spec ? { spec } : {})
153
+ if (!out.ok) throw MohdelError.fromJSON(out.error, { provider, model })
150
154
  return out.result
151
155
  }
152
156
 
@@ -168,9 +172,13 @@ export async function runAnswerImage ({ provider, model, configuration, prompt,
168
172
  * @returns {Promise<any>}
169
173
  */
170
174
  export async function runAnswerTranscription ({ provider, model, configuration, audio, options = {}, spec }) {
175
+ const callId = options.callId || newCallId()
176
+ const authId = options.authId || 'local'
177
+ assertValidIds(callId, authId, `${provider}/${model}`)
178
+
171
179
  const envelope = {
172
- callId: options.callId || newCallId(),
173
- authId: options.authId || 'local',
180
+ callId,
181
+ authId,
174
182
  auth: configToAuth(configuration),
175
183
  model: `${provider}/${model}`,
176
184
  audio
@@ -178,8 +186,8 @@ export async function runAnswerTranscription ({ provider, model, configuration,
178
186
  if (options.language) envelope.language = options.language
179
187
  if (options.prompt) envelope.prompt = options.prompt
180
188
 
181
- const out = await runTranscription(envelope, spec ? { spec } : {})
182
- if (!out.ok) throw fromTypedError(out.error, { provider, model })
189
+ const out = await runTranscription(markTrustedMedia(envelope), spec ? { spec } : {})
190
+ if (!out.ok) throw MohdelError.fromJSON(out.error, { provider, model })
183
191
  return out.result
184
192
  }
185
193
 
@@ -196,12 +204,16 @@ export async function runAnswerTranscription ({ provider, model, configuration,
196
204
  * @returns {import('#core/envelope.js').CallEnvelope}
197
205
  */
198
206
  function toEnvelope ({ modelKey, configuration, prompt, options }) {
207
+ const callId = options.callId || newCallId()
208
+ const authId = options.authId || 'local'
209
+ assertValidIds(callId, authId, modelKey)
210
+
199
211
  /** @type {import('#core/envelope.js').CallEnvelope} */
200
212
  const envelope = {
201
- callId: options.callId || newCallId(),
202
- authId: options.authId || 'local',
213
+ callId,
214
+ authId,
203
215
  auth: configToAuth(configuration),
204
- model: /** @type {import('#core/model-id.js').ModelId} */ (modelKey),
216
+ model: modelKey,
205
217
  prompt: toEnvelopePrompt(prompt)
206
218
  }
207
219
 
@@ -237,36 +249,18 @@ function toEnvelope ({ modelKey, configuration, prompt, options }) {
237
249
  }
238
250
 
239
251
  /**
240
- * Re-throw a TypedError as a MohdelError so factory-API caller catch
241
- * blocks — which duck-type on `.detail` / `.retryable` — keep
242
- * working without knowing about the session event-stream shape.
243
- *
244
- * @param {import('#core/errors.js').TypedError} err
245
- * @param {{provider: string, model: string, modelKey?: string}} ctx
246
- * @returns {MohdelError}
252
+ * @param {string} callId
253
+ * @param {string} authId
254
+ * @param {string} model
247
255
  */
248
- function fromTypedError (err, ctx) {
249
- // TypedError `message` is the machine-key label (e.g. "provider error
250
- // 400"); `detail` carries the provider's own rejection text when it
251
- // was safe to surface. Prefer the detail so callers see what to fix.
252
- return new MohdelError(err.type || 'PROVIDER_ERROR', {
253
- severity: toSeveritySymbol(err.severity),
254
- detail: err.detail || err.message,
255
- retryable: !!err.retryable,
256
- context: { provider: ctx.provider, model: ctx.model }
257
- })
258
- }
259
-
260
- /** @param {string | undefined} s */
261
- function toSeveritySymbol (s) {
262
- switch (s) {
263
- case 'trace': return Severity.TRACE
264
- case 'debug': return Severity.DEBUG
265
- case 'info': return Severity.INFO
266
- case 'warn': return Severity.WARN
267
- case 'error': return Severity.ERROR
268
- case 'fatal': return Severity.FATAL
269
- default: return Severity.ERROR
256
+ function assertValidIds (callId, authId, model) {
257
+ const reason = validateIds(callId, authId, model)
258
+ if (reason) {
259
+ throw new MohdelError('invalid envelope', {
260
+ type: 'PROTOCOL_INVALID_ENVELOPE',
261
+ detail: reason,
262
+ retryable: false
263
+ })
270
264
  }
271
265
  }
272
266
 
@@ -287,12 +281,12 @@ function newCallId () {
287
281
  */
288
282
  const ALLOWED_CONFIG_KEYS = new Set(['apiKey', 'baseURL'])
289
283
 
290
- function configToAuth (configuration) {
284
+ export function configToAuth (configuration) {
291
285
  if (!configuration) return { key: '' }
292
286
  const unsupported = Object.keys(configuration).filter(k => !ALLOWED_CONFIG_KEYS.has(k))
293
287
  if (unsupported.length > 0) {
294
- throw new MohdelError('CONFIGURATION_UNSUPPORTED', {
295
- severity: Severity.ERROR,
288
+ throw new MohdelError('unsupported per-call configuration', {
289
+ type: 'CONFIGURATION_UNSUPPORTED',
296
290
  detail:
297
291
  'per-call SDK configuration is limited to `apiKey` and `baseURL`. ' +
298
292
  `Unsupported keys: ${unsupported.join(', ')}. ` +
@@ -372,8 +366,8 @@ function toEnvelopePrompt (prompt) {
372
366
  // fall through would land a raw non-iterable in the envelope and
373
367
  // produce a confusing `prompt.map is not a function` deep inside
374
368
  // the adapter.
375
- throw new MohdelError('SESSION_INVALID_PROMPT', {
376
- severity: Severity.ERROR,
369
+ throw new MohdelError('invalid prompt shape', {
370
+ type: 'SESSION_INVALID_PROMPT',
377
371
  retryable: false,
378
372
  detail:
379
373
  'prompt must be a string, a Message[] array, ' +
@@ -11,20 +11,30 @@
11
11
  * own. Consumers decide whether to log, bump a watchdog, or trigger
12
12
  * an external AbortSignal.
13
13
  *
14
- * The in-flight `iterator.next()` is reused across timer firings so
15
- * no real event is dropped: when the timer wins the race, the
16
- * underlying promise stays pending and the next loop iteration
17
- * attaches a fresh race to the same promise.
14
+ * `idleMs` is caller-supplied and raised to `MIN_IDLE_HEARTBEAT_MS`.
15
+ * Without a floor one request can ask for a millisecond cadence and
16
+ * turn adapter silence into thousands of serialized events per second
17
+ * through the gate.
18
+ *
19
+ * The in-flight `iterator.next()` is reused across timer firings so no
20
+ * real event is dropped. Its continuation is attached exactly once, at
21
+ * creation, and parks its result in `settled`; each idle tick races a
22
+ * fresh timer against that flag rather than re-subscribing. Attaching
23
+ * per tick instead would retain two reaction records per firing on a
24
+ * promise that by definition has not settled.
18
25
  *
19
26
  * @module session/_idle_heartbeat
20
27
  */
21
28
 
29
+ export const MIN_IDLE_HEARTBEAT_MS = 250
30
+
22
31
  /**
23
32
  * @template T
24
33
  * @param {AsyncIterable<T>} source
25
34
  * @param {number | undefined | null} idleMs
26
35
  * When falsy or non-positive, the source is yielded through
27
- * unchanged (no timer is set up).
36
+ * unchanged (no timer is set up). Positive values below
37
+ * `MIN_IDLE_HEARTBEAT_MS` are raised to it.
28
38
  * @returns {AsyncGenerator<T | import('#core/events.js').IdleEvent>}
29
39
  */
30
40
  export async function * withIdleHeartbeat (source, idleMs) {
@@ -33,28 +43,45 @@ export async function * withIdleHeartbeat (source, idleMs) {
33
43
  return
34
44
  }
35
45
 
46
+ const tickMs = Math.max(idleMs, MIN_IDLE_HEARTBEAT_MS)
36
47
  const iter = source[Symbol.asyncIterator]()
37
48
  let lastAt = Date.now()
38
- /** @type {Promise<IteratorResult<T>> | null} */
39
- let pending = null
49
+
50
+ /** @type {{real: IteratorResult<T>} | {err: unknown} | null} */
51
+ let settled = null
52
+ /** @type {(() => void) | null} */
53
+ let wake = null
54
+ let inFlight = false
55
+
56
+ const park = (result) => {
57
+ settled = result
58
+ const w = wake
59
+ wake = null
60
+ w?.()
61
+ }
40
62
 
41
63
  try {
42
64
  while (true) {
43
- if (!pending) pending = iter.next()
44
-
45
- /** @type {NodeJS.Timeout | undefined} */
46
- let timer
47
- /** @type {{idle: true} | {real: IteratorResult<T>} | {err: unknown}} */
48
- const winner = await new Promise(resolve => {
49
- timer = setTimeout(() => resolve({ idle: true }), idleMs)
50
- pending.then(
51
- r => resolve({ real: r }),
52
- e => resolve({ err: e })
65
+ if (!inFlight) {
66
+ inFlight = true
67
+ iter.next().then(
68
+ r => park({ real: r }),
69
+ e => park({ err: e })
53
70
  )
54
- })
55
- clearTimeout(timer)
71
+ }
72
+
73
+ if (!settled) {
74
+ /** @type {NodeJS.Timeout | undefined} */
75
+ let timer
76
+ await new Promise(resolve => {
77
+ wake = resolve
78
+ timer = setTimeout(resolve, tickMs)
79
+ })
80
+ clearTimeout(timer)
81
+ wake = null
82
+ }
56
83
 
57
- if ('idle' in winner) {
84
+ if (!settled) {
58
85
  yield /** @type {import('#core/events.js').IdleEvent} */ ({
59
86
  type: 'idle',
60
87
  sinceMs: Date.now() - lastAt
@@ -62,11 +89,13 @@ export async function * withIdleHeartbeat (source, idleMs) {
62
89
  continue
63
90
  }
64
91
 
65
- pending = null
66
- if ('err' in winner) throw winner.err
67
- if (winner.real.done) return
92
+ const done = settled
93
+ settled = null
94
+ inFlight = false
95
+ if ('err' in done) throw done.err
96
+ if (done.real.done) return
68
97
  lastAt = Date.now()
69
- yield winner.real.value
98
+ yield done.real.value
70
99
  }
71
100
  } finally {
72
101
  // Best-effort cleanup if the consumer abandons us mid-stream.
@@ -3,7 +3,7 @@
3
3
  * a terminal `done` event on `signal.aborted` mid-stream.
4
4
  *
5
5
  * Three adapters (openai, anthropic, gemini) had byte-identical
6
- * copies of this before F58; consolidated here. `_chat_completions.js`
6
+ * copies of this; consolidated here. `_chat_completions.js`
7
7
  * and `run.js` have their own cancel paths — don't migrate them
8
8
  * here unless you're certain the shape matches (thinkingTokens,
9
9
  * cost, tool_calls semantics can all differ).
@@ -142,7 +142,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
142
142
  args.stream = true
143
143
  args.stream_options = { include_usage: true }
144
144
 
145
- // F53: accumulate via array + join to avoid per-delta V8 cons-string
145
+ // Accumulate via array + join to avoid per-delta V8 cons-string
146
146
  // churn on long streams.
147
147
  const contentParts = []
148
148
  const reasoningParts = []
@@ -32,13 +32,6 @@
32
32
 
33
33
  const DETAIL_CAP = 500
34
34
 
35
- /**
36
- * Extract a short human-readable detail from an SDK error. Trimmed
37
- * to `DETAIL_CAP` chars so a verbose provider body doesn't blow up
38
- * log pipelines.
39
- * @param {any} err
40
- * @returns {string | undefined}
41
- */
42
35
  /**
43
36
  * Replace verbatim occurrences of `key` in `detail` with a masked
44
37
  * form. Long keys (≥ 16 chars) become `<first4>…<last4>` so a caller
@@ -60,6 +53,13 @@ function scrubKey (detail, key) {
60
53
  return detail.split(key).join(mask)
61
54
  }
62
55
 
56
+ /**
57
+ * Extract a short human-readable detail from an SDK error. Trimmed
58
+ * to `DETAIL_CAP` chars so a verbose provider body doesn't blow up
59
+ * log pipelines.
60
+ * @param {any} err
61
+ * @returns {string | undefined}
62
+ */
63
63
  function extractDetail (err) {
64
64
  if (!err) return undefined
65
65
  const nested = err.error?.message || err.response?.data?.error?.message
@@ -410,10 +410,45 @@ export function classifyProviderError (e, key, opts = {}) {
410
410
  }
411
411
  }
412
412
  return {
413
- message: message ? String(message).slice(0, 200) : 'network error',
413
+ message: message ? scrubKey(String(message), key).slice(0, 200) : 'network error',
414
414
  severity: 'warn',
415
415
  retryable: true,
416
416
  type: 'NET_ERROR',
417
417
  detail
418
418
  }
419
419
  }
420
+
421
+ /**
422
+ * Wrap a `TypedError` payload in a throwable `Error`. Adapters that
423
+ * fetch directly (rather than through a provider SDK) throw these; the
424
+ * run loop lifts `.typed` onto the terminal error event.
425
+ *
426
+ * @param {string} message
427
+ * @param {string} type
428
+ * @param {boolean} retryable
429
+ * @param {string} [detail]
430
+ * @returns {Error & {typed: import('#core/errors.js').TypedError}}
431
+ */
432
+ export function typedError (message, type, retryable, detail) {
433
+ const err = new Error(message)
434
+ /** @type {import('#core/errors.js').TypedError} */
435
+ const typed = { message, severity: retryable ? 'warn' : 'error', retryable, type }
436
+ if (detail) typed.detail = detail
437
+ return Object.assign(err, { typed })
438
+ }
439
+
440
+ /**
441
+ * Classify an HTTP status, keeping the classifier's stable message and
442
+ * routing the caller's context plus any response-body snippet into
443
+ * `detail` — provider response bodies must never reach
444
+ * `TypedError.message`.
445
+ *
446
+ * @param {number} status
447
+ * @param {string} message
448
+ * @param {string} [detail]
449
+ * @returns {Error & {typed: import('#core/errors.js').TypedError}}
450
+ */
451
+ export function fromHttpStatus (status, message, detail) {
452
+ const typed = classifyProviderError({ status })
453
+ return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
454
+ }
@@ -11,10 +11,16 @@
11
11
  * provider. Errors are surfaced — file IO failures bubble up so the
12
12
  * adapter can emit a typed error rather than silently skipping.
13
13
  *
14
+ * Local paths are resolved through `_media.js`, which owns symlink
15
+ * resolution, the `MOHDEL_MEDIA_ROOTS` confinement check and the read
16
+ * cap.
17
+ *
14
18
  * @module session/adapters/_images
15
19
  */
16
20
 
17
- import { readFile } from 'node:fs/promises'
21
+ import { dataUriPayload, mediaError, mediaScheme, readLocalMedia } from './_media.js'
22
+
23
+ const IMAGE_ERROR = 'SESSION_INVALID_IMAGE'
18
24
 
19
25
  /**
20
26
  * @typedef {object} LoadedImage
@@ -25,36 +31,39 @@ import { readFile } from 'node:fs/promises'
25
31
 
26
32
  /**
27
33
  * @param {Array<{fileUri: string, mimeType: string}>} images
34
+ * @param {{trusted?: boolean}} [opts]
28
35
  * @returns {Promise<LoadedImage[]>}
29
36
  */
30
- export async function loadImages (images) {
37
+ export async function loadImages (images, opts = {}) {
31
38
  if (!images || !Array.isArray(images)) return []
32
39
  const out = []
33
40
  for (const img of images) {
34
41
  if (!img?.fileUri || !img?.mimeType) continue
35
- out.push(await loadImage(img))
42
+ out.push(await loadImage(img, opts))
36
43
  }
37
44
  return out
38
45
  }
39
46
 
40
47
  /**
41
48
  * @param {{fileUri: string, mimeType: string}} image
49
+ * @param {{trusted?: boolean}} [opts]
42
50
  * @returns {Promise<LoadedImage>}
43
51
  */
44
- export async function loadImage (image) {
52
+ export async function loadImage (image, opts = {}) {
45
53
  const { fileUri, mimeType } = image
46
- if (fileUri.startsWith('file://')) {
47
- const path = fileUri.replace(/^file:\/\//, '')
48
- const buf = await readFile(path)
49
- return { mimeType, base64: buf.toString('base64') }
50
- }
51
- if (fileUri.startsWith('data:')) {
52
- const parts = fileUri.split(',')
53
- if (parts.length > 1) return { mimeType, base64: parts[1] }
54
- throw new Error(`malformed data URI: ${fileUri.slice(0, 32)}…`)
55
- }
56
- if (fileUri.startsWith('https://') || fileUri.startsWith('http://')) {
57
- return { mimeType, url: fileUri }
54
+ switch (mediaScheme(fileUri)) {
55
+ case 'file': {
56
+ const { bytes } = await readLocalMedia(fileUri, { type: IMAGE_ERROR, trusted: opts.trusted })
57
+ return { mimeType, base64: bytes.toString('base64') }
58
+ }
59
+ case 'data':
60
+ return { mimeType, base64: dataUriPayload(fileUri, IMAGE_ERROR) }
61
+ case 'remote':
62
+ return { mimeType, url: fileUri }
63
+ default:
64
+ throw mediaError(
65
+ `unsupported image URI scheme: ${fileUri.slice(0, 32)}…`,
66
+ IMAGE_ERROR
67
+ )
58
68
  }
59
- throw new Error(`unsupported image URI scheme: ${fileUri.slice(0, 32)}…`)
60
69
  }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Shared lazy-load-once JSON cache for config files under
3
3
  * `~/.config/mohdel/`. `_catalog.js` and `_providers.js` both had
4
- * byte-similar implementations before F62; this helper owns the
5
- * pattern.
4
+ * byte-similar implementations; this helper owns the pattern.
6
5
  *
7
6
  * Contract:
8
7
  * - `loadSync(path?)` — synchronous read; used as the lazy
@@ -15,9 +14,12 @@
15
14
  * hook for deployments that source config from elsewhere).
16
15
  * - `get(key)` — read-through; loads synchronously on first miss.
17
16
  *
18
- * A malformed / missing / non-object file resolves to the supplied
19
- * `defaultValue` (default `{}`) so callers never have to handle
20
- * file-absence explicitly.
17
+ * A missing file resolves to the supplied `defaultValue` (default
18
+ * `{}`) so callers never have to handle file-absence explicitly. A
19
+ * file that exists but does not parse throws: absent config is a
20
+ * runtime state, corrupt config is a bug, and collapsing the two
21
+ * turns a typo in `curated.json` into `Unknown model` on every call
22
+ * with nothing naming the real cause.
21
23
  *
22
24
  * @module session/adapters/_lazy_json_cache
23
25
  */
@@ -41,24 +43,42 @@ export function createLazyJsonFileCache (pathFn, { defaultValue = /** @type {any
41
43
  return /** @type {V} */(parsed)
42
44
  }
43
45
 
46
+ /** @param {string} file */
47
+ function parseOrThrow (text, file) {
48
+ try {
49
+ return normalize(JSON.parse(text))
50
+ } catch (e) {
51
+ throw new Error(`[mohdel] ${file} is not valid JSON: ${e.message}`, { cause: e })
52
+ }
53
+ }
54
+
44
55
  /** @param {string} [p] */
45
56
  function loadSync (p) {
46
57
  const file = p ?? pathFn()
58
+ let text
47
59
  try {
48
- return normalize(JSON.parse(fs.readFileSync(file, 'utf8')))
49
- } catch {
50
- return defaultValue
60
+ text = fs.readFileSync(file, 'utf8')
61
+ } catch (e) {
62
+ if (e.code === 'ENOENT') return defaultValue
63
+ throw e
51
64
  }
65
+ return parseOrThrow(text, file)
52
66
  }
53
67
 
54
68
  async function initAsync () {
55
69
  if (active !== null) return
70
+ const file = pathFn()
71
+ let text
56
72
  try {
57
- const text = await fs.promises.readFile(pathFn(), 'utf8')
58
- active = normalize(JSON.parse(text))
59
- } catch {
60
- active = defaultValue
73
+ text = await fs.promises.readFile(file, 'utf8')
74
+ } catch (e) {
75
+ if (e.code === 'ENOENT') {
76
+ active = defaultValue
77
+ return
78
+ }
79
+ throw e
61
80
  }
81
+ active = parseOrThrow(text, file)
62
82
  }
63
83
 
64
84
  /** @param {V} table */