@gobing-ai/knowledge-kit 0.0.23 → 0.0.24

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 (67) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +137 -59
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +97 -45
  5. package/plugins/generations/core-facts-gen/dist/index.js +106 -57
  6. package/plugins/generations/daily-article-gen/dist/index.js +105 -56
  7. package/plugins/generations/dailynews-gen/dist/index.js +235 -57
  8. package/plugins/generations/dailynews-gen/src/index.ts +67 -2
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +36 -15
  10. package/plugins/generations/dailynews-gen/src/script-plan.ts +142 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +115 -66
  12. package/plugins/generations/gemini-tts/README.md +24 -0
  13. package/plugins/generations/gemini-tts/dist/index.js +326 -260
  14. package/plugins/generations/gemini-tts/src/gemini-client.ts +41 -11
  15. package/plugins/generations/image-gen/dist/index.js +338 -117
  16. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +19 -0
  17. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +55 -0
  18. package/plugins/generations/image-gen/presets/styles/daily-news.yaml +9 -0
  19. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +7 -0
  20. package/plugins/generations/image-gen/src/index.ts +148 -14
  21. package/plugins/generations/image-gen/src/job.ts +30 -0
  22. package/plugins/generations/image-gen/src/providers/errors.ts +91 -0
  23. package/plugins/generations/image-gen/src/providers/types.ts +79 -0
  24. package/plugins/generations/news-report-gen/dist/index.js +136 -55
  25. package/plugins/generations/news-report-gen/src/index.ts +57 -1
  26. package/plugins/generations/voice-gen/dist/index.js +119 -67
  27. package/plugins/ingestions/aihot-ingest/dist/index.js +103 -53
  28. package/plugins/ingestions/horizon-ingest/dist/index.js +102 -53
  29. package/plugins/ingestions/karakeep-local/dist/index.js +273 -224
  30. package/plugins/ingestions/last30days-ingest/dist/index.js +97 -48
  31. package/plugins/ingestions/web-search/dist/index.js +121 -72
  32. package/plugins/kk/config.example.yaml +13 -0
  33. package/plugins/kk/plugin.json +1 -1
  34. package/plugins/kk/scripts/daily-voice-style.zh.md +52 -0
  35. package/plugins/kk/scripts/kk-workflow-stages.ts +433 -16
  36. package/plugins/kk/skills/publish/SKILL.md +24 -1
  37. package/plugins/kk/skills/xhs-images/SKILL.md +89 -0
  38. package/plugins/kk/skills/xhs-images/references/card-layouts.md +35 -0
  39. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +162 -11
  40. package/plugins/publishings/emdash-pub/dist/index.js +94 -45
  41. package/plugins/publishings/infoq-pub/dist/index.js +94 -45
  42. package/plugins/publishings/juejin-pub/dist/index.js +94 -45
  43. package/plugins/publishings/medium-pub/dist/index.js +94 -45
  44. package/plugins/publishings/podcast-pub/dist/index.js +115 -55
  45. package/plugins/publishings/podcast-pub/src/map.ts +30 -10
  46. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -1
  47. package/plugins/publishings/qiita-pub/dist/index.js +94 -45
  48. package/plugins/publishings/substack-pub/dist/index.js +94 -45
  49. package/plugins/publishings/surfdash-pub/dist/index.js +99 -63
  50. package/plugins/publishings/surfdash-pub/src/index.ts +10 -1
  51. package/plugins/publishings/wechatmp-pub/dist/index.js +94 -45
  52. package/plugins/publishings/x-pub/dist/index.js +94 -45
  53. package/plugins/publishings/xhs-pub/dist/index.js +8908 -229
  54. package/plugins/publishings/xhs-pub/package.json +1 -0
  55. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  56. package/plugins/publishings/xhs-pub/src/automation-guard.ts +128 -0
  57. package/plugins/publishings/xhs-pub/src/index.ts +753 -24
  58. package/plugins/publishings/xhs-pub/src/profile-check.ts +133 -0
  59. package/plugins/publishings/zenn-pub/dist/index.js +94 -45
  60. package/plugins/sp/lib/env.ts +46 -0
  61. package/plugins/sp/lib/inline-run.generated.mjs +1489 -0
  62. package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
  63. package/plugins/sp/scripts/inline-run-setup.ts +571 -47
  64. package/plugins/sp/scripts/wrapup-drift-probe.mjs +190 -0
  65. package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
  66. package/plugins/sp/scripts/wrapup-steps.mjs +77 -6
  67. package/plugins/sp/scripts/wrapup-steps.ts +91 -5
