@gobing-ai/knowledge-kit 0.0.12 → 0.0.13

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 (66) hide show
  1. package/dist/index.js +100 -26
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/dist/index.js +22167 -0
  4. package/plugins/generations/content-gen/plugin.json +1 -1
  5. package/plugins/generations/core-facts-gen/dist/index.js +22048 -0
  6. package/plugins/generations/core-facts-gen/plugin.json +1 -1
  7. package/plugins/generations/daily-article-gen/dist/index.js +22022 -0
  8. package/plugins/generations/daily-article-gen/plugin.json +1 -1
  9. package/plugins/generations/dailynews-gen/dist/index.js +22324 -0
  10. package/plugins/generations/dailynews-gen/plugin.json +1 -1
  11. package/plugins/generations/episode-plan-gen/dist/index.js +22479 -0
  12. package/plugins/generations/episode-plan-gen/plugin.json +1 -1
  13. package/plugins/generations/image-gen/config.example.yaml +75 -0
  14. package/plugins/generations/image-gen/dist/index.js +22619 -0
  15. package/plugins/generations/image-gen/package.json +17 -0
  16. package/plugins/generations/image-gen/plugin.json +7 -0
  17. package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
  18. package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
  19. package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
  20. package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
  21. package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
  22. package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
  23. package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
  24. package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
  25. package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
  26. package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
  27. package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
  28. package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
  29. package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
  30. package/plugins/generations/image-gen/src/bytes.ts +19 -0
  31. package/plugins/generations/image-gen/src/index.ts +319 -0
  32. package/plugins/generations/image-gen/src/job.ts +143 -0
  33. package/plugins/generations/image-gen/src/paths.ts +31 -0
  34. package/plugins/generations/image-gen/src/presets.ts +344 -0
  35. package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
  36. package/plugins/generations/image-gen/src/providers/types.ts +285 -0
  37. package/plugins/generations/image-gen/tsconfig.json +8 -0
  38. package/plugins/generations/voice-gen/dist/index.js +23027 -0
  39. package/plugins/generations/voice-gen/plugin.json +1 -1
  40. package/plugins/ingestions/aihot-ingest/dist/index.js +22358 -0
  41. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  42. package/plugins/ingestions/horizon-ingest/dist/index.js +22105 -0
  43. package/plugins/ingestions/horizon-ingest/plugin.json +1 -1
  44. package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
  45. package/plugins/ingestions/karakeep-local/plugin.json +1 -1
  46. package/plugins/ingestions/last30days-ingest/dist/index.js +22050 -0
  47. package/plugins/ingestions/last30days-ingest/plugin.json +1 -1
  48. package/plugins/ingestions/web-search/dist/index.js +24379 -0
  49. package/plugins/ingestions/web-search/plugin.json +1 -1
  50. package/plugins/kk/commands/image-generate.md +15 -0
  51. package/plugins/kk/config.example.yaml +80 -0
  52. package/plugins/kk/skills/image-authoring/SKILL.md +250 -0
  53. package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
  54. package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
  55. package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
  56. package/plugins/kk/skills/image-authoring/references/style-extraction.md +134 -0
  57. package/plugins/publishings/emdash-pub/dist/index.js +22243 -0
  58. package/plugins/publishings/emdash-pub/plugin.json +1 -1
  59. package/plugins/publishings/podcast-pub/dist/index.js +22596 -0
  60. package/plugins/publishings/podcast-pub/plugin.json +8 -2
  61. package/plugins/publishings/qiita-pub/dist/index.js +22081 -0
  62. package/plugins/publishings/qiita-pub/plugin.json +1 -1
  63. package/plugins/publishings/surfdash-pub/dist/index.js +22233 -0
  64. package/plugins/publishings/surfdash-pub/plugin.json +1 -1
  65. package/plugins/publishings/zenn-pub/dist/index.js +22122 -0
  66. package/plugins/publishings/zenn-pub/plugin.json +1 -1
