@gobing-ai/knowledge-kit 0.0.29 → 0.0.31

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +24 -7
  5. package/plugins/generations/daily-article-gen/src/index.ts +36 -12
  6. package/plugins/generations/dailynews-gen/dist/index.js +20 -3
  7. package/plugins/generations/dailynews-gen/src/index.ts +7 -0
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +4 -2
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +66 -5
  11. package/plugins/generations/episode-plan-gen/src/index.ts +103 -4
  12. package/plugins/generations/image-gen/dist/index.js +122 -20
  13. package/plugins/generations/image-gen/src/bytes.ts +106 -18
  14. package/plugins/generations/image-gen/src/index.ts +46 -6
  15. package/plugins/generations/image-gen/src/job.ts +6 -0
  16. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  17. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  18. package/plugins/kk/plugin.json +1 -1
  19. package/plugins/kk/scripts/daily-voice-style.zh.md +5 -5
  20. package/plugins/kk/scripts/itc-stages.ts +21 -3
  21. package/plugins/kk/scripts/kk-workflow-stages.ts +199 -7
  22. package/plugins/kk/scripts/topic-boundary.json +3 -12
  23. package/plugins/kk/skills/content-judge/SKILL.md +32 -0
  24. package/plugins/kk/skills/content-judge/references/workflow-integration.md +10 -2
  25. package/plugins/kk/skills/publish/SKILL.md +1 -1
  26. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  27. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +39 -14
  28. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  29. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  30. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  31. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  32. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  33. package/plugins/sp/lib/env.ts +0 -46
  34. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  35. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  36. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  37. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  38. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  39. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  40. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  41. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  42. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  43. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  44. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  45. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  46. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  47. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  48. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  49. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  50. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  51. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  52. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  53. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  54. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  55. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  56. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  57. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  58. package/plugins/sp/scripts/quality-gate.ts +0 -217
  59. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  60. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  61. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  62. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  63. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  64. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  65. package/plugins/sp/scripts/validate-commands.ts +0 -689
  66. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  67. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  68. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  69. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  70. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  71. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  72. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  73. package/plugins/sp/scripts/wrapup-steps.ts +0 -552
@@ -248,9 +248,30 @@ function passesGates(doc: Doc, planDate: string): boolean {
248
248
  return published >= Date.parse(planDate) - FRESHNESS_DAYS * 86_400_000;
249
249
  }
250
250
 
251
- /** Strip `⭐️ x.y/10` ratings and `### [title](url)` heading copies from a body. */
251
+ /**
252
+ * Research-note section headings: the horizon/last30days note format labels its sections with a
253
+ * bold corner-bracket heading (`**「社区讨论」** 评论区…`, `**「背景」**`, `**「背景:GCN 架构」**`,
254
+ * `**「方案」**`, …). Spoken from a VoiceScript segment they read as scaffolding, and the content
255
+ * gates reject a segment that opens with one (`voice-gen`'s `voicescript.ts`, the
256
+ * `validate-voicescript.ts` sidecar) — which fails the whole unattended daily run at the `script`
257
+ * state (2026-10-03 「背景」/「社区讨论」, 2026-10-04 「社区讨论」). The bold corner-bracket markup is
258
+ * the discriminator: these note bodies never bold-quote conversational prose, so the label list
259
+ * stays open-ended.
260
+ */
261
+ const SCAFFOLD_HEADING_RE = /^\s*\*\*\s*[「『][^」』\n]{1,80}[」』]\s*\*\*\s*[::]?(?:\s+|$)/;
262
+
263
+ /** Strip `⭐️ x.y/10` ratings, `### [title](url)` heading copies and `**「label」**` research-note
264
+ * section headings from a body. A heading ahead of prose keeps the prose; a heading alone on its
265
+ * line disappears with it. */
252
266
  export function cleanBody(body: string): string {
253
- return body
267
+ const kept: string[] = [];
268
+ for (const line of body.split('\n')) {
269
+ const stripped = line.replace(SCAFFOLD_HEADING_RE, '');
270
+ if (stripped.trim() === '' && line.trim() !== '') continue; // the line held only a heading
271
+ kept.push(stripped);
272
+ }
273
+ return kept
274
+ .join('\n')
254
275
  .replace(/⭐️\s*[\d.]+\s*\/\s*10/g, '')
255
276
  .replace(/^#{1,6}\s*\[[^\]]*\]\([^)]*\)\s*$/gm, '')
256
277
  .replace(/\n{3,}/g, '\n\n')
@@ -635,6 +656,8 @@ export interface FilterConfig {
635
656
  minImportance: number;
636
657
  minUrgency: number;
637
658
  minImpact: number;
659
+ /** Episode floor: when fewer docs survive, importance-only rejects backfill up to this count. */
660
+ minEpisodeItems: number;
638
661
  }
639
662
 
640
663
  /** A doc the filter dropped, with every failed check (`category:not-in-allowlist`, `quality:2<3`, …). */
@@ -659,6 +682,13 @@ export const DEFAULT_FILTER_CONFIG: FilterConfig = {
659
682
  minImportance: 3,
660
683
  minUrgency: 2,
661
684
  minImpact: 1,
685
+ // 20261002 dogfood: a weak, policy-heavy day collapsed the episode to 2 spoken items
686
+ // (~5 min audio) because importance (source-tier base) is the first dimension a tail-source
687
+ // day fails. Backfill admits importance-only rejects — quality/urgency/impact/category
688
+ // minimums stay hard — so a thin news day deepens instead of shipping a stub. Floor is 8,
689
+ // not 6: the prompt-layer 选题边界 (script-write) drops policy items AFTER this gate (today
690
+ // 2 of 5), so the floor compensates upstream to land ~4-6 spoken items on a thin day.
691
+ minEpisodeItems: 8,
662
692
  };
663
693
 
664
694
  /** One QC_MIN_* threshold: an integer 0–5 or absent (→ fallback); anything else fails loud — never clamped. */
@@ -671,6 +701,17 @@ function parseFilterThreshold(raw: string | undefined, name: string, fallback: n
671
701
  return parsed;
672
702
  }
673
703
 
704
+ /** QC_MIN_EPISODE_ITEMS: a positive integer count or absent (→ fallback); anything else fails loud. */
705
+ function parseFloorCount(raw: string | undefined, name: string, fallback: number): number {
706
+ if (raw === undefined || raw.trim() === '') return fallback;
707
+ const value = raw.trim();
708
+ if (!/^\d+$/.test(value)) throw new Error(`${name}='${raw}' is not a positive integer (fail loud, no clamping)`);
709
+ const parsed = Number.parseInt(value, 10);
710
+ if (parsed < 1)
711
+ throw new Error(`${name}=${parsed} must be >= 1 (a 0 floor disables the gate — drop the env instead)`);
712
+ return parsed;
713
+ }
714
+
674
715
  /** QC_CATEGORIES: a comma list of MECE categories; unknown entries fail loud (no silent drops). */
675
716
  function parseFilterCategories(raw: string | undefined): Category[] {
676
717
  if (raw === undefined || raw.trim() === '') return [...DEFAULT_FILTER_CONFIG.categories];
@@ -697,6 +738,11 @@ export function filterConfigFromEnv(env: Record<string, string | undefined> = pr
697
738
  ),
698
739
  minUrgency: parseFilterThreshold(env.QC_MIN_URGENCY, 'QC_MIN_URGENCY', DEFAULT_FILTER_CONFIG.minUrgency),
699
740
  minImpact: parseFilterThreshold(env.QC_MIN_IMPACT, 'QC_MIN_IMPACT', DEFAULT_FILTER_CONFIG.minImpact),
741
+ minEpisodeItems: parseFloorCount(
742
+ env.QC_MIN_EPISODE_ITEMS,
743
+ 'QC_MIN_EPISODE_ITEMS',
744
+ DEFAULT_FILTER_CONFIG.minEpisodeItems,
745
+ ),
700
746
  };
701
747
  }
702
748
 
@@ -749,6 +795,53 @@ export function filterAnnotated(docs: Doc[], config: FilterConfig): { kept: Doc[
749
795
  return { kept, rejected };
750
796
  }
751
797
 
798
+ function importanceOf(doc: Doc): number {
799
+ const scores = asRecord(asRecord(doc.metadata).scores);
800
+ return typeof scores.importance === 'number' ? scores.importance : 0;
801
+ }
802
+
803
+ function planScoreOf(doc: Doc): number {
804
+ const value = asRecord(doc.metadata).planScore;
805
+ return typeof value === 'number' ? value : 0;
806
+ }
807
+
808
+ /**
809
+ * Episode floor (20261002): when the strict filter leaves fewer than `minEpisodeItems` docs,
810
+ * backfill from rejects that failed ONLY the importance minimum — importance is the source-tier
811
+ * base (tail sources carry 2), so on a thin day it is the softest dimension, while quality/
812
+ * urgency/impact/category stay hard gates. Admission order: importance desc, planScore desc,
813
+ * plan order. Admitted docs are stamped `metadata.backfilled: true` and leave the rejected
814
+ * audit (they are spoken, not dropped). Pure: input docs are never mutated.
815
+ */
816
+ export function backfillToFloor(
817
+ kept: Doc[],
818
+ rejected: RejectedDoc[],
819
+ config: FilterConfig,
820
+ ): { kept: Doc[]; rejected: RejectedDoc[]; admitted: Doc[] } {
821
+ if (kept.length >= config.minEpisodeItems) return { kept, rejected, admitted: [] };
822
+ const eligible = rejected
823
+ .map((entry, index) => ({ entry, index }))
824
+ .filter(({ entry }) => entry.reasons.length > 0 && entry.reasons.every((r) => r.startsWith('importance:')));
825
+ eligible.sort((a, b) => {
826
+ const byImportance = importanceOf(b.entry.doc) - importanceOf(a.entry.doc);
827
+ if (byImportance !== 0) return byImportance;
828
+ const byPlanScore = planScoreOf(b.entry.doc) - planScoreOf(a.entry.doc);
829
+ if (byPlanScore !== 0) return byPlanScore;
830
+ return a.index - b.index;
831
+ });
832
+ const taken = eligible.slice(0, config.minEpisodeItems - kept.length);
833
+ const takenIds = new Set(taken.map(({ entry }) => entry.doc.id));
834
+ const admitted = taken.map(({ entry }) => ({
835
+ ...entry.doc,
836
+ metadata: { ...asRecord(entry.doc.metadata), backfilled: true },
837
+ }));
838
+ return {
839
+ kept: [...kept, ...admitted],
840
+ rejected: rejected.filter((entry) => !takenIds.has(entry.doc.id)),
841
+ admitted,
842
+ };
843
+ }
844
+
752
845
  /** Sibling rejected-audit path: `<out>.json` → `<out>.rejected.json` (candidates.json → candidates.rejected.json). */
753
846
  export function rejectedOutPath(outputPath: string): string {
754
847
  return outputPath.endsWith('.json')
@@ -792,9 +885,15 @@ async function processFilterIO(inputPath: string, outputPath: string): Promise<C
792
885
  const config = filterConfigFromEnv();
793
886
  const docs = DocListSchema.parse(await readJsonFile(inputPath));
794
887
  const { kept, rejected } = filterAnnotated(docs, config);
795
- const content = planContent(kept, resolvePlanDate());
888
+ const filled = backfillToFloor(kept, rejected, config);
889
+ if (filled.admitted.length > 0) {
890
+ echoError(
891
+ `episode floor backfill: admitted ${filled.admitted.length} importance-only reject(s) to reach ${config.minEpisodeItems} items (${filled.admitted.map((d) => d.id).join(', ')})`,
892
+ );
893
+ }
894
+ const content = planContent(filled.kept, resolvePlanDate());
796
895
  await atomicWrite(outputPath, content);
797
- await atomicWrite(rejectedOutPath(outputPath), { rejected, config, total: docs.length });
896
+ await atomicWrite(rejectedOutPath(outputPath), { rejected: filled.rejected, config, total: docs.length });
798
897
  return content;
799
898
  }
800
899
 
@@ -21818,23 +21818,101 @@ async function readBytes(path2) {
21818
21818
  return new Uint8Array(await readFile(path2));
21819
21819
  }
21820
21820
  function imageDimensions(bytes) {
21821
+ if (isPng(bytes))
21822
+ return pngDimensions(bytes);
21823
+ if (isJpeg(bytes))
21824
+ return jpegDimensions(bytes);
21825
+ if (isWebp(bytes))
21826
+ return webpDimensions(bytes);
21827
+ return null;
21828
+ }
21829
+ function isPng(b) {
21821
21830
  const PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
21822
- if (bytes.byteLength < 24) {
21823
- throw new Error("imageDimensions: not a PNG \u2014 buffer too small");
21824
- }
21831
+ if (b.byteLength < 24)
21832
+ return false;
21825
21833
  for (let i = 0;i < 8; i++) {
21826
- if (bytes[i] !== PNG_SIG[i]) {
21827
- throw new Error("imageDimensions: not a PNG \u2014 invalid signature");
21828
- }
21834
+ if (b[i] !== PNG_SIG[i])
21835
+ return false;
21829
21836
  }
21830
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
21837
+ return true;
21838
+ }
21839
+ function pngDimensions(b) {
21840
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
21831
21841
  const ihdrLength = view.getUint32(8, false);
21832
- if (ihdrLength !== 13 || bytes[12] !== 73 || bytes[13] !== 72 || bytes[14] !== 68 || bytes[15] !== 82) {
21833
- throw new Error("imageDimensions: not a PNG \u2014 IHDR chunk not found at offset 8");
21842
+ if (ihdrLength !== 13 || view.getUint32(12, false) !== 1229472850) {
21843
+ return null;
21834
21844
  }
21835
- const width = view.getUint32(16, false);
21836
- const height = view.getUint32(20, false);
21837
- return { width, height };
21845
+ return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
21846
+ }
21847
+ function isJpeg(b) {
21848
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
21849
+ return b.byteLength >= 4 && view.getUint16(0, false) === 65496 && view.getUint8(2) === 255;
21850
+ }
21851
+ function jpegDimensions(b) {
21852
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
21853
+ let at = 2;
21854
+ while (at + 4 <= b.byteLength) {
21855
+ if (view.getUint8(at) !== 255)
21856
+ return null;
21857
+ const marker = view.getUint8(at + 1);
21858
+ at += 2;
21859
+ if (marker === 1 || marker >= 208 && marker <= 215)
21860
+ continue;
21861
+ if (at + 2 > b.byteLength)
21862
+ return null;
21863
+ const length = view.getUint16(at, false);
21864
+ if (length < 2)
21865
+ return null;
21866
+ const isSof = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
21867
+ if (isSof) {
21868
+ if (at + 7 > b.byteLength)
21869
+ return null;
21870
+ return { height: view.getUint16(at + 3, false), width: view.getUint16(at + 5, false) };
21871
+ }
21872
+ at += length;
21873
+ }
21874
+ return null;
21875
+ }
21876
+ function isWebp(b) {
21877
+ if (b.byteLength < 20)
21878
+ return false;
21879
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
21880
+ return view.getUint32(0, true) === 1179011410 && view.getUint32(8, true) === 1346520407;
21881
+ }
21882
+ function webpDimensions(b) {
21883
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
21884
+ let at = 12;
21885
+ while (at + 8 <= b.byteLength) {
21886
+ const id = String.fromCharCode(view.getUint8(at), view.getUint8(at + 1), view.getUint8(at + 2), view.getUint8(at + 3));
21887
+ const size = view.getUint32(at + 4, true);
21888
+ const body = at + 8;
21889
+ if (id === "VP8 ") {
21890
+ if (size < 10 || body + 10 > b.byteLength)
21891
+ return null;
21892
+ if (view.getUint8(body + 3) !== 157 || view.getUint8(body + 4) !== 1 || view.getUint8(body + 5) !== 42) {
21893
+ return null;
21894
+ }
21895
+ return {
21896
+ width: view.getUint16(body + 6, true) & 16383,
21897
+ height: view.getUint16(body + 8, true) & 16383
21898
+ };
21899
+ }
21900
+ if (id === "VP8L") {
21901
+ if (size < 5 || body + 5 > b.byteLength || view.getUint8(body) !== 47)
21902
+ return null;
21903
+ const bits = view.getUint32(body + 1, true);
21904
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
21905
+ }
21906
+ if (id === "VP8X") {
21907
+ if (size < 10 || body + 10 > b.byteLength)
21908
+ return null;
21909
+ const width = view.getUint8(body + 4) | view.getUint8(body + 5) << 8 | view.getUint8(body + 6) << 16;
21910
+ const height = view.getUint8(body + 7) | view.getUint8(body + 8) << 8 | view.getUint8(body + 9) << 16;
21911
+ return { width: width + 1, height: height + 1 };
21912
+ }
21913
+ at = body + size + size % 2;
21914
+ }
21915
+ return null;
21838
21916
  }
21839
21917
  var init_bytes = () => {};
21840
21918
 
@@ -24240,11 +24318,14 @@ import { Buffer as Buffer7 } from "buffer";
24240
24318
  var DEFAULT_MODEL9 = "image-01";
24241
24319
  var MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
24242
24320
  var SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
24321
+ function isAcceptablePixelSize(width, height) {
24322
+ return width >= 512 && width <= 2048 && height >= 512 && height <= 2048 && width % 8 === 0 && height % 8 === 0;
24323
+ }
24243
24324
  function validatePixelSize(width, height) {
24244
- if (width < 512 || width > 2048 || height < 512 || height > 2048) {
24245
- throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
24246
- }
24247
- if (width % 8 !== 0 || height % 8 !== 0) {
24325
+ if (!isAcceptablePixelSize(width, height)) {
24326
+ if (width < 512 || width > 2048 || height < 512 || height > 2048) {
24327
+ throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
24328
+ }
24248
24329
  throw new Error("MiniMax custom size requires width and height divisible by 8.");
24249
24330
  }
24250
24331
  }
@@ -24326,6 +24407,7 @@ var createMinimax = (options) => {
24326
24407
  defaultModel: () => DEFAULT_MODEL9,
24327
24408
  isAvailable: () => readEnv("MINIMAX_API_KEY") !== "",
24328
24409
  supportsReferenceImages: () => true,
24410
+ exactSize: (model, width, height) => model === "image-01" && isAcceptablePixelSize(width, height),
24329
24411
  validate(req) {
24330
24412
  requestBody(req.prompt, req.model, req);
24331
24413
  },
@@ -25489,15 +25571,17 @@ async function processGeneratorIO(inputPath, outputPath, options = {}) {
25489
25571
  }
25490
25572
  const provider = at === 0 ? primary : createCandidate(name);
25491
25573
  const model = at === 0 ? spec.model ?? provider.defaultModel() : provider.defaultModel();
25574
+ const exact = format.width !== undefined && format.height !== undefined && provider.exactSize?.(model, format.width, format.height) === true;
25492
25575
  const request = {
25493
25576
  prompt,
25494
25577
  model,
25495
- aspectRatio: format.aspectRatio,
25578
+ ...exact ? { width: format.width, height: format.height } : { aspectRatio: format.aspectRatio },
25496
25579
  ...spec.referenceImages !== undefined && spec.referenceImages.length > 0 ? { referenceImages: spec.referenceImages } : {}
25497
25580
  };
25498
25581
  try {
25499
25582
  provider.validate?.(request);
25500
- return await provider.generate(request);
25583
+ const result = await provider.generate(request);
25584
+ return { result, sizeRequest: exact ? "exact" : "aspect" };
25501
25585
  } catch (err) {
25502
25586
  if (spec.fallback === undefined) {
25503
25587
  throw err;
@@ -25520,7 +25604,15 @@ async function processGeneratorIO(inputPath, outputPath, options = {}) {
25520
25604
  };
25521
25605
  let body = doc2?.body ?? "";
25522
25606
  for (const [index, prompt] of prompts.entries()) {
25523
- const result = await generateWithFallback(index + 1, prompt);
25607
+ const { result, sizeRequest } = await generateWithFallback(index + 1, prompt);
25608
+ const delivered = imageDimensions(result.bytes);
25609
+ if (format.width !== undefined && format.height !== undefined) {
25610
+ if (delivered === null) {
25611
+ echoError(`WARN image-gen: ${result.provider}/${result.model} delivered unparseable image bytes; dimensions omitted`);
25612
+ } else if (delivered.width !== format.width || delivered.height !== format.height) {
25613
+ echoError(`WARN image-gen: ${result.provider}/${result.model} delivered ${delivered.width}x${delivered.height}; format '${format.name}' declares ${format.width}x${format.height}`);
25614
+ }
25615
+ }
25524
25616
  let anchor;
25525
25617
  if (spec.job === "illustration") {
25526
25618
  anchor = positions?.[index];
@@ -25564,6 +25656,9 @@ async function processGeneratorIO(inputPath, outputPath, options = {}) {
25564
25656
  await writeBytes(cardPath, cardBytes);
25565
25657
  written.push(cardPath);
25566
25658
  const dims = imageDimensions(cardBytes);
25659
+ if (dims === null) {
25660
+ throw new Error(`compose: composed card dimensions are unparseable; format '${format.name}' requires ${format.width}x${format.height}`);
25661
+ }
25567
25662
  if (dims.width !== format.width || dims.height !== format.height) {
25568
25663
  throw new Error(`compose: card dimensions ${dims.width}x${dims.height} do not match format canvas ${format.width}x${format.height}`);
25569
25664
  }
@@ -25571,13 +25666,19 @@ async function processGeneratorIO(inputPath, outputPath, options = {}) {
25571
25666
  artifactBytes = cardBytes.byteLength;
25572
25667
  artifactWidth = dims.width;
25573
25668
  artifactHeight = dims.height;
25574
- illustrationMeta = { path: illustrationArtifactPath, bytes: result.bytes.byteLength };
25669
+ illustrationMeta = {
25670
+ path: illustrationArtifactPath,
25671
+ bytes: result.bytes.byteLength,
25672
+ ...delivered !== null ? { width: delivered.width, height: delivered.height } : {}
25673
+ };
25575
25674
  } else {
25576
25675
  const path2 = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
25577
25676
  await writeBytes(path2, result.bytes);
25578
25677
  written.push(path2);
25579
25678
  artifactPathValue = path2;
25580
25679
  artifactBytes = result.bytes.byteLength;
25680
+ artifactWidth = delivered?.width;
25681
+ artifactHeight = delivered?.height;
25581
25682
  }
25582
25683
  const artifact = {
25583
25684
  path: artifactPathValue,
@@ -25587,6 +25688,7 @@ async function processGeneratorIO(inputPath, outputPath, options = {}) {
25587
25688
  model: result.model,
25588
25689
  prompt,
25589
25690
  bytes: artifactBytes,
25691
+ sizeRequest,
25590
25692
  ...artifactWidth !== undefined ? { width: artifactWidth } : {},
25591
25693
  ...artifactHeight !== undefined ? { height: artifactHeight } : {},
25592
25694
  ...illustrationMeta !== undefined ? { illustration: illustrationMeta } : {}
@@ -19,29 +19,117 @@ export async function readBytes(path: string): Promise<Uint8Array> {
19
19
  }
20
20
 
21
21
  /**
22
- * Task 0180 — pure PNG-only dimension reader (IHDR chunk).
23
- * Reads `width` and `height` from the PNG IHDR chunk. Non-PNG bytes and
24
- * truncated headers throw. Task 0182 will extend this to JPEG/WebP (escalation
25
- * decision A — do not preempt that task's scope here).
22
+ * Task 0180 (PNG) + task 0182 (JPEG/WebP, `null` contract) — pure header-only dimension
23
+ * reader. Reads `width`/`height` from PNG (IHDR), JPEG (SOF0–SOF15 except DHT/DAC/JPG)
24
+ * and WebP (VP8 lossy, VP8L lossless, VP8X extended) bytes; no decode. Returns `null`
25
+ * when the bytes cannot be parsed — the caller decides whether that is fatal (the compose
26
+ * canvas assertion) or a WARN with dimensions omitted (artifact recording, R3: the bytes
27
+ * are already paid for).
26
28
  */
27
- export function imageDimensions(bytes: Uint8Array): { width: number; height: number } {
28
- // PNG signature (8 bytes) + IHDR chunk length (4) + 'IHDR' tag (4) + width (4) + height (4) = 24
29
+ export function imageDimensions(bytes: Uint8Array): { width: number; height: number } | null {
30
+ if (isPng(bytes)) return pngDimensions(bytes);
31
+ if (isJpeg(bytes)) return jpegDimensions(bytes);
32
+ if (isWebp(bytes)) return webpDimensions(bytes);
33
+ return null;
34
+ }
35
+
36
+ function isPng(b: Uint8Array): boolean {
29
37
  const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
30
- if (bytes.byteLength < 24) {
31
- throw new Error('imageDimensions: not a PNG — buffer too small');
32
- }
38
+ if (b.byteLength < 24) return false;
33
39
  for (let i = 0; i < 8; i++) {
34
- if (bytes[i] !== PNG_SIG[i]) {
35
- throw new Error('imageDimensions: not a PNG — invalid signature');
36
- }
40
+ if (b[i] !== PNG_SIG[i]) return false;
37
41
  }
42
+ return true;
43
+ }
44
+
45
+ function pngDimensions(b: Uint8Array): { width: number; height: number } | null {
38
46
  // IHDR chunk: offset 8 = chunk data length (must be 13), offset 12 = 'IHDR' tag.
39
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
47
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
40
48
  const ihdrLength = view.getUint32(8, false);
41
- if (ihdrLength !== 13 || bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) {
42
- throw new Error('imageDimensions: not a PNG — IHDR chunk not found at offset 8');
49
+ if (ihdrLength !== 13 || view.getUint32(12, false) !== 0x49484452) {
50
+ return null;
51
+ }
52
+ return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
53
+ }
54
+
55
+ function isJpeg(b: Uint8Array): boolean {
56
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
57
+ // SOI + the first marker's 0xFF prefix.
58
+ return b.byteLength >= 4 && view.getUint16(0, false) === 0xffd8 && view.getUint8(2) === 0xff;
59
+ }
60
+
61
+ function jpegDimensions(b: Uint8Array): { width: number; height: number } | null {
62
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
63
+ let at = 2; // after SOI
64
+ while (at + 4 <= b.byteLength) {
65
+ if (view.getUint8(at) !== 0xff) return null; // marker desync — not a parseable JPEG stream
66
+ const marker = view.getUint8(at + 1);
67
+ at += 2;
68
+ // Standalone markers (TEM, RSTn) carry no length segment.
69
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
70
+ if (at + 2 > b.byteLength) return null;
71
+ const length = view.getUint16(at, false);
72
+ if (length < 2) return null;
73
+ // SOF0–SOF15 minus DHT (C4), JPG (C8) and DAC (CC) carry the canvas.
74
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
75
+ if (isSof) {
76
+ // SOF segment: length(2) precision(1) height(2) width(2).
77
+ if (at + 7 > b.byteLength) return null;
78
+ return { height: view.getUint16(at + 3, false), width: view.getUint16(at + 5, false) };
79
+ }
80
+ at += length;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function isWebp(b: Uint8Array): boolean {
86
+ if (b.byteLength < 20) return false;
87
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
88
+ return view.getUint32(0, true) === 0x46464952 && view.getUint32(8, true) === 0x50424557; // 'RIFF' 'WEBP'
89
+ }
90
+
91
+ function webpDimensions(b: Uint8Array): { width: number; height: number } | null {
92
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
93
+ // Walk the RIFF chunks after the 12-byte file header until a size-bearing chunk appears.
94
+ let at = 12;
95
+ while (at + 8 <= b.byteLength) {
96
+ const id = String.fromCharCode(
97
+ view.getUint8(at),
98
+ view.getUint8(at + 1),
99
+ view.getUint8(at + 2),
100
+ view.getUint8(at + 3),
101
+ );
102
+ const size = view.getUint32(at + 4, true);
103
+ const body = at + 8;
104
+ if (id === 'VP8 ') {
105
+ // Lossy: frame tag (3) + sync code 0x9d 0x01 0x2a + 14-bit LE width/height.
106
+ if (size < 10 || body + 10 > b.byteLength) return null;
107
+ if (
108
+ view.getUint8(body + 3) !== 0x9d ||
109
+ view.getUint8(body + 4) !== 0x01 ||
110
+ view.getUint8(body + 5) !== 0x2a
111
+ ) {
112
+ return null;
113
+ }
114
+ return {
115
+ width: view.getUint16(body + 6, true) & 0x3fff,
116
+ height: view.getUint16(body + 8, true) & 0x3fff,
117
+ };
118
+ }
119
+ if (id === 'VP8L') {
120
+ // Lossless: signature 0x2f, then 14-bit width-1 / height-1 packed little-endian.
121
+ if (size < 5 || body + 5 > b.byteLength || view.getUint8(body) !== 0x2f) return null;
122
+ const bits = view.getUint32(body + 1, true);
123
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
124
+ }
125
+ if (id === 'VP8X') {
126
+ // Extended: 4 flag/reserved bytes, then 24-bit LE width-1 / height-1.
127
+ if (size < 10 || body + 10 > b.byteLength) return null;
128
+ const width = view.getUint8(body + 4) | (view.getUint8(body + 5) << 8) | (view.getUint8(body + 6) << 16);
129
+ const height = view.getUint8(body + 7) | (view.getUint8(body + 8) << 8) | (view.getUint8(body + 9) << 16);
130
+ return { width: width + 1, height: height + 1 };
131
+ }
132
+ at = body + size + (size % 2); // RIFF chunks pad to even sizes
43
133
  }
44
- const width = view.getUint32(16, false);
45
- const height = view.getUint32(20, false);
46
- return { width, height };
134
+ return null;
47
135
  }
@@ -390,7 +390,10 @@ export async function processGeneratorIO(
390
390
  const captions: CaptionEntry[] = [];
391
391
  const attempts: ProviderAttempt[] = [];
392
392
  let at = 0; // sticky index into `order`
393
- const generateWithFallback = async (image: number, prompt: string): Promise<ImageResult> => {
393
+ const generateWithFallback = async (
394
+ image: number,
395
+ prompt: string,
396
+ ): Promise<{ result: ImageResult; sizeRequest: 'exact' | 'aspect' }> => {
394
397
  const start = attempts.length; // this image's failed attempts begin here (R5 summarises the slice)
395
398
  for (;;) {
396
399
  const name = order[at];
@@ -401,17 +404,26 @@ export async function processGeneratorIO(
401
404
  const provider = at === 0 ? primary : createCandidate(name);
402
405
  // spec.model is provider-specific (D6): only the requested provider receives it.
403
406
  const model = at === 0 ? (spec.model ?? provider.defaultModel()) : provider.defaultModel();
407
+ // 0182 R1/R4: the exact-size gate runs per candidate, with that candidate's own
408
+ // model and its own capability report. Without it the request keeps today's
409
+ // aspect-only shape (R2); on the exact path aspectRatio is omitted (R1 — MiniMax
410
+ // reads width/height only when aspect_ratio is absent).
411
+ const exact =
412
+ format.width !== undefined &&
413
+ format.height !== undefined &&
414
+ provider.exactSize?.(model, format.width, format.height) === true;
404
415
  const request: ImageRequest = {
405
416
  prompt,
406
417
  model,
407
- aspectRatio: format.aspectRatio,
418
+ ...(exact ? { width: format.width, height: format.height } : { aspectRatio: format.aspectRatio }),
408
419
  ...(spec.referenceImages !== undefined && spec.referenceImages.length > 0
409
420
  ? { referenceImages: spec.referenceImages }
410
421
  : {}),
411
422
  };
412
423
  try {
413
424
  provider.validate?.(request);
414
- return await provider.generate(request);
425
+ const result = await provider.generate(request);
426
+ return { result, sizeRequest: exact ? 'exact' : 'aspect' };
415
427
  } catch (err: unknown) {
416
428
  if (spec.fallback === undefined) {
417
429
  throw err; // D6 R1: strict mode propagates the original error, unwrapped.
@@ -441,7 +453,22 @@ export async function processGeneratorIO(
441
453
  };
442
454
  let body = doc?.body ?? '';
443
455
  for (const [index, prompt] of prompts.entries()) {
444
- const result = await generateWithFallback(index + 1, prompt);
456
+ const { result, sizeRequest } = await generateWithFallback(index + 1, prompt);
457
+ // 0182 R3: measure the raw provider delivery once — both branches read it. A declared
458
+ // canvas that came back different (or unparseable) warns and never fails: the bytes
459
+ // are already paid for, and failing would delete them through the cleanup path.
460
+ const delivered = imageDimensions(result.bytes);
461
+ if (format.width !== undefined && format.height !== undefined) {
462
+ if (delivered === null) {
463
+ echoError(
464
+ `WARN image-gen: ${result.provider}/${result.model} delivered unparseable image bytes; dimensions omitted`,
465
+ );
466
+ } else if (delivered.width !== format.width || delivered.height !== format.height) {
467
+ echoError(
468
+ `WARN image-gen: ${result.provider}/${result.model} delivered ${delivered.width}x${delivered.height}; format '${format.name}' declares ${format.width}x${format.height}`,
469
+ );
470
+ }
471
+ }
445
472
  // Re-check against the spliced body; the pre-spend plan check is assertAnchorPlan (0169).
446
473
  let anchor: string | undefined;
447
474
  if (spec.job === 'illustration') {
@@ -505,6 +532,11 @@ export async function processGeneratorIO(
505
532
 
506
533
  // 4. Measure the card dimensions via imageDimensions and assert canvas.
507
534
  const dims = imageDimensions(cardBytes);
535
+ if (dims === null) {
536
+ throw new Error(
537
+ `compose: composed card dimensions are unparseable; format '${format.name}' requires ${format.width}x${format.height}`,
538
+ );
539
+ }
508
540
  if (dims.width !== format.width || dims.height !== format.height) {
509
541
  throw new Error(
510
542
  `compose: card dimensions ${dims.width}x${dims.height} do not match format canvas ${format.width}x${format.height}`,
@@ -515,14 +547,21 @@ export async function processGeneratorIO(
515
547
  artifactBytes = cardBytes.byteLength;
516
548
  artifactWidth = dims.width;
517
549
  artifactHeight = dims.height;
518
- illustrationMeta = { path: illustrationArtifactPath, bytes: result.bytes.byteLength };
550
+ illustrationMeta = {
551
+ path: illustrationArtifactPath,
552
+ bytes: result.bytes.byteLength,
553
+ // 0182 R3/R5: the raw provider delivery, measured from its own bytes.
554
+ ...(delivered !== null ? { width: delivered.width, height: delivered.height } : {}),
555
+ };
519
556
  } else {
520
- // Non-compose: existing path (R6).
557
+ // Non-compose: existing path (R6); the artifact records the delivered size (0182 R3).
521
558
  const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
522
559
  await writeBytes(path, result.bytes);
523
560
  written.push(path);
524
561
  artifactPathValue = path;
525
562
  artifactBytes = result.bytes.byteLength;
563
+ artifactWidth = delivered?.width;
564
+ artifactHeight = delivered?.height;
526
565
  }
527
566
 
528
567
  const artifact: ImageArtifact = {
@@ -533,6 +572,7 @@ export async function processGeneratorIO(
533
572
  model: result.model,
534
573
  prompt,
535
574
  bytes: artifactBytes,
575
+ sizeRequest,
536
576
  ...(artifactWidth !== undefined ? { width: artifactWidth } : {}),
537
577
  ...(artifactHeight !== undefined ? { height: artifactHeight } : {}),
538
578
  ...(illustrationMeta !== undefined ? { illustration: illustrationMeta } : {}),
@@ -76,6 +76,12 @@ export interface ImageArtifact {
76
76
  /** The exact prompt string sent. */
77
77
  prompt: string;
78
78
  seed?: number;
79
+ /**
80
+ * 0182 R3: what the provider was asked for — 'exact' when the request carried the
81
+ * preset's declared width/height (the provider reported it could honour them for this
82
+ * model), 'aspect' for the aspect-only shape. Present on every written artifact.
83
+ */
84
+ sizeRequest?: 'exact' | 'aspect';
79
85
  width?: number;
80
86
  height?: number;
81
87
  bytes: number;
@@ -50,11 +50,17 @@ export function parsePixelSize(size: string): { width: number; height: number }
50
50
  return { width, height };
51
51
  }
52
52
 
53
+ /** 0182 R4: image-01 accepts an exact canvas of 512..2048 per edge, divisible by 8. */
54
+ function isAcceptablePixelSize(width: number, height: number): boolean {
55
+ return width >= 512 && width <= 2048 && height >= 512 && height <= 2048 && width % 8 === 0 && height % 8 === 0;
56
+ }
57
+
58
+ /** Throwing twin of {@link isAcceptablePixelSize} for the request-body path; messages are frozen (tests pin them). */
53
59
  function validatePixelSize(width: number, height: number): void {
54
- if (width < 512 || width > 2048 || height < 512 || height > 2048) {
55
- throw new Error('MiniMax custom size must keep width and height between 512 and 2048.');
56
- }
57
- if (width % 8 !== 0 || height % 8 !== 0) {
60
+ if (!isAcceptablePixelSize(width, height)) {
61
+ if (width < 512 || width > 2048 || height < 512 || height > 2048) {
62
+ throw new Error('MiniMax custom size must keep width and height between 512 and 2048.');
63
+ }
58
64
  throw new Error('MiniMax custom size requires width and height divisible by 8.');
59
65
  }
60
66
  }
@@ -146,6 +152,8 @@ export const createMinimax: ProviderFactory = (options?: ProviderOptions): Image
146
152
  defaultModel: () => DEFAULT_MODEL,
147
153
  isAvailable: () => readEnv('MINIMAX_API_KEY') !== '',
148
154
  supportsReferenceImages: () => true,
155
+ exactSize: (model: string, width: number, height: number): boolean =>
156
+ model === 'image-01' && isAcceptablePixelSize(width, height),
149
157
  validate(req: ImageRequest): void {
150
158
  requestBody(req.prompt, req.model, req);
151
159
  },