@@ -0,0 +1,19 @@
1
+ name: daily-news-cover
2
+ description: Daily AI news episode cover — fixed composition and palette; subject is the only per-run input
3
+ job: cover
4
+ aspectRatio: '21:9'
5
+ width: 1344
6
+ height: 576
7
+ defaultStyle: daily-news
8
+ keywords: [clean composition, high detail, consistent series look]
9
+ outputFilename: 'daily-news-cover.png'
10
+ variables:
11
+ subject:
12
+ description: One concrete visual scene for the day's lead story (objects, settings, symbols)
13
+ default: a glowing network of connected nodes rising above a quiet city at dawn
14
+ template: |-
15
+ Ultrawide editorial cover illustration for a daily AI news podcast episode.
16
+ Scene: {{subject | a glowing network of connected nodes rising above a quiet city at dawn}}.
17
+ Composition: one central focal subject, generous negative space in the upper third, uncluttered background.
18
+ Color palette: deep navy background with electric blue and warm amber accents.
19
+ Atmosphere: calm, intelligent, forward-looking.
@@ -0,0 +1,55 @@
1
+ name: xhs-card
2
+ description: >-
3
+ Xiaohongshu (XHS) knowledge card — 3:4 portrait image-text card with large
4
+ Chinese headline and short bullet points. Adapted from baoyu-xhs-images
5
+ (MIT © Jim Liu); see plugins/kk/skills/xhs-images/references/card-layouts.md.
6
+ job: free
7
+ aspectRatio: '3:4'
8
+ width: 1080
9
+ height: 1440
10
+ defaultStyle: xhs-knowledge
11
+ keywords:
12
+ - xiaohongshu-style
13
+ - image-text card layout
14
+ - large-font title (Chinese-friendly)
15
+ - fresh clean design
16
+ - flat illustration accents with geometric decorations
17
+ - crisp legible text hierarchy
18
+ - professional color grading
19
+ outputFilename: 'card-{{index | 01}}.png'
20
+ variables:
21
+ role:
22
+ description: Card role in the series (cover, content, closing)
23
+ default: content
24
+ layout:
25
+ description: Layout name from xhs-images references (see references/card-layouts.md)
26
+ default: definition
27
+ headline:
28
+ description: Short on-card headline (Chinese, ≤ 12 chars recommended)
29
+ default: 核心概念
30
+ points:
31
+ description: 2–4 short on-card bullet points, one per line
32
+ default: ''
33
+ subject:
34
+ description: Visual scene or metaphor illustrated on the card
35
+ default: clean flat illustration of the core concept
36
+ language:
37
+ description: On-card text language
38
+ default: 简体中文
39
+ index:
40
+ description: Card number in the series (zero-padded)
41
+ default: '01'
42
+ content:
43
+ description: Additional context (use --content)
44
+ default: ''
45
+ template: |-
46
+ Xiaohongshu-style knowledge card, portrait 3:4, for a card series.
47
+ Card role: {{role | content}}. Layout: {{layout | definition}}.
48
+ On-card headline in {{language | 简体中文}}, extra-large bold type: "{{headline}}".
49
+ Bullet points (short lines, generous line spacing, keep on-card text minimal):
50
+ {{points}}
51
+ Visual scene: {{subject | clean flat illustration of the core concept}},
52
+ rendered as flat illustration accents with subtle geometric decorations.
53
+ Fresh clean design, strong text hierarchy, comfortable margins, no watermark,
54
+ no extra UI chrome. 8K quality, crisp details, professional color grading.
55
+ {{content}}
@@ -0,0 +1,9 @@
1
+ name: daily-news
2
+ description: House style for the kk-daily-ai-voice cover series — flat editorial vector, navy/blue/amber
3
+ modifiers: flat editorial vector illustration, bold simple geometric shapes, subtle paper grain, soft even lighting, limited palette
4
+ palette:
5
+ - { role: background, name: deep navy, hex: '#0B1F3A' }
6
+ - { role: primary, name: electric blue, hex: '#2D7FF9' }
7
+ - { role: accent, name: warm amber, hex: '#F5A623' }
8
+ negative: text, letters, words, numbers, captions, watermark, logo, brand marks, flags, real people, recognizable faces, photorealism, clutter
9
+ source: { kind: authored }
@@ -0,0 +1,7 @@
1
+ name: xhs-knowledge
2
+ description: >-
3
+ Notion-like knowledge card aesthetic for XHS image-text series — simple light
4
+ background, clean information layout, soft accent colors, rounded card zones.
5
+ Adapted from baoyu-xhs-images "knowledge card" style guidance (MIT © Jim Liu);
6
+ see plugins/kk/skills/xhs-images/references/card-layouts.md.
7
+ modifiers: simple clean light background, rounded rectangle card zones, soft pastel accent colors, large legible Chinese typography, generous whitespace, flat geometric decorations, subtle drop shadows, fresh and professional
@@ -9,12 +9,22 @@ import {
9
9
  type ImageArtifact,
10
10
  type ImageGenMetadata,
11
11
  type ImageJobSpec,
12
+ type ProviderAttempt,
12
13
  parseJobSpec,
13
14
  readPositions,
14
15
  } from './job';