@@ -0,0 +1,143 @@
1
+ import type { Doc } from '@gobing-ai/kk-core';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Task 0104 — the frozen `--in` envelope. A plugin's only input channel is the
6
+ * `--in` JSON (the spawn argv is frozen at `--in`/`--out`,
7
+ * `apps/cli/src/invoke.ts:118-123`), so exactly one job spec rides on the
8
+ * first document's `metadata.imageGen`, and the array itself is the content.
9
+ */
10
+
11
+ /** The three v1 job branches; a resolved format preset's `job` field must match. */
12
+ export const IMAGE_JOBS = ['free', 'cover', 'illustration'] as const;
13
+ export type ImageJob = (typeof IMAGE_JOBS)[number];
14
+
15
+ /** Job spec parsed from `docs[0].metadata.imageGen` (frozen 0104 Design). */
16
+ export interface ImageJobSpec {
17
+ job: ImageJob;
18
+ /** FormatPreset name (0102); default per job is the job's own preset name. */
19
+ format?: string;
20
+ /** StylePreset name; default = format.defaultStyle. */
21
+ style?: string;
22
+ /** Template variables (0102 composePrompt); caller values win over preset defaults. */
23
+ vars?: Record<string, string>;
24
+ /** Free text appended as composePrompt's 4th segment. */
25
+ extra?: string;
26
+ /** 0103 selectProvider's `requested` name. */
27
+ provider?: string;
28
+ model?: string;
29
+ /** Absolute paths (0105 pass-through). */
30
+ referenceImages?: string[];
31
+ /** Illustration only: images to produce; default 1. */
32
+ count?: number;
33
+ /** Budget cap (R6); wins over KK_IMAGE_MAX_IMAGES. */
34
+ maxImages?: number;
35
+ seed?: number;
36
+ /** Overrides the `--out` basename for artefact names. */
37
+ outputStem?: string;
38
+ }
39
+
40
+ /** Strict on purpose: the envelope is frozen and 0106 writes it — a typo'd key must fail loud, not vanish. */
41
+ export const ImageJobSpecSchema = z.strictObject({
42
+ job: z.enum(IMAGE_JOBS),
43
+ format: z.string().optional(),
44
+ style: z.string().optional(),
45
+ vars: z.record(z.string(), z.string()).optional(),
46
+ extra: z.string().optional(),
47
+ provider: z.string().optional(),
48
+ model: z.string().optional(),
49
+ referenceImages: z.array(z.string()).optional(),
50
+ count: z.number().int().positive().optional(),
51
+ maxImages: z.number().int().positive().optional(),
52
+ seed: z.number().int().optional(),
53
+ outputStem: z.string().optional(),
54
+ });
55
+
56
+ /** One generated image inside `Content.metadata.images` (frozen 0104 Design). */
57
+ export interface ImageArtifact {
58
+ /** Absolute; always a sibling of `--out`. */
59
+ path: string;
60
+ /** FormatPreset name. */
61
+ format: string;
62
+ /** StylePreset name. */
63
+ style: string;
64
+ provider: string;
65
+ model: string;
66
+ /** The exact prompt string sent. */
67
+ prompt: string;
68
+ seed?: number;
69
+ width?: number;
70
+ height?: number;
71
+ bytes: number;
72
+ }
73
+
74
+ /** One illustration caption, the wt captions.json payload reduced to what consumers read (R4). */
75
+ export interface CaptionEntry {
76
+ index: number;
77
+ path: string;
78
+ caption: string;
79
+ altText: string;
80
+ anchor: string;
81
+ }
82
+
83
+ /** Typed shape of this plugin's `Content.metadata` (frozen keys, 0104 Design). */
84
+ export interface ImageGenMetadata {
85
+ generator: 'kk:image-gen';
86
+ job: ImageJob;
87
+ images: ImageArtifact[];
88
+ /** Illustration/cover: the input doc's id — the original article is never mutated. */
89
+ sourceDocId?: string;
90
+ /** Illustration only: the captions payload. */
91
+ captions?: CaptionEntry[];
92
+ /** Set only when the run spent nothing (R6 dry run). */
93
+ dryRun?: true;
94
+ }
95
+
96
+ function jobList(): string {
97
+ return IMAGE_JOBS.map((job) => `'${job}'`).join(', ');
98
+ }
99
+
100
+ function zodIssues(error: z.ZodError): string {
101
+ return error.issues.map((issue) => `${issue.path.join('.') || 'spec'}: ${issue.message}`).join('; ');
102
+ }
103
+
104
+ /**
105
+ * Parse the job spec from `docs[0].metadata.imageGen`; fail loud, naming the
106
+ * frozen path and the valid `job` values, before any preset or provider work.
107
+ */
108
+ export function parseJobSpec(docs: Doc[]): ImageJobSpec {
109
+ const raw = docs[0]?.metadata?.imageGen;
110
+ if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
111
+ throw new Error(
112
+ `Invalid image job spec at metadata.imageGen: expected an object with a "job" of ${jobList()}, received ${
113
+ raw === undefined ? 'nothing' : JSON.stringify(raw)
114
+ }`,
115
+ );
116
+ }
117
+ const result = ImageJobSpecSchema.safeParse(raw);
118
+ if (!result.success) {
119
+ throw new Error(`Invalid image job spec at metadata.imageGen: ${zodIssues(result.error)}`);
120
+ }
121
+ return result.data;
122
+ }
123
+
124
+ const PositionsSchema = z.array(z.string());
125
+
126
+ /**
127
+ * Read `docs[0].metadata.positions` — the caller's (0106) chosen insertion
128
+ * anchors for the illustration job. Shape-only here; the anchor-occurs-once
129
+ * check against the body belongs to the illustration branch.
130
+ */
131
+ export function readPositions(docs: Doc[]): string[] | undefined {
132
+ const raw = docs[0]?.metadata?.positions;
133
+ if (raw === undefined) {
134
+ return undefined;
135
+ }
136
+ const result = PositionsSchema.safeParse(raw);
137
+ if (!result.success) {
138
+ throw new Error(
139
+ `Invalid docs[0].metadata.positions: expected an array of string anchors: ${zodIssues(result.error)}`,
140
+ );
141
+ }
142
+ return result.data;
143
+ }
@@ -0,0 +1,31 @@
1
+ import { basename, dirname, extname, resolve } from 'node:path';
2
+
3
+ /**
4
+ * Task 0104 R3 — artefacts are siblings of `--out` (the voice-gen rule): the
5
+ * invoker expands `works_dir` before building `--out`, and a plugin's cwd is
6
+ * its own package dir, so every path resolves against `--out`'s directory —
7
+ * never `process.cwd()`.
8
+ */
9
+
10
+ /** `ImageJobSpec.outputStem` ?? the `--out` basename without extension. */
11
+ export function outStemFor(outPath: string, override?: string): string {
12
+ return override ?? basename(outPath, extname(outPath));
13
+ }
14
+
15
+ /**
16
+ * `<outDir>/<stem>-<format>-<NN>.<ext>` — NN zero-padded from 01, so a
17
+ * deterministic re-run against the same `--out` overwrites instead of appending.
18
+ *
19
+ * 0107 R5: stem and extension guards keep a hostile spec/provider from escaping
20
+ * `--out`'s directory — this is the single choke point for all artefact paths.
21
+ */
22
+ export function artifactPath(outPath: string, stem: string, format: string, index: number, extension: string): string {
23
+ if (stem.includes('/') || stem.includes('\\')) {
24
+ throw new Error(`Artefact stem must not contain path separators: '${stem}'`);
25
+ }
26
+ if (!/^[a-z0-9]{1,5}$/i.test(extension)) {
27
+ throw new Error(`Artefact extension must be a short alphanumeric segment: '${extension}'`);
28
+ }
29
+ const nn = String(index).padStart(2, '0');
30
+ return resolve(dirname(outPath), `${stem}-${format}-${nn}.${extension}`);
31
+ }
@@ -0,0 +1,344 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * Image preset contract (task 0102): the format × style preset schemas, the
8
+ * frozen prompt-composition rule (R2), and multi-root resolution with
9
+ * shadowing (R4), mirroring ADR-012's plugin discovery
10
+ * (`docs/design/multi-root-plugin-discovery.md`).
11
+ */
12
+
13
+ /** One colour entry in a structured (baoyu-style) palette. */
14
+ export interface PaletteEntry {
15
+ role: string;
16
+ name: string;
17
+ hex: string;
18
+ }
19
+
20
+ /** Provenance of a style preset; 0105 populates it after extraction. */
21
+ export interface PresetSource {
22
+ kind: 'authored' | 'extracted';
23
+ refImages?: string[];
24
+ extractedAt?: string;
25
+ confidence?: 'high' | 'medium' | 'low';
26
+ }
27
+
28
+ /** One declared template variable: caller-facing description plus default. */
29
+ export interface FormatVariable {
30
+ description: string;
31
+ default: string;
32
+ }
33
+
34
+ /** R1 — `formats/<name>.yaml`: the job skeleton (template, keywords, variables). */
35
+ export interface FormatPreset {
36
+ /** Filename stem must equal this. */
37
+ name: string;
38
+ description: string;
39
+ /** Which plugin job branch this format drives (0104). */
40
+ job: 'free' | 'cover' | 'illustration';
41
+ /** Portable primary dimension; width/height are the optional exact pixels. */
42
+ aspectRatio: string;
43
+ width?: number;
44
+ height?: number;
45
+ /** Style used when the caller names none. */
46
+ defaultStyle: string;
47
+ /** Quality/composition tail, joined with ', '. */
48
+ keywords: string[];
49
+ /** Same placeholder grammar as the template, e.g. '{{title | cover}}.png'. */
50
+ outputFilename: string;
51
+ variables: Record<string, FormatVariable>;
52
+ /** Prompt body; YAML block scalar, embedded newlines preserved. */
53
+ template: string;
54
+ }
55
+
56
+ /** R1 — `styles/<name>.yaml`; `modifiers` is the only required prompt field. */
57
+ export interface StylePreset {
58
+ name: string;
59
+ description: string;
60
+ modifiers: string;
61
+ palette?: PaletteEntry[];
62
+ typography?: string;
63
+ visualElements?: string[];
64
+ bestFor?: string;
65
+ /** Provider negative prompt when supported, else appended as 'avoid: …'. */
66
+ negative?: string;
67
+ source?: PresetSource;
68
+ }
69
+
70
+ const PaletteEntrySchema = z.object({ role: z.string(), name: z.string(), hex: z.string() });
71
+
72
+ const PresetSourceSchema = z.object({
73
+ kind: z.enum(['authored', 'extracted']),
74
+ refImages: z.array(z.string()).optional(),
75
+ extractedAt: z.string().optional(),
76
+ confidence: z.enum(['high', 'medium', 'low']).optional(),
77
+ });
78
+
79
+ /** Parse gate for `formats/<name>.yaml`. */
80
+ export const FormatPresetSchema = z.object({
81
+ name: z.string(),
82
+ description: z.string(),
83
+ job: z.enum(['free', 'cover', 'illustration']),
84
+ aspectRatio: z.string(),
85
+ width: z.number().optional(),
86
+ height: z.number().optional(),
87
+ defaultStyle: z.string(),
88
+ keywords: z.array(z.string()),
89
+ outputFilename: z.string(),
90
+ variables: z.record(z.string(), z.object({ description: z.string(), default: z.string() })),
91
+ template: z.string(),
92
+ });
93
+
94
+ /** Parse gate for `styles/<name>.yaml`. */
95
+ export const StylePresetSchema = z.object({
96
+ name: z.string(),
97
+ description: z.string(),
98
+ modifiers: z.string(),
99
+ palette: z.array(PaletteEntrySchema).optional(),
100
+ typography: z.string().optional(),
101
+ visualElements: z.array(z.string()).optional(),
102
+ bestFor: z.string().optional(),
103
+ negative: z.string().optional(),
104
+ source: PresetSourceSchema.optional(),
105
+ });
106
+
107
+ /**
108
+ * Merge caller vars over the format's declared defaults; the caller wins.
109
+ */
110
+ export function mergeVars(
111
+ declared: Record<string, FormatVariable>,
112
+ vars: Record<string, string>,
113
+ ): Record<string, string> {
114
+ const merged: Record<string, string> = {};
115
+ for (const [name, def] of Object.entries(declared)) {
116
+ merged[name] = def.default;
117
+ }
118
+ for (const [name, value] of Object.entries(vars)) {
119
+ merged[name] = value;
120
+ }
121
+ return merged;
122
+ }
123
+
124
+ /**
125
+ * Match one `{{ … }}` expression with brace-depth counting, so a fallback
126
+ * that itself contains a placeholder survives whole. wt's `[^}]+` regex
127
+ * truncates at the first inner `}` (`{{subtitle | , subtitled
128
+ * "{{subtitle}}"}}` lost its tail); the R6 golden prompt pins that form as
129
+ * rendering correctly, so the port must balance the braces.
130
+ */
131
+ function matchPlaceholder(template: string, start: number): { expr: string; end: number } | undefined {
132
+ let depth = 1;
133
+ for (let i = start + 2; i < template.length; i++) {
134
+ if (template.startsWith('{{', i)) {
135
+ depth++;
136
+ i++;
137
+ } else if (template.startsWith('}}', i)) {
138
+ depth--;
139
+ if (depth === 0) {
140
+ return { expr: template.slice(start + 2, i), end: i + 2 };
141
+ }
142
+ i++;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ /**
149
+ * Resolve one expression against the merged vars. An empty value (including
150
+ * a caller-passed '') resolves to the fallback; an unknown placeholder with
151
+ * no fallback throws, naming the placeholder and the format — wt silently
152
+ * left it in place, and a literal `{{title}}` must not reach a paid provider.
153
+ * Leading trim on the fallback skips the `| ` separator; its trailing space
154
+ * is preserved (the R6 golden prompt pins it). Single pass: a fallback is
155
+ * emitted literally and never re-scanned, like wt's `re.sub`.
156
+ */
157
+ function resolvePlaceholder(expr: string, vars: Record<string, string>, formatName: string): string {
158
+ const pipe = expr.indexOf('|');
159
+ if (pipe !== -1) {
160
+ const name = expr.slice(0, pipe).trim();
161
+ const fallback = expr.slice(pipe + 1).trimStart();
162
+ const value = vars[name];
163
+ if (!value) {
164
+ return fallback;
165
+ }
166
+ // A fallback that embeds its own '{{name}}' slot is a mini-template
167
+ // wrapping the value (the R6 golden's ', subtitled "…"' form); a
168
+ // plain-text fallback is just the default, so the value wins.
169
+ return fallback.includes(`{{${name}}}`) ? fallback.split(`{{${name}}}`).join(value) : value;
170
+ }
171
+ const name = expr.trim();
172
+ const value = vars[name];
173
+ if (value === undefined) {
174
+ throw new Error(`Format "${formatName}": placeholder {{${name}}} has no value and no fallback`);
175
+ }
176
+ return value;
177
+ }
178
+
179
+ /**
180
+ * Render `{{name}}` / `{{name | fallback}}` (wt's two-form grammar,
181
+ * `template_engine.py` `_substitute_variables`).
182
+ */
183
+ export function renderTemplate(template: string, vars: Record<string, string>, formatName: string): string {
184
+ let rendered = '';
185
+ let cursor = 0;
186
+ while (cursor < template.length) {
187
+ const start = template.indexOf('{{', cursor);
188
+ if (start === -1) {
189
+ rendered += template.slice(cursor);
190
+ break;
191
+ }
192
+ const match = matchPlaceholder(template, start);
193
+ if (match === undefined) {
194
+ rendered += template.slice(cursor, start + 2);
195
+ cursor = start + 2;
196
+ continue;
197
+ }
198
+ rendered += template.slice(cursor, start);
199
+ rendered += resolvePlaceholder(match.expr, vars, formatName);
200
+ cursor = match.end;
201
+ }
202
+ return rendered;
203
+ }
204
+
205
+ /**
206
+ * R2 — the frozen composition rule, wt's order as built
207
+ * (`template_engine.py:174-190`): rendered body → keywords → style modifiers
208
+ * → caller extra, empty segments dropped. Keeping wt's order makes the ported
209
+ * presets render byte-identically to their wt originals; the extra segment is
210
+ * the only addition (0105's ref-derived traits land there).
211
+ */
212
+ export function composePrompt(
213
+ format: FormatPreset,
214
+ style: StylePreset,
215
+ vars: Record<string, string>,
216
+ extra?: string,
217
+ ): string {
218
+ const body = renderTemplate(format.template, mergeVars(format.variables, vars), format.name);
219
+ return [body, format.keywords.join(', '), style.modifiers, extra].filter(Boolean).join(', ');
220
+ }
221
+
222
+ /** Discovery diagnostic, mirroring kk-core's `DiscoveryDiagnostic` shape. */
223
+ export interface PresetDiagnostic {
224
+ code: 'shadowed' | 'invalid';
225
+ path: string;
226
+ reason: string;
227
+ }
228
+
229
+ export interface LoadedPresets {
230
+ formats: Map<string, FormatPreset>;
231
+ styles: Map<string, StylePreset>;
232
+ diagnostics: PresetDiagnostic[];
233
+ }
234
+
235
+ // File IO goes through the ts-runtime FileSystem seam (rule no-direct-fs-io). The
236
+ // node backend uses sync fs APIs by default (file-system-node.d.ts); the interface
237
+ // unions Promise for CF stubs, so pin the sync shape once here.
238
+ const fs = createNodeFileSystem() as unknown as {
239
+ exists(path: string): boolean;
240
+ readFile(path: string): string;
241
+ readDir(path: string): string[];
242
+ stat(path: string): { isDirectory(): boolean } | null;
243
+ };
244
+
245
+ function isDirectory(path: string): boolean {
246
+ return fs.stat(path)?.isDirectory() ?? false;
247
+ }
248
+
249
+ /**
250
+ * R4 — preset roots, low → high, mirroring ADR-012's plugin roots
251
+ * (`docs/design/multi-root-plugin-discovery.md`): builtin → user → project →
252
+ * each `KK_IMAGE_PRESET_PATH` entry (colon-separated, left → right). The
253
+ * project root resolves against KNOWLEDGE_KIT_PROJECT_ROOT, not cwd, because
254
+ * plugins are spawned with cwd set to their own package dir
255
+ * (`apps/cli/src/invoke.ts`).
256
+ */
257
+ export function resolvePresetRoots(): string[] {
258
+ const roots: string[] = [join(import.meta.dir, '..', 'presets')];
259
+ // process.env.HOME directly: Bun snapshots os.homedir() at startup, so a
260
+ // runtime HOME override (tests) would be invisible through homedir().
261
+ const userRoot = join(process.env.HOME ?? homedir(), '.config', 'kk', 'image-presets');
262
+ if (isDirectory(userRoot)) {
263
+ roots.push(userRoot);
264
+ }
265
+ const projectRoot = join(process.env.KNOWLEDGE_KIT_PROJECT_ROOT ?? process.cwd(), '.kk', 'image-presets');
266
+ if (isDirectory(projectRoot)) {
267
+ roots.push(projectRoot);
268
+ }
269
+ for (const entry of process.env.KK_IMAGE_PRESET_PATH?.split(':') ?? []) {
270
+ if (entry !== '' && isDirectory(entry)) {
271
+ roots.push(entry);
272
+ }
273
+ }
274
+ return roots;
275
+ }
276
+
277
+ /** Minimal structural shape of a Zod schema, so the loader stays generic. */
278
+ interface SchemaLike<T extends { name: string }> {
279
+ safeParse(value: unknown): { success: true; data: T } | { success: false; error: Error };
280
+ }
281
+
282
+ /**
283
+ * Load one axis (`formats/` or `styles/`) from one root. A file that fails its
284
+ * schema parse (or YAML parse) is skipped with an `invalid` diagnostic, never
285
+ * fatal; a name already loaded from a lower root is replaced wholesale with a
286
+ * `shadowed` diagnostic naming the losing file — no field merging, because a
287
+ * half-overridden preset cannot be reproduced from its file alone.
288
+ */
289
+ function loadAxis<T extends { name: string }>(
290
+ dir: string,
291
+ byName: Map<string, T>,
292
+ pathsByName: Map<string, string>,
293
+ schema: SchemaLike<T>,
294
+ diagnostics: PresetDiagnostic[],
295
+ ): void {
296
+ if (!isDirectory(dir)) {
297
+ return;
298
+ }
299
+ for (const entry of fs.readDir(dir).sort()) {
300
+ if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) {
301
+ continue;
302
+ }
303
+ const path = join(dir, entry);
304
+ let parsed: unknown;
305
+ try {
306
+ parsed = Bun.YAML.parse(fs.readFile(path));
307
+ } catch (error) {
308
+ diagnostics.push({ code: 'invalid', path, reason: `unreadable or unparseable: ${error}` });
309
+ continue;
310
+ }
311
+ const result = schema.safeParse(parsed);
312
+ if (!result.success) {
313
+ diagnostics.push({ code: 'invalid', path, reason: result.error.message });
314
+ continue;
315
+ }
316
+ const previousPath = pathsByName.get(result.data.name);
317
+ if (previousPath !== undefined) {
318
+ diagnostics.push({
319
+ code: 'shadowed',
320
+ path: previousPath,
321
+ reason: `Preset "${result.data.name}" shadowed by ${path}`,
322
+ });
323
+ }
324
+ byName.set(result.data.name, result.data);
325
+ pathsByName.set(result.data.name, path);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * R4 — load formats and styles across all resolved roots; a later root
331
+ * shadows an earlier one by preset name within each axis.
332
+ */
333
+ export function loadPresets(): LoadedPresets {
334
+ const diagnostics: PresetDiagnostic[] = [];
335
+ const formats = new Map<string, FormatPreset>();
336
+ const styles = new Map<string, StylePreset>();
337
+ const formatPaths = new Map<string, string>();
338
+ const stylePaths = new Map<string, string>();
339
+ for (const root of resolvePresetRoots()) {
340
+ loadAxis(join(root, 'formats'), formats, formatPaths, FormatPresetSchema, diagnostics);
341
+ loadAxis(join(root, 'styles'), styles, stylePaths, StylePresetSchema, diagnostics);
342
+ }
343
+ return { formats, styles, diagnostics };
344
+ }
@@ -0,0 +1,128 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { readBytes } from '../bytes';
3
+ import type { ImageProvider, ImageRequest, ImageResult, ProviderFactory, ProviderOptions } from './types';
4
+
5
+ /**
6
+ * Seedream adapter (0107 R2) — the first wire-speaking 0103 adapter.
7
+ * Wire prior art: vendors/baoyu-skills/skills/baoyu-image-gen/scripts/providers/seedream.ts
8
+ * (MIT © 2026 Jim Liu). POST `{base}/images/generations`, Bearer ARK_API_KEY.
9
+ */
10
+
11
+ const DEFAULT_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3';
12
+ const DEFAULT_MODEL = 'doubao-seedream-5-0-260128';
13
+ const REF_MODEL_FRAGMENTS = ['doubao-seedream-5-0', 'doubao-seedream-4-5-', 'doubao-seedream-4-0-'];
14
+
15
+ interface SeedreamResponse {
16
+ data?: Array<{ url?: string; b64_json?: string; error?: { code?: string; message?: string } }>;
17
+ error?: { code?: string; message?: string };
18
+ }
19
+
20
+ /** ARK `size`: explicit pixels win; the aspect ratio rides verbatim (seedream 4.x accepts '21:9'); else 2K. */
21
+ export function mapSize(req: ImageRequest): string {
22
+ if (req.width !== undefined && req.height !== undefined) {
23
+ return `${req.width}x${req.height}`;
24
+ }
25
+ if (req.aspectRatio !== undefined) {
26
+ return req.aspectRatio;
27
+ }
28
+ return '2K';
29
+ }
30
+
31
+ function extensionFromUrl(url: string): string {
32
+ // data.b64_json path carries no extension — default png; URL paths end ?sig=… so take the last clean segment.
33
+ const match = /(?:png|jpe?g|webp)/i.exec(url.split('?')[0] ?? '');
34
+ return match === null ? 'jpg' : match[0].toLowerCase().replace(/^jpeg$/, 'jpg');
35
+ }
36
+
37
+ export const createSeedream: ProviderFactory = (options?: ProviderOptions): ImageProvider => {
38
+ const fetchFn = options?.fetch ?? fetch;
39
+ const env = options?.env ?? process.env;
40
+ const readEnv = (key: string): string => env[key] ?? '';
41
+ const supportsRef = (model: string): boolean => REF_MODEL_FRAGMENTS.some((fragment) => model.includes(fragment));
42
+
43
+ return {
44
+ name: 'seedream',
45
+ defaultModel: () => DEFAULT_MODEL,
46
+ isAvailable: () => readEnv('ARK_API_KEY') !== '',
47
+ supportsReferenceImages: supportsRef,
48
+ validate(req: ImageRequest): void {
49
+ if (req.referenceImages !== undefined && req.referenceImages.length > 0 && !supportsRef(req.model)) {
50
+ throw new Error(
51
+ `seedream model '${req.model}' does not accept reference images; use a doubao-seedream 4.x+ model`,
52
+ );
53
+ }
54
+ },
55
+ async generate(req: ImageRequest): Promise<ImageResult> {
56
+ const apiKey = readEnv('ARK_API_KEY');
57
+ if (apiKey === '') {
58
+ throw new Error("Provider 'seedream' requires ARK_API_KEY");
59
+ }
60
+ const base = readEnv('SEEDREAM_BASE_URL') || DEFAULT_BASE_URL;
61
+ const body: Record<string, unknown> = {
62
+ model: req.model,
63
+ prompt: req.prompt,
64
+ size: mapSize(req),
65
+ response_format: 'url',
66
+ watermark: false,
67
+ };
68
+ if (req.referenceImages !== undefined && req.referenceImages.length > 0) {
69
+ // Local paths cannot ride the wire: lift each to a base64 data URI.
70
+ const lifted = await Promise.all(
71
+ req.referenceImages.map(async (path) => {
72
+ const bytes = await readBytes(path);
73
+ return `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
74
+ }),
75
+ );
76
+ body.image = lifted.length === 1 ? lifted[0] : lifted;
77
+ body.response_format = 'b64_json';
78
+ }
79
+ const resp = await fetchFn(`${base}/images/generations`, {
80
+ method: 'POST',
81
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
82
+ body: JSON.stringify(body),
83
+ });
84
+ const text = await resp.text();
85
+ if (!resp.ok) {
86
+ throw new Error(`seedream HTTP ${resp.status}: ${text.slice(0, 200)}`);
87
+ }
88
+ let parsed: SeedreamResponse;
89
+ try {
90
+ parsed = JSON.parse(text) as SeedreamResponse;
91
+ } catch {
92
+ throw new Error(`seedream returned non-JSON: ${text.slice(0, 120)}`);
93
+ }
94
+ const image = parsed.data?.[0];
95
+ if (parsed.error !== undefined) {
96
+ throw new Error(`seedream error ${parsed.error.code ?? ''}: ${parsed.error.message ?? ''}`);
97
+ }
98
+ if (image === undefined) {
99
+ throw new Error('seedream returned no image data');
100
+ }
101
+ if (image.b64_json !== undefined) {
102
+ return {
103
+ bytes: Uint8Array.from(Buffer.from(image.b64_json, 'base64')),
104
+ extension: 'png',
105
+ provider: 'seedream',
106
+ model: req.model,
107
+ };
108
+ }
109
+ if (image.url !== undefined) {
110
+ const r = await fetchFn(image.url);
111
+ if (!r.ok) {
112
+ throw new Error(`seedream fetch URL HTTP ${r.status}`);
113
+ }
114
+ return {
115
+ bytes: new Uint8Array(await r.arrayBuffer()),
116
+ extension: extensionFromUrl(image.url),
117
+ provider: 'seedream',
118
+ model: req.model,
119
+ };
120
+ }
121
+ throw new Error(
122
+ image.error === undefined
123
+ ? 'seedream returned neither url nor b64_json'
124
+ : `seedream image error ${image.error.code ?? ''}: ${image.error.message ?? ''}`,
125
+ );
126
+ },
127
+ };
128
+ };