@ngockhoale/ukit 2.3.12 → 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,66 @@
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
+
36
+ ## 2.3.13 - 2026-09-11
37
+
38
+ Vision-lane root cause, part 2: the Codex model catalog could still deny the vision lane on
39
+ every gateway machine, even after the 2.3.12 plumbing sweep. Found by a fresh end-to-end
40
+ repro (install into a clean project → fire the hook → live dispatch) plus a live gateway
41
+ probe on 2026-09-11 that returned `STATUS: OK / MODEL: unic-vision` on a machine whose
42
+ `~/.codex/model-catalog.json` omits the alias.
43
+
44
+ **P1 — the Codex model catalog could deny the Claude Code vision lane from another tool's
45
+ config.** `unic-gateway.mjs` derived `aliasAvailable` solely from
46
+ `~/.codex/model-catalog.json`: a catalog that omitted `unic-vision` — or was missing
47
+ entirely, the default on any machine that never used Codex CLI — produced
48
+ `aliasAvailable: false`, and both consumers (the vision router hint and route-task's
49
+ `resolveVisionModel`) then force-downgraded the lane to "read natively / do NOT dispatch"
50
+ or a config fallback. The catalog is a different tool's config and says nothing about what
51
+ the gateway resolves server-side for this session — the same principle TASK-007 already
52
+ established for endpoints. Fix: the catalog may now only CONFIRM the alias (new
53
+ `catalogListed` field); silence (absent, malformed, or alias-omitting catalog) leaves the
54
+ lane honored, with an informational advisory instead of a denial. The analyst agent's own
55
+ `WRONG_MODEL` self-check remains the in-band safety net: a genuinely dead alias fails
56
+ loudly and never guesses. `aliasAvailable: false` is reserved for a future source of
57
+ positive unavailability evidence, so the dormant fallback branches in both consumers are
58
+ unchanged and still covered by tests.
59
+
60
+ Tests: `tests/handoff/cycle4/unic-gateway.test.mjs` and
61
+ `tests/handoff/cycle10/vision-lane-fallback.test.mjs` rewritten for the flip (catalog
62
+ silent → lane honored; catalog missing → lane honored; listing → positive confirmation),
63
+ with the strict `=== false` fallback contract kept as a source-level case.
64
+
5
65
  ## 2.3.12 - 2026-09-11
6
66
 
7
67
  Vision-lane freeze-sweep (post-2.3.11): the four defects that stranded the vision image lane on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.12",
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
@@ -99,8 +99,11 @@ function detectPastedImageViaExtractor({ rootDir = process.cwd() } = {}) {
99
99
  * Policy (mirrors the hook side in TASK-007 / ws-g.sh):
100
100
  * - unicMode true AND aliasAvailable true -> unic-vision (today's behavior).
101
101
  * - unicMode true AND aliasAvailable false -> fall through to config fallback (do NOT force a
102
- * failing dispatch to a model the gateway itself says is unavailable); state the
103
- * unavailability in visionAdvisory so callers skip the lane and prefer native read.
102
+ * failing dispatch to a model positively reported unavailable); state the unavailability in
103
+ * visionAdvisory so callers skip the lane and prefer native read. Since the 2026-09-11
104
+ * unic-gateway contract flip, the Codex catalog can no longer produce `false` (its silence
105
+ * never denies the lane), so this branch only fires on POSITIVE unavailability evidence
106
+ * from a future source.
104
107
  * - unicMode true AND aliasAvailable missing/undefined -> keep today's behavior
105
108
  * (return unic-vision). The strict === false check is what preserves the "missing field
106
109
  * doesn't change today's behavior" contract from the task spec.
@@ -124,15 +127,15 @@ async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
124
127
  if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
125
128
  return {
126
129
  visionModel: fallbackModel.trim(),
127
- visionAdvisory: 'vision lane detected but unic-vision alias is unavailable in the gateway '
128
- + 'catalog; falling back to orchestration.modelTiers.vision.fallbackModel.',
130
+ visionAdvisory: 'vision lane detected but the unic-vision alias was positively reported '
131
+ + 'unavailable; falling back to orchestration.modelTiers.vision.fallbackModel.',
129
132
  };
130
133
  }
131
134
  return {
132
135
  visionModel: null,
133
- visionAdvisory: 'vision lane detected but unic-vision alias is unavailable in the gateway '
134
- + 'catalog and orchestration.modelTiers.vision.fallbackModel is not configured; no model '
135
- + 'was named to avoid forcing a failing dispatch — caller should prefer native read.',
136
+ visionAdvisory: 'vision lane detected but the unic-vision alias was positively reported '
137
+ + 'unavailable and orchestration.modelTiers.vision.fallbackModel is not configured; no '
138
+ + 'model was named to avoid forcing a failing dispatch — caller should prefer native read.',
136
139
  };
137
140
  }
138
141
 
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * unic-gateway.mjs (TASK-003, narrowed by TASK-007)
3
+ * unic-gateway.mjs (TASK-003, narrowed by TASK-007; alias-availability contract flipped 2026-09-11)
4
4
  *
5
5
  * Detects whether THIS Claude Code session's own outbound endpoint is pointed at the UNIC gateway
6
- * — i.e. `ANTHROPIC_BASE_URL` contains "unicjsc.com" — so the vision lane can pin `unic-vision`
7
- * when it is available in the Codex model catalog, and fall back safely when it is not.
6
+ * — i.e. `ANTHROPIC_BASE_URL` contains "unicjsc.com" — so the vision lane can pin `unic-vision`.
7
+ * The Codex model catalog can CONFIRM the alias (listing it) but its silence never denies it:
8
+ * it is another tool's config and says nothing about what the gateway resolves server-side for
9
+ * this session (live dispatch STATUS: OK on a machine whose catalog omits the alias, 2026-09-11).
8
10
  *
9
11
  * Self-contained: reads only env vars and local files. Does NOT read `.ukit/storage/config.json`,
10
12
  * so it has no dependency on TASK-001 and stays usable in Wave 1.
@@ -121,25 +123,35 @@ function probeClaudeSettings(filePath) {
121
123
  // Narrow anchored regex probe (no YAML parsing) for the omp config file's `baseUrl` key. A
122
124
  // missing file, unreadable path, or malformed YAML all degrade to "no hit" -- never throws.
123
125
  /**
124
- * Checks whether the Codex model catalog exists and, if so, whether it lists `unic-vision`.
125
- * A missing/unreadable/malformed catalog is never an error — it degrades to "unknown" and the
126
- * caller treats that as aliasAvailable: false with an explanatory advisory.
126
+ * Checks what the Codex model catalog says about `unic-vision`.
127
+ *
128
+ * 2026-09-11 CONTRACT FLIP — the catalog may CONFIRM the alias but must never DENY it.
129
+ * The catalog belongs to Codex, a different tool, and says nothing about what the UNIC
130
+ * gateway resolves server-side for a Claude Code subagent dispatch. Live evidence from
131
+ * 2026-09-11: on a machine whose ~/.codex/model-catalog.json omits the alias, dispatching
132
+ * the alias through the real gateway returned STATUS: OK with real pixel data. The old
133
+ * behaviour (missing / malformed / alias-absent catalog => aliasAvailable: false) made the
134
+ * vision lane dead by default on every gateway machine — the exact "image reading always
135
+ * errors" field report this flip closes. The analyst agent's own WRONG_MODEL self-check is
136
+ * the in-band safety net: if the alias ever stops resolving, a dispatch fails loudly and
137
+ * never guesses. `aliasAvailable: false` is therefore reserved for a future source of
138
+ * POSITIVE unavailability evidence; no such source exists today.
127
139
  */
128
140
  function checkCodexVisionAlias(catalogPath) {
129
141
  const json = safeReadJson(catalogPath);
130
142
  if (json == null) {
131
- return { checked: false, aliasAvailable: false };
143
+ return { checked: false, catalogListed: null, aliasAvailable: true };
132
144
  }
133
145
  const models = Array.isArray(json?.models)
134
146
  ? json.models
135
147
  : Array.isArray(json)
136
148
  ? json
137
149
  : [];
138
- const aliasAvailable = models.some((entry) => {
150
+ const catalogListed = models.some((entry) => {
139
151
  const slug = entry && typeof entry === 'object' ? entry.slug ?? entry.id ?? entry.name : null;
140
152
  return slug === VISION_MODEL_ALIAS;
141
153
  });
142
- return { checked: true, aliasAvailable };
154
+ return { checked: true, catalogListed, aliasAvailable: true };
143
155
  }
144
156
 
145
157
  /**
@@ -187,6 +199,7 @@ export function detectUnicGateway(options = {}) {
187
199
  sources,
188
200
  visionModel: VISION_MODEL_ALIAS,
189
201
  aliasAvailable: catalogInfo.aliasAvailable,
202
+ catalogListed: catalogInfo.catalogListed,
190
203
  };
191
204
 
192
205
  const advisoryParts = [];
@@ -195,8 +208,15 @@ export function detectUnicGateway(options = {}) {
195
208
  'No tool on this machine is pointed at the UNIC gateway (unicjsc.com); UNIC routing stays disabled.',
196
209
  );
197
210
  }
198
- if (catalogInfo.checked && !catalogInfo.aliasAvailable) {
199
- advisoryParts.push(`${VISION_MODEL_ALIAS} not listed in the Codex model catalog; vision requests may fall back.`);
211
+ // Informational only — never a lane denial (see checkCodexVisionAlias above).
212
+ if (!catalogInfo.checked) {
213
+ advisoryParts.push(
214
+ `${VISION_MODEL_ALIAS} availability is unverified locally (Codex model catalog not found); the gateway resolves the alias server-side.`,
215
+ );
216
+ } else if (!catalogInfo.catalogListed) {
217
+ advisoryParts.push(
218
+ `${VISION_MODEL_ALIAS} not listed in the local Codex model catalog; the gateway resolves the alias server-side, so the lane stays available.`,
219
+ );
200
220
  }
201
221
  if (advisoryParts.length > 0) {
202
222
  result.advisory = advisoryParts.join(' ');
@@ -223,7 +243,9 @@ function safeFallbackResult() {
223
243
  source: null,
224
244
  sources: [],
225
245
  visionModel: VISION_MODEL_ALIAS,
226
- aliasAvailable: false,
246
+ // Silence is never denial (2026-09-11 flip): an unexpected detection failure carries no
247
+ // positive unavailability evidence, so it must not deny the vision lane either.
248
+ aliasAvailable: true,
227
249
  advisory: 'unic-gateway detection failed unexpectedly; defaulting to unicMode: false.',
228
250
  };
229
251
  }