15
16
  import { artifactPath, outStemFor } from './paths';
16
17
  import { composePrompt, type FormatPreset, loadPresets, type StylePreset } from './presets';
17
- import { type ImageProvider, type ImageRequest, PROVIDERS, selectProvider } from './providers/types';
18
+ import {
19
+ classifyProviderError,
20
+ type ImageProvider,
21
+ type ImageRequest,
22
+ type ImageResult,
23
+ isFallbackEligible,
24
+ PROVIDERS,
25
+ resolveFallbackOrder,
26
+ selectProvider,
27
+ } from './providers/types';
18
28
 
19
29
  export * from './job';
20
30
  export * from './paths';
@@ -44,6 +54,16 @@ function defaultProviderResolver(spec: ImageJobSpec): ImageProvider {
44
54
  return registration.create();
45
55
  }
46
56
 
57
+ /** Build a fallback candidate from the registry; guards registry drift like defaultProviderResolver. */
58
+ function registeredProvider(name: string): ImageProvider {
59
+ const registration = PROVIDERS[name];
60
+ if (registration === undefined) {
61
+ // resolveFallbackOrder rejects unknown names before this runs; this guards drift only.
62
+ throw new Error(`Provider '${name}' was selected but is missing from the registry`);
63
+ }
64
+ return registration.create();
65
+ }
66
+
47
67
  /**
48
68
  * Budget cap resolution (R6): the spec value wins over the env, which wins over
49
69
  * the compiled default — the repo's flag > env > config > default rule, with
@@ -126,6 +146,35 @@ function composePrompts(
126
146
  return [composePrompt(format, style, vars, spec.extra)];
127
147
  }
128
148
 
149
+ /** Task 0169: validate the illustration anchor plan against the original body before any provider work. */
150
+ function assertAnchorPlan(body: string, positions: string[] | undefined, count: number): void {
151
+ const ranges: { anchor: string; start: number; end: number }[] = [];
152
+ for (let index = 0; index < count; index++) {
153
+ const anchor = positions?.[index];
154
+ if (anchor === undefined) {
155
+ throw new Error('Illustration anchor missing from metadata.positions');
156
+ }
157
+ const found = anchor === '' ? 0 : body.split(anchor).length - 1;
158
+ if (found !== 1) {
159
+ throw new Error(
160
+ `Illustration anchor must occur exactly once in the source body, found ${found}: '${anchor.slice(0, 50)}'`,
161
+ );
162
+ }
163
+ const start = body.indexOf(anchor);
164
+ ranges.push({ anchor, start, end: start + anchor.length });
165
+ }
166
+ // ponytail: O(n²) pair scan; n ≤ maxImages (default 4), so sorting is not worth it.
167
+ for (const [i, a] of ranges.entries()) {
168
+ for (const b of ranges.slice(i + 1)) {
169
+ if (a.start < b.end && b.start < a.end) {
170
+ throw new Error(
171
+ `Illustration anchors overlap in the source body: '${a.anchor.slice(0, 50)}' and '${b.anchor.slice(0, 50)}'`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+ }
177
+
129
178
  /**
130
179
  * Read validated `Doc[]` input and emit the image-gen `Content` (R1/R3/R6).
131
180
  *
@@ -139,7 +188,11 @@ function composePrompts(
139
188
  export async function processGeneratorIO(
140
189
  inputPath: string,
141
190
  outputPath: string,
142
- options: { providerResolver?: ProviderResolver } = {},
191
+ options: {
192
+ providerResolver?: ProviderResolver;
193
+ /** D6 0166: builds fallback candidates by name (tests inject stubs); defaults to the registry. */
194
+ createProvider?: (name: string) => ImageProvider;
195
+ } = {},
143
196
  ): Promise<void> {
144
197
  const fs = createNodeFileSystem();
145
198
  // voice-gen rule: start clean, and on any failure delete --out plus every
@@ -174,10 +227,44 @@ export async function processGeneratorIO(
174
227
  `Image count ${prompts.length} exceeds the budget cap ${cap} (raise ImageJobSpec.maxImages or KK_IMAGE_MAX_IMAGES)`,
175
228
  );
176
229
  }
230
+ if (spec.job === 'illustration') assertAnchorPlan(doc?.body ?? '', positions, prompts.length);
177
231
 
178
232
  // Selection must succeed (or fail loud naming the provider) even when nothing is spent.
179
233
  const resolveProvider: ProviderResolver = options.providerResolver ?? defaultProviderResolver;
180
- const provider = resolveProvider(spec);
234
+ const primary = resolveProvider(spec);
235
+
236
+ // D6 (0166): the fallback order is resolved once, before the dry-run branch, so a typo'd
237
+ // name fails with no spend on both branches. Candidates are created once through the
238
+ // cached `createProvider` seam; `skipReason` consults candidates only, never the primary.
239
+ const built = new Map<string, ImageProvider>();
240
+ const createCandidate = (name: string): ImageProvider => {
241
+ const cached = built.get(name);
242
+ if (cached !== undefined) {
243
+ return cached;
244
+ }
245
+ const created =
246
+ options.createProvider !== undefined ? options.createProvider(name) : registeredProvider(name);
247
+ built.set(name, created);
248
+ return created;
249
+ };
250
+ const skipReason = (name: string): string | undefined => {
251
+ const prospect = createCandidate(name);
252
+ if (!prospect.isAvailable()) {
253
+ return 'not configured';
254
+ }
255
+ if (
256
+ spec.referenceImages !== undefined &&
257
+ spec.referenceImages.length > 0 &&
258
+ !prospect.supportsReferenceImages(prospect.defaultModel())
259
+ ) {
260
+ return 'no reference-image support';
261
+ }
262
+ return undefined;
263
+ };
264
+ const { order, skipped } = resolveFallbackOrder(primary.name, spec.fallback, { skipReason });
265
+ for (const skip of skipped) {
266
+ echoError(`WARN image-gen: skipping fallback ${skip.provider} (${skip.reason})`);
267
+ }
181
268
 
182
269
  if (process.env.KK_IMAGE_DRY_RUN === '1') {
183
270
  // R6: compose every prompt, select the provider, spend nothing, write no image.
@@ -204,23 +291,69 @@ export async function processGeneratorIO(
204
291
  // The money-spending branch: one provider call per prompt, bytes written
205
292
  // beside `--out` via artifactPath (R1/R3 worded as 0104 R3), written[]
206
293
  // tracking keeps failure cleanup live (dead-loop advisory closed here).
294
+ // D6 (0166): the validate+generate pair rides the fallback loop. `at`
295
+ // advances stickily — an image that fell to candidate i keeps the later
296
+ // images there — and failed attempts never consume the budget cap,
297
+ // which counts produced images only.
207
298
  await fs.ensureDir(dirname(outputPath));
208
299
  const stem = outStemFor(outputPath, spec.outputStem);
209
300
  const artifacts: ImageArtifact[] = [];
210
301
  const captions: CaptionEntry[] = [];
302
+ const attempts: ProviderAttempt[] = [];
303
+ let at = 0; // sticky index into `order`
304
+ const generateWithFallback = async (image: number, prompt: string): Promise<ImageResult> => {
305
+ const start = attempts.length; // this image's failed attempts begin here (R5 summarises the slice)
306
+ for (;;) {
307
+ const name = order[at];
308
+ if (name === undefined) {
309
+ // Unreachable: `at` advances only after order[at + 1] was just proven defined.
310
+ throw new Error(`image-gen: fallback order exhausted before image ${image} was decided`);
311
+ }
312
+ const provider = at === 0 ? primary : createCandidate(name);
313
+ // spec.model is provider-specific (D6): only the requested provider receives it.
314
+ const model = at === 0 ? (spec.model ?? provider.defaultModel()) : provider.defaultModel();
315
+ const request: ImageRequest = {
316
+ prompt,
317
+ model,
318
+ aspectRatio: format.aspectRatio,
319
+ ...(spec.referenceImages !== undefined && spec.referenceImages.length > 0
320
+ ? { referenceImages: spec.referenceImages }
321
+ : {}),
322
+ };
323
+ try {
324
+ provider.validate?.(request);
325
+ return await provider.generate(request);
326
+ } catch (err: unknown) {
327
+ if (spec.fallback === undefined) {
328
+ throw err; // D6 R1: strict mode propagates the original error, unwrapped.
329
+ }
330
+ const cls = classifyProviderError(err);
331
+ const message = err instanceof Error ? err.message : String(err);
332
+ if (!isFallbackEligible(cls)) {
333
+ // D6 R4: a rejected prompt is never re-asked to a second vendor.
334
+ throw new Error(
335
+ `image-gen: ${provider.name} rejected the prompt (policy): ${message}; fallback does not apply`,
336
+ );
337
+ }
338
+ attempts.push({ image, provider: provider.name, model, errorClass: cls, message });
339
+ const next = order[at + 1];
340
+ if (next === undefined) {
341
+ // D6 R5: every candidate failed — name each with its class, then the last message.
342
+ const detail = attempts
343
+ .slice(start)
344
+ .map((attempt) => `${attempt.provider} (${attempt.errorClass})`)
345
+ .join(', ');
346
+ throw new Error(`image-gen: all providers failed for image ${image}: ${detail}; ${message}`);
347
+ }
348
+ echoError(`WARN image-gen: ${provider.name} failed (${cls}): ${message}; falling back to ${next}`);
349
+ at += 1;
350
+ }
351
+ }
352
+ };
211
353
  let body = doc?.body ?? '';
212
354
  for (const [index, prompt] of prompts.entries()) {
213
- const request: ImageRequest = {
214
- prompt,
215
- model: spec.model ?? provider.defaultModel(),
216
- aspectRatio: format.aspectRatio,
217
- ...(spec.referenceImages && spec.referenceImages.length > 0
218
- ? { referenceImages: spec.referenceImages }
219
- : {}),
220
- };
221
- provider.validate?.(request);
222
- const result = await provider.generate(request);
223
- // Illustration: the anchor must occur exactly once before we spend image 1.
355
+ const result = await generateWithFallback(index + 1, prompt);
356
+ // Re-check against the spliced body; the pre-spend plan check is assertAnchorPlan (0169).
224
357
  let anchor: string | undefined;
225
358
  if (spec.job === 'illustration') {
226
359
  anchor = positions?.[index];
@@ -271,6 +404,7 @@ export async function processGeneratorIO(
271
404
  images: artifacts,
272
405
  ...(spec.job !== 'free' && doc?.id !== undefined ? { sourceDocId: doc.id } : {}),
273
406
  ...(captions.length > 0 ? { captions } : {}),
407
+ ...(attempts.length > 0 ? { attempts } : {}),
274
408
  };
275
409
  const content = ContentSchema.parse({
276
410
  // free/cover: the body is the prompt trail (same as dry run); illustration: the
@@ -1,5 +1,6 @@
1
1
  import type { Doc } from '@gobing-ai/kk-core';
2
2
  import { z } from 'zod';
3
+ import type { ProviderErrorClass } from './providers/errors';
3
4
 
4
5
  /**
5
6
  * Task 0104 — the frozen `--in` envelope. A plugin's only input channel is the
@@ -25,6 +26,13 @@ export interface ImageJobSpec {
25
26
  extra?: string;
26
27
  /** 0103 selectProvider's `requested` name. */
27
28
  provider?: string;
29
+ /**
30
+ * D6 runtime fallback (0165). Absent = strict: one provider, fail fast. `true` = on a
31
+ * fallback-eligible error retry down AUTO_ORDER (minus NEVER_AUTO). A list = retry in exactly
32
+ * that order. Name validation lives in resolveFallbackOrder — this file must not import the
33
+ * provider registry (it pulls in every adapter).
34
+ */
35
+ fallback?: true | string[];
28
36
  model?: string;
29
37
  /** Absolute paths (0105 pass-through). */
30
38
  referenceImages?: string[];
@@ -45,6 +53,8 @@ export const ImageJobSpecSchema = z.strictObject({
45
53
  vars: z.record(z.string(), z.string()).optional(),
46
54
  extra: z.string().optional(),
47
55
  provider: z.string().optional(),
56
+ /** Absent = strict; `true` or a non-empty list of non-empty strings. `false`/`[]` fail so "off" has one spelling. */
57
+ fallback: z.union([z.literal(true), z.array(z.string().min(1)).min(1)]).optional(),
48
58
  model: z.string().optional(),
49
59
  referenceImages: z.array(z.string()).optional(),
50
60
  count: z.number().int().positive().optional(),
@@ -80,6 +90,24 @@ export interface CaptionEntry {
80
90
  anchor: string;
81
91
  }
82
92
 
93
+ /**
94
+ * One failed provider try, recorded by the D6 fallback loop (task 0166). The classifier class lives
95
+ * in `./providers/errors` — a leaf module with no imports — so this file stays clear of the
96
+ * provider registry (which pulls in every adapter).
97
+ */
98
+ export interface ProviderAttempt {
99
+ /** 1-based image number in the run (same scale as `captions[].index` and the `-NN` artefact suffix). */
100
+ image: number;
101
+ /** The provider whose validate/generate call failed. */
102
+ provider: string;
103
+ /** The model the failed call sent (`spec.model` on the requested provider, else its default). */
104
+ model: string;
105
+ /** The 0164 classification that drove the retry decision. */
106
+ errorClass: ProviderErrorClass;
107
+ /** The failed call's own error message, unwrapped. */
108
+ message: string;
109
+ }
110
+
83
111
  /** Typed shape of this plugin's `Content.metadata` (frozen keys, 0104 Design). */
84
112
  export interface ImageGenMetadata {
85
113
  generator: 'kk:image-gen';
@@ -89,6 +117,8 @@ export interface ImageGenMetadata {
89
117
  sourceDocId?: string;
90
118
  /** Illustration only: the captions payload. */
91
119
  captions?: CaptionEntry[];
120
+ /** D6 fallback (0166): every failed provider try in attempt order; present only when non-empty. */
121
+ attempts?: ProviderAttempt[];
92
122
  /** Set only when the run spent nothing (R6 dry run). */
93
123
  dryRun?: true;
94
124
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Provider error classifier for image-gen runtime fallback (task 0164, feature D6).
3
+ *
4
+ * Leaf module — no imports, like `timeouts.ts`. `types.ts` builds the `PROVIDERS` registry from
5
+ * value imports of every adapter, so anything a provider needs outside that cycle must live in its
6
+ * own leaf; task 0166's retry loop (and any adapter later) can import this one safely.
7
+ *
8
+ * The adapters throw plain `Error`s with inconsistent messages: some carry a status
9
+ * (`seedream HTTP 401: …`, `MiniMax API error (429): …`, `Google API error (403): …`,
10
+ * `HuggingFace model loading (HTTP 503): …`), some carry none (`OpenAI API error: <body>`),
11
+ * some name a missing key (`… requires ARK_API_KEY`), and request timeouts abort through
12
+ * `AbortSignal` (see `timeouts.ts`) as DOMExceptions named `TimeoutError`/`AbortError`. Rather
13
+ * than retyping all 13 adapters to a typed error (rejected in the task Q&A), the classifier reads
14
+ * `err.name` plus the message, parsing statuses from the three shapes in use: `(NNN)`, `HTTP NNN`,
15
+ * and a trailing `: NNN`. Test fixtures copy real adapter throw strings, so wording drift in an
16
+ * adapter shows up as a test to update.
17
+ *
18
+ * First matching row wins, policy first: content-policy rejections often arrive as HTTP 400/403,
19
+ * and they must never trigger a fallback (D6 R4). `unknown` stays fallback-eligible — most
20
+ * unknowns are provider-specific request constraints (validate() size/ratio rules, "no image
21
+ * data") another provider may satisfy (task Q&A).
22
+ */
23
+
24
+ /** The retry decision classes the D6 fallback loop (0166) acts on. */
25
+ export type ProviderErrorClass = 'auth' | 'quota' | 'transient' | 'policy' | 'unknown';
26
+
27
+ /** Content-policy / safety / moderation rejections — checked first, they often arrive as 400/403. */
28
+ const POLICY_RE = /content[_ -]?policy|moderation|safety|sensitive|inappropriate|violat|blocked/i;
29
+ /** Credential wording; 401/403 statuses are checked separately. */
30
+ const AUTH_RE = /api[_ -]?key|unauthori[sz]ed|authenticat|forbidden|requires [A-Z_]+|is required/i;
31
+ /** Quota / spend / rate-limit wording; 402/429 statuses are checked separately. */
32
+ const QUOTA_RE = /quota|rate[_ -]?limit|insufficient|balance|billing|exceeded|too many requests/i;
33
+ /** Network / timeout wording (the `err.name` TimeoutError/AbortError check is separate). */
34
+ const TRANSIENT_RE = /timed? ?out|ECONNRESET|ETIMEDOUT|ECONNREFUSED|fetch failed|socket|model loading/i;
35
+
36
+ /** Abort names produced by `AbortSignal.timeout` and manual aborts (DOMException in Bun and Node). */
37
+ const TRANSIENT_NAMES: ReadonlySet<string> = new Set(['TimeoutError', 'AbortError']);
38
+
39
+ /**
40
+ * HTTP statuses carried by the message, parsed from the three shapes adapters use: `(NNN)`
41
+ * (`MiniMax API error (429): …`), `HTTP NNN` (`seedream HTTP 401: …`), and a trailing `: NNN`
42
+ * (`Failed to download image from MiniMax: 401`). Response bodies and provider-specific codes are
43
+ * deliberately not statuses.
44
+ */
45
+ function statusesIn(message: string): number[] {
46
+ const statuses: number[] = [];
47
+ for (const match of message.matchAll(/\((\d{3})\)|HTTP\s+(\d{3})\b|:\s*(\d{3})\s*$/gi)) {
48
+ const status = match[1] ?? match[2] ?? match[3];
49
+ if (status !== undefined) statuses.push(Number(status));
50
+ }
51
+ return statuses;
52
+ }
53
+
54
+ /** Duck-typed read of `name`/`message`; never throws, and non-object values have neither. */
55
+ function partsOf(err: unknown): { readonly name: string; readonly message: string } {
56
+ if (typeof err !== 'object' || err === null) return { name: '', message: '' };
57
+ const candidate = err as { readonly name?: unknown; readonly message?: unknown };
58
+ return {
59
+ name: typeof candidate.name === 'string' ? candidate.name : '',
60
+ message: typeof candidate.message === 'string' ? candidate.message : '',
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Classify a thrown value for the D6 fallback decision. Pure: no I/O, no env, no provider imports.
66
+ * Row order is load-bearing — policy → auth → quota → transient → unknown — and the function never
67
+ * throws: non-object values and objects without string `name`/`message` classify as `unknown`.
68
+ */
69
+ export function classifyProviderError(err: unknown): ProviderErrorClass {
70
+ const parts = partsOf(err);
71
+ const statuses = statusesIn(parts.message);
72
+ if (POLICY_RE.test(parts.message)) return 'policy';
73
+ if (statuses.some((status) => status === 401 || status === 403) || AUTH_RE.test(parts.message)) return 'auth';
74
+ if (statuses.some((status) => status === 402 || status === 429) || QUOTA_RE.test(parts.message)) return 'quota';
75
+ if (
76
+ TRANSIENT_NAMES.has(parts.name) ||
77
+ statuses.some((status) => status === 408 || (status >= 500 && status <= 599)) ||
78
+ TRANSIENT_RE.test(parts.message)
79
+ ) {
80
+ return 'transient';
81
+ }
82
+ return 'unknown';
83
+ }
84
+
85
+ /**
86
+ * Whether the D6 fallback loop may move to the next provider after this class. Only `policy` is
87
+ * never eligible: a rejected prompt must not be re-asked to a second vendor (D6 R4 / AC1).
88
+ */
89
+ export function isFallbackEligible(cls: ProviderErrorClass): boolean {
90
+ return cls !== 'policy';
91
+ }
@@ -69,6 +69,14 @@ export interface ImageProvider {
69
69
  generate(req: ImageRequest): Promise<ImageResult>;
70
70
  }
71
71
 
72
+ /**
73
+ * Provider error classification for the runtime fallback loop (task 0164; consumed by 0166).
74
+ * Declared in `./errors`, a leaf module like `./timeouts`, so adapters can classify throw sites
75
+ * later without entering the registry import cycle.
76
+ */
77
+ export type { ProviderErrorClass } from './errors';
78
+ export { classifyProviderError, isFallbackEligible } from './errors';
79
+
72
80
  /** Injection seam for tests — mirrors `VoiceboxClientOptions.fetch` (voice-gen). */
73
81
  /**
74
82
  * Default per-request timeout for every image-gen provider HTTP call. Defined in `./timeouts` (a
@@ -294,3 +302,74 @@ export function selectProvider(
294
302
  .join('; ');
295
303
  throw new Error(`No image provider is configured. Credentials wanted — ${wanted}`);
296
304
  }
305
+
306
+ /**
307
+ * D6 runtime fallback (task 0165) — the envelope's `fallback` field as the schema admits it:
308
+ * absent (strict), `true` (AUTO_ORDER), or an explicit ordered list. Shape-only; name validation
309
+ * happens in {@link resolveFallbackOrder}, because `job.ts` must not import this registry.
310
+ */
311
+ export type FallbackSpec = true | readonly string[] | undefined;
312
+
313
+ /** The ordered candidate list the retry loop (task 0166) walks, plus every dropped candidate. */
314
+ export interface FallbackOrder {
315
+ /** Invariant: non-empty, no duplicates, `order[0] === requested`. */
316
+ order: string[];
317
+ /** Candidates dropped because `skipReason` returned a string; never contains `requested`. */
318
+ skipped: { provider: string; reason: string }[];
319
+ }
320
+
321
+ /**
322
+ * Turn the envelope's `fallback` field into the candidate order for the retry loop (task 0166).
323
+ * Pure: availability and reference support arrive through `skipReason`, which 0166 builds from
324
+ * provider instances — nothing here constructs or calls a provider. Behaviour:
325
+ *
326
+ * - absent → `[requested]` (strict, today's fail-fast).
327
+ * - `true` → `requested`, then AUTO_ORDER minus NEVER_AUTO minus `requested`, in AUTO_ORDER order.
328
+ * - list → `requested`, then the list in the given order, deduped and minus `requested`.
329
+ *
330
+ * An unknown name in a list throws before any provider call, because a typo must fail loud, not
331
+ * silently shrink the chain. An explicitly listed NEVER_AUTO provider is allowed: NEVER_AUTO
332
+ * governs automatic choice only (mirrors selectProvider's explicit pin rule). `skipReason` is
333
+ * consulted for candidates only — never `requested`, so `order` always keeps the already-selected
334
+ * provider first.
335
+ */
336
+ export function resolveFallbackOrder(
337
+ requested: string,
338
+ fallback: FallbackSpec,
339
+ options?: { skipReason?: (name: string) => string | undefined },
340
+ ): FallbackOrder {
341
+ if (fallback === undefined) {
342
+ return { order: [requested], skipped: [] };
343
+ }
344
+
345
+ let candidates: readonly string[];
346
+ if (fallback === true) {
347
+ candidates = AUTO_ORDER.filter((name) => !NEVER_AUTO.has(name) && name !== requested);
348
+ } else {
349
+ const names = Object.keys(PROVIDERS).sort();
350
+ const seen = new Set<string>([requested]);
351
+ const listed: string[] = [];
352
+ for (const name of fallback) {
353
+ if (PROVIDERS[name] === undefined) {
354
+ throw new Error(`Unknown fallback provider '${name}'. Valid providers: ${names.join(', ')}`);
355
+ }
356
+ if (!seen.has(name)) {
357
+ seen.add(name);
358
+ listed.push(name);
359
+ }
360
+ }
361
+ candidates = listed;
362
+ }
363
+
364
+ const order = [requested];
365
+ const skipped: { provider: string; reason: string }[] = [];
366
+ for (const name of candidates) {
367
+ const reason = options?.skipReason?.(name);
368
+ if (reason === undefined) {
369
+ order.push(name);
370
+ } else {
371
+ skipped.push({ provider: name, reason });
372
+ }
373
+ }
374
+ return { order, skipped };
375
+ }