@ngockhoale/ukit 2.3.13 → 2.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.14 - 2026-09-11
6
+
7
+ Vision-lane root cause, part 3 — the decisive one: the extractor could not see images in REAL
8
+ Claude Code transcripts at all. Found by reproducing the failure against the user's own real
9
+ session transcripts after they insisted the bug lived "right at the image" — grep proved the
10
+ transcripts contained `"type":"image"` while every extraction returned `imageCount: 0`. The
11
+ 2.3.12/2.3.13 suites were green because the fixtures modeled an invented transcript shape.
12
+
13
+ **P1 — real transcript image envelopes were invisible to the extractor.** Verified against
14
+ real transcripts from four projects, Claude Code stores images in THREE shapes:
15
+
16
+ - flat: `message.content[i] = { type: 'image', source: { type: 'base64', … } }` — the only
17
+ shape the pre-fix parser handled;
18
+ - pasted: a top-level `attachment` envelope (`type: 'attachment'`,
19
+ `attachment.prompt[]` = content blocks) — every real pasted image lives here, NOT in
20
+ `message.content`; the pre-fix parser extracted nothing from any pasted image;
21
+ - tool-returned: `message.content[i] = { type: 'tool_result', content: [ { type: 'image',
22
+ … } ] }` — screenshots coming back from tools nest one level down; also invisible.
23
+
24
+ Fix: `extractImageBlocks()` now walks a bounded collector (depth ≤ 3) across
25
+ `message.content[]`, nested `content[]` arrays inside blocks, and the top-level
26
+ `attachment.prompt[]` / `attachment.content` envelopes. Same-base64 payloads across
27
+ envelopes dedupe by sha as before. Real-data validation after the fix: the same four
28
+ transcripts now extract valid images (PNG 2278×2154 from the paste envelope, JPEG 1400×884
29
+ from a tool_result, PNG/JPEG from the flat shape), and a live analyst dispatch read the
30
+ real pasted screenshot.
31
+
32
+ Tests: `tests/handoff/cycle10/extract-image-contract.test.mjs` gains three cases whose
33
+ fixtures copy the real envelope structures verbatim (RED pre-fix: `imageCount 0`), plus a
34
+ cross-envelope dedupe pin. All cycle10 + cycle4 vision suites green.
35
+
5
36
  ## 2.3.13 - 2026-09-11
6
37
 
7
38
  Vision-lane root cause, part 2: the Codex model catalog could still deny the vision lane on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.13",
3
+ "version": "2.3.14",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -381,25 +381,64 @@ function readLines(filePath) {
381
381
  return raw.split('\n').filter((line) => line.trim().length > 0);
382
382
  }
383
383
 
384
+ /**
385
+ * Deepest content-blocks nesting the collector walks. 1 covers the flat
386
+ * message.content[] / attachment.prompt[] arrays; 2 covers the real-world
387
+ * tool_result nesting (message.content[i].content[]); 3 is a cheap safety
388
+ * bound — deeper structures are not a Claude Code shape and are ignored.
389
+ */
390
+ const MAX_BLOCK_WALK_DEPTH = 3;
391
+
392
+ /**
393
+ * Collects base64 image blocks from one content-blocks array. Real Claude
394
+ * Code transcripts nest images in more than one place (verified against real
395
+ * session transcripts 2026-09-11 — the pre-fix parser only saw the flat
396
+ * shape and extracted NOTHING from real paste/tool-image traffic):
397
+ * A. message.content[i] = { type: 'image', source: { type: 'base64', … } }
398
+ * C. message.content[i] = { type: 'tool_result', content: [ { type: 'image', … } ] }
399
+ * Blocks that carry their own `content` array (tool_result etc.) are walked
400
+ * one level deeper, bounded by MAX_BLOCK_WALK_DEPTH.
401
+ */
402
+ function collectImageBlocksFromContent(content, lineIndex, blocks, depth) {
403
+ if (!Array.isArray(content) || depth > MAX_BLOCK_WALK_DEPTH) return;
404
+ for (const block of content) {
405
+ if (!block || typeof block !== 'object') continue;
406
+ if (block.type === 'image') {
407
+ const source = block.source;
408
+ if (!source || source.type !== 'base64') continue;
409
+ const mediaType = source.media_type;
410
+ const data = source.data;
411
+ if (typeof mediaType !== 'string' || typeof data !== 'string' || !data) continue;
412
+ blocks.push({ mediaType, data, lineIndex });
413
+ } else if (Array.isArray(block.content)) {
414
+ collectImageBlocksFromContent(block.content, lineIndex, blocks, depth + 1);
415
+ }
416
+ }
417
+ }
418
+
384
419
  /**
385
420
  * Parses each JSONL line independently. A malformed line is skipped via its
386
421
  * own try/catch and never throws out to the caller.
422
+ *
423
+ * Envelope shapes collected per line (real-transcript evidence 2026-09-11):
424
+ * A/C. the API message envelope — flat image blocks, plus tool_result
425
+ * blocks that nest theirs one level down (see the collector above).
426
+ * B. pasted images arrive in a TOP-LEVEL attachment envelope, NOT in
427
+ * message.content: attachment.prompt[] is the content-blocks array
428
+ * (attachment.content accepted defensively for the same reason).
429
+ * The same payload appearing in two envelopes (attachment + the later user
430
+ * message) dedupes by sha in selectImages().
387
431
  */
388
432
  function extractImageBlocks(lines) {
389
433
  const blocks = [];
390
434
  for (let i = 0; i < lines.length; i += 1) {
391
435
  try {
392
436
  const obj = JSON.parse(lines[i]);
393
- const content = obj?.message?.content;
394
- if (!Array.isArray(content)) continue;
395
- for (const block of content) {
396
- if (block?.type !== 'image') continue;
397
- const source = block.source;
398
- if (!source || source.type !== 'base64') continue;
399
- const mediaType = source.media_type;
400
- const data = source.data;
401
- if (typeof mediaType !== 'string' || typeof data !== 'string' || !data) continue;
402
- blocks.push({ mediaType, data, lineIndex: i });
437
+ collectImageBlocksFromContent(obj?.message?.content, i, blocks, 1);
438
+ const attachment = obj?.attachment;
439
+ if (attachment && typeof attachment === 'object') {
440
+ collectImageBlocksFromContent(attachment.prompt, i, blocks, 1);
441
+ collectImageBlocksFromContent(attachment.content, i, blocks, 1);
403
442
  }
404
443
  } catch {
405
444
  // malformed line: skip and continue