@ngockhoale/ukit 2.3.11 → 2.3.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.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.13 - 2026-09-11
|
|
6
|
+
|
|
7
|
+
Vision-lane root cause, part 2: the Codex model catalog could still deny the vision lane on
|
|
8
|
+
every gateway machine, even after the 2.3.12 plumbing sweep. Found by a fresh end-to-end
|
|
9
|
+
repro (install into a clean project → fire the hook → live dispatch) plus a live gateway
|
|
10
|
+
probe on 2026-09-11 that returned `STATUS: OK / MODEL: unic-vision` on a machine whose
|
|
11
|
+
`~/.codex/model-catalog.json` omits the alias.
|
|
12
|
+
|
|
13
|
+
**P1 — the Codex model catalog could deny the Claude Code vision lane from another tool's
|
|
14
|
+
config.** `unic-gateway.mjs` derived `aliasAvailable` solely from
|
|
15
|
+
`~/.codex/model-catalog.json`: a catalog that omitted `unic-vision` — or was missing
|
|
16
|
+
entirely, the default on any machine that never used Codex CLI — produced
|
|
17
|
+
`aliasAvailable: false`, and both consumers (the vision router hint and route-task's
|
|
18
|
+
`resolveVisionModel`) then force-downgraded the lane to "read natively / do NOT dispatch"
|
|
19
|
+
or a config fallback. The catalog is a different tool's config and says nothing about what
|
|
20
|
+
the gateway resolves server-side for this session — the same principle TASK-007 already
|
|
21
|
+
established for endpoints. Fix: the catalog may now only CONFIRM the alias (new
|
|
22
|
+
`catalogListed` field); silence (absent, malformed, or alias-omitting catalog) leaves the
|
|
23
|
+
lane honored, with an informational advisory instead of a denial. The analyst agent's own
|
|
24
|
+
`WRONG_MODEL` self-check remains the in-band safety net: a genuinely dead alias fails
|
|
25
|
+
loudly and never guesses. `aliasAvailable: false` is reserved for a future source of
|
|
26
|
+
positive unavailability evidence, so the dormant fallback branches in both consumers are
|
|
27
|
+
unchanged and still covered by tests.
|
|
28
|
+
|
|
29
|
+
Tests: `tests/handoff/cycle4/unic-gateway.test.mjs` and
|
|
30
|
+
`tests/handoff/cycle10/vision-lane-fallback.test.mjs` rewritten for the flip (catalog
|
|
31
|
+
silent → lane honored; catalog missing → lane honored; listing → positive confirmation),
|
|
32
|
+
with the strict `=== false` fallback contract kept as a source-level case.
|
|
33
|
+
|
|
34
|
+
## 2.3.12 - 2026-09-11
|
|
35
|
+
|
|
36
|
+
Vision-lane freeze-sweep (post-2.3.11): the four defects that stranded the vision image lane on
|
|
37
|
+
nearly every image are fixed, the second consumer of the same advisory now honors the fallback,
|
|
38
|
+
and the installed vision-agent file mode is corrected. The cycle10 + cycle4 scripts prove the
|
|
39
|
+
plumbing; a real UNIC round-trip dispatch is not covered by the suite (see `docs/STATUS.md`).
|
|
40
|
+
|
|
41
|
+
**P1 — `--detect` could not resolve absolute image paths outside the project, or paths wrapped
|
|
42
|
+
in quotes or trailing punctuation.** `extract-image.mjs`'s `--detect` arm accepted relative
|
|
43
|
+
paths and in-project absolutes, but the path-capture regex in `vision-router.sh` then refused an
|
|
44
|
+
out-of-project image ("did not resolve from the project root … Nothing was armed"), and
|
|
45
|
+
trailing punctuation / wrapping quotes (`'<abs>/x.png'.`, `"<abs>/x.png"`) broke the existence
|
|
46
|
+
check. Fix: `stripPathNoise()` in the extractor tries raw / soft-trailing-stripped /
|
|
47
|
+
wrapping-pair-stripped / both shapes against `fs.existsSync`; the hook-side regex now uses the
|
|
48
|
+
same tolerance. The interior of the path is never touched, and relative handling is unchanged.
|
|
49
|
+
|
|
50
|
+
**P1 — marker receipts were leaked into `images[]`, so the dispatch hint could carry a
|
|
51
|
+
`pending-*.json` path to the analyst.** `--mark-pending --json` returned each marker receipt
|
|
52
|
+
(`{ path: 'pending-<sha>.json', bytes: 0 }`) inside the same `images[]` array that was supposed
|
|
53
|
+
to carry materialized images; downstream code that filtered "bytes > 0" still sometimes passed
|
|
54
|
+
the marker through, and the dispatch hint could carry a `pending-*.json` path the analyst would
|
|
55
|
+
not have materialized. Fix: `images[]` is now the materialized-only invariant (empty in
|
|
56
|
+
`--detect` / `--mark-pending` mode; every entry has bytes > 0 and a non-`.json` extension
|
|
57
|
+
otherwise); receipts live in a new top-level `pendingMarkers[]` carrying
|
|
58
|
+
`{ path, sha, bytes: 0, … }` per detection. On-disk marker format (`pending-<sha>.json`,
|
|
59
|
+
`bytes: 0`) is untouched — only JSON presentation changed.
|
|
60
|
+
|
|
61
|
+
**P1 — the hook never forwarded the parent transcript, and default session discovery could
|
|
62
|
+
pick a newer-but-empty subagent transcript over the parent's image-bearing one.** When a
|
|
63
|
+
subagent spawn raced the paste, the subagent's empty transcript was the newest file in the
|
|
64
|
+
sessions root, so `discoverSessionFile()` returned it and the analyst got `imageCount: 0`. The
|
|
65
|
+
dispatch hint also lacked any `--session` argument, so the analyst could not materialize from
|
|
66
|
+
the parent transcript even when one was named in the payload. Fix: `discoverSessionFile()`
|
|
67
|
+
honors `CLAUDE_CONFIG_DIR` when set, calls `fs.realpathSync` so the macOS `/var` ↔ `/private/var`
|
|
68
|
+
symlinks do not drift the slug, and runs a bounded newest-first scan (`DISCOVERY_SCAN_BOUND = 5`)
|
|
69
|
+
with a cheap 64 KiB `transcriptHasImageHead()` peek — never an unbounded walk or full-content
|
|
70
|
+
parse. When nothing image-bearing is found, the script emits an explicit `STATUS: NO_IMAGE
|
|
71
|
+
scanned=<names>` (and a JSON line first under `--json`) instead of a fake success. The hook
|
|
72
|
+
hint now always carries `--session <payload.transcript_path>` whenever the payload provides one;
|
|
73
|
+
explicit `--session` still bypasses discovery and the NO_IMAGE branch entirely.
|
|
74
|
+
|
|
75
|
+
**P1 — when the vision lane was unavailable, both consumers forced a failing dispatch instead
|
|
76
|
+
of degrading to native read.** `unic-gateway.mjs`'s `aliasAvailable: false` advisory was
|
|
77
|
+
ignored by `resolveVisionModel` in `route-task.mjs` and by the hook's hint builder, so a
|
|
78
|
+
gateway without vision routed the analyst task with `[model: unic-vision]` and the agent model
|
|
79
|
+
itself had to read the image, only to fail mid-dispatch. Fix: both consumers now honor
|
|
80
|
+
`aliasAvailable === false` — `resolveVisionModel` returns a non-`unic-vision` (native/main-
|
|
81
|
+
model) lane and states the unavailability in the routed output; the hook hint omits
|
|
82
|
+
`[model: unic-vision]`, says `vision lane unavailable`, and instructs a native read instead of
|
|
83
|
+
dispatching the analyst. The missing-field case preserves today's default; the
|
|
84
|
+
`unicMode === false` short-circuit is unchanged. Also: installed
|
|
85
|
+
`.claude/agents/ukit-vision-analyst.md` is now mode 0644 (template stays `100644` in git) — a
|
|
86
|
+
stale 755 from a prior install that was never regenerated by `ukit install` because
|
|
87
|
+
`copyFileSafe` preserves template mode.
|
|
88
|
+
|
|
5
89
|
## 2.3.11 - 2026-09-11
|
|
6
90
|
|
|
7
91
|
Freeze-sweep wave 11 (post-2.3.10): the two deferred liveness candidates fixed via TDD, plus
|
package/package.json
CHANGED
|
@@ -79,7 +79,68 @@ const { pathToFileURL } = require('url');
|
|
|
79
79
|
// URL images are checked first and stripped out so the local-path regex never
|
|
80
80
|
// re-matches the tail of a URL (case c is a hint-only lane — no download here).
|
|
81
81
|
const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
|
|
82
|
-
|
|
82
|
+
// Local path regex deliberately tolerates a trailing run of punctuation /
|
|
83
|
+
// brackets / quotes after the extension. A bare \b would stop at the first
|
|
84
|
+
// closing quote and drop `'.` from `'/tmp/x.png'.`, leaving an unpairable
|
|
85
|
+
// leading quote for stripPathNoise to fail on. The char class [PUNCT] is
|
|
86
|
+
// hand-picked: it never matches a word char, so it still refuses to consume
|
|
87
|
+
// `image.pngbar` (false image) — the class doesn't include letters/digits.
|
|
88
|
+
// The negative lookahead `(?![A-Za-z0-9])` between the extension and the
|
|
89
|
+
// punct class is what actually enforces "extension terminates at a
|
|
90
|
+
// non-alphanumeric boundary": without it, `\S+` would backtrack past
|
|
91
|
+
// `image.png` inside `image.pngbar` and emit a false positive match (which
|
|
92
|
+
// then trips the unresolved-path advisory on every prompt of every
|
|
93
|
+
// installed project). The lookahead keeps the trailing-punctuation
|
|
94
|
+
// tolerance unchanged for real paths.
|
|
95
|
+
const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)(?![A-Za-z0-9])[()\[\]{}.,;:!?'"`]*/gi;
|
|
96
|
+
|
|
97
|
+
// Mirror of extract-image.mjs's stripPathNoise() — the regex above greedily
|
|
98
|
+
// captures trailing punctuation/quotes an LLM sometimes leaves on a named
|
|
99
|
+
// path (`/tmp/x.png'.`, `(/tmp/x.png).`, `"/tmp/x.png"`). Without this
|
|
100
|
+
// pre-clean the extractor receives `'/tmp/x.png` (leading quote, no close
|
|
101
|
+
// pair) and silently skips it. Kept here as a small inline copy rather than
|
|
102
|
+
// a cross-file import — the contract is a few lines, the cost of drift is
|
|
103
|
+
// small, and the hook must stay self-contained (no module resolution).
|
|
104
|
+
function stripPathNoise(raw) {
|
|
105
|
+
const value = String(raw ?? '').trim();
|
|
106
|
+
if (!value) return value;
|
|
107
|
+
const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
|
|
108
|
+
const trailingSoftPunctRe = /[.,;:]+$/;
|
|
109
|
+
const trailingAnyPunctRe = /[.,;:)\]}"']+$/;
|
|
110
|
+
const candidates = [];
|
|
111
|
+
candidates.push(value);
|
|
112
|
+
const noSoft = value.replace(trailingSoftPunctRe, '');
|
|
113
|
+
if (noSoft !== value) candidates.push(noSoft);
|
|
114
|
+
if (value.length >= 2) {
|
|
115
|
+
const o = value[0];
|
|
116
|
+
const c = value[value.length - 1];
|
|
117
|
+
if (pairs[o] === c) candidates.push(value.slice(1, -1));
|
|
118
|
+
}
|
|
119
|
+
if (value.length >= 2) {
|
|
120
|
+
const o = value[0];
|
|
121
|
+
const c = value[value.length - 1];
|
|
122
|
+
if (pairs[o] === c) {
|
|
123
|
+
const inner = value.slice(1, -1);
|
|
124
|
+
const innerTrim = inner.replace(trailingAnyPunctRe, '');
|
|
125
|
+
if (innerTrim !== inner) candidates.push(innerTrim);
|
|
126
|
+
if (innerTrim !== value.slice(1, -1)) candidates.push(innerTrim);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (noSoft.length >= 2) {
|
|
130
|
+
const o = noSoft[0];
|
|
131
|
+
const c = noSoft[noSoft.length - 1];
|
|
132
|
+
if (pairs[o] === c) candidates.push(noSoft.slice(1, -1));
|
|
133
|
+
}
|
|
134
|
+
for (const c of candidates) {
|
|
135
|
+
if (!c) continue;
|
|
136
|
+
try {
|
|
137
|
+
if (fs.existsSync(path.resolve(c))) return c;
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return noSoft !== value ? noSoft : value;
|
|
143
|
+
}
|
|
83
144
|
|
|
84
145
|
const urlMatches = promptText.match(URL_IMAGE_RE) || [];
|
|
85
146
|
const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
|
|
@@ -100,7 +161,10 @@ const { pathToFileURL } = require('url');
|
|
|
100
161
|
args.push('--session-id', payload.session_id.trim());
|
|
101
162
|
}
|
|
102
163
|
for (const ref of [...localMatches, ...urlMatches]) {
|
|
103
|
-
|
|
164
|
+
// Mirror extractor's resolveRefs input contract: the LLM sometimes leaves
|
|
165
|
+
// trailing punctuation/quotes on a path named in a prompt. Clean before
|
|
166
|
+
// handing off so the hook and the extractor agree on the ref string.
|
|
167
|
+
args.push('--ref', stripPathNoise(ref));
|
|
104
168
|
}
|
|
105
169
|
try {
|
|
106
170
|
const out = execFileSync('node', args, {
|
|
@@ -114,24 +178,31 @@ const { pathToFileURL } = require('url');
|
|
|
114
178
|
}
|
|
115
179
|
}
|
|
116
180
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
181
|
+
// TASK-006 contract: in --mark-pending --json mode `images[]` is the
|
|
182
|
+
// MATERIALIZED-ONLY shape and stays empty; armed markers live in
|
|
183
|
+
// `pendingMarkers[]` with `{path, sha, bytes: 0, source, ref}`. The hook
|
|
184
|
+
// keys its armed/not-armed decision off `pendingMarkers[]`, never off
|
|
185
|
+
// `images[]` — the latter would silently report zero armed for the
|
|
186
|
+
// mark-pending path that is the entire point of this hook.
|
|
187
|
+
const pendingMarkers = Array.isArray(extractorJson?.pendingMarkers)
|
|
188
|
+
? extractorJson.pendingMarkers
|
|
189
|
+
: [];
|
|
121
190
|
const analyzed = Array.isArray(extractorJson?.alreadyAnalyzed) ? extractorJson.alreadyAnalyzed : [];
|
|
122
191
|
const sessionId = typeof extractorJson?.sessionId === 'string' && extractorJson.sessionId
|
|
123
192
|
? extractorJson.sessionId
|
|
124
193
|
: '';
|
|
125
|
-
const armedCount =
|
|
126
|
-
const armedRefs = new Set(
|
|
194
|
+
const armedCount = pendingMarkers.length;
|
|
195
|
+
const armedRefs = new Set(
|
|
196
|
+
pendingMarkers.filter((m) => typeof m?.ref === 'string').map((m) => m.ref)
|
|
197
|
+
);
|
|
127
198
|
const analyzedRefs = new Set(analyzed.filter((a) => typeof a?.ref === 'string').map((a) => a.ref));
|
|
128
199
|
const unresolvedLocal = localMatches.filter(
|
|
129
200
|
(m) => !armedRefs.has(m.trim()) && !analyzedRefs.has(m.trim())
|
|
130
201
|
);
|
|
131
202
|
const cases = [];
|
|
132
|
-
if (
|
|
133
|
-
if (
|
|
134
|
-
if (
|
|
203
|
+
if (pendingMarkers.some((m) => !m?.source)) cases.push('pasted image');
|
|
204
|
+
if (pendingMarkers.some((m) => m?.source === 'path')) cases.push('local file path');
|
|
205
|
+
if (pendingMarkers.some((m) => m?.source === 'url')) cases.push('image URL');
|
|
135
206
|
|
|
136
207
|
if (armedCount === 0) {
|
|
137
208
|
// No NEW markers. Only speak up when a named path never resolved at all —
|
|
@@ -148,27 +219,77 @@ const { pathToFileURL } = require('url');
|
|
|
148
219
|
return;
|
|
149
220
|
}
|
|
150
221
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
222
|
+
// Forward the parent transcript path so step 1 below targets the parent
|
|
223
|
+
// transcript, NOT whatever transcript happens to live in the parent's
|
|
224
|
+
// session dir after a subagent spawn (which is empty and silently zero).
|
|
225
|
+
// No path here ever came from `pendingMarkers[].path` — that field is a
|
|
226
|
+
// pending-<sha>.json marker receipt and must never reach the hint.
|
|
227
|
+
const transcriptPath = typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()
|
|
228
|
+
? payload.transcript_path.trim()
|
|
229
|
+
: '';
|
|
230
|
+
const materializeArgv = ['node', '.claude/ukit/index/extract-image.mjs', '--json'];
|
|
231
|
+
if (transcriptPath) {
|
|
232
|
+
materializeArgv.push('--session', transcriptPath);
|
|
233
|
+
}
|
|
234
|
+
const materializeCmd = materializeArgv
|
|
235
|
+
.map((tok) => (/[\s'"\\$`]/.test(tok) ? `'${tok.replace(/'/g, `'\\''`)}'` : tok))
|
|
236
|
+
.join(' ');
|
|
237
|
+
|
|
238
|
+
// Only unicMode true or null reach here. Resolve the gateway advisory ONCE
|
|
239
|
+
// here so the hint can pick a lane-aware shape. Missing `aliasAvailable`
|
|
240
|
+
// (older gateway) keeps today's behavior; strict `aliasAvailable === false`
|
|
241
|
+
// forces the native-read fallback rather than dispatching a specialist that
|
|
242
|
+
// the gateway itself says is unavailable.
|
|
243
|
+
let gatewayResult = null;
|
|
156
244
|
if (unicMode === true) {
|
|
157
245
|
try {
|
|
158
246
|
const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
|
|
159
247
|
const mod = await import(pathToFileURL(gatewayPath).href);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
|
|
248
|
+
if (typeof mod.detectUnicGateway === 'function') {
|
|
249
|
+
gatewayResult = mod.detectUnicGateway({ rootDir: projectRoot });
|
|
163
250
|
}
|
|
164
251
|
} catch {
|
|
165
|
-
|
|
252
|
+
gatewayResult = null;
|
|
166
253
|
}
|
|
167
254
|
}
|
|
255
|
+
const aliasAvailable = gatewayResult?.aliasAvailable;
|
|
256
|
+
const laneUnavailable = aliasAvailable === false;
|
|
257
|
+
|
|
258
|
+
if (laneUnavailable) {
|
|
259
|
+
// Lane cannot be honored — say so, prefer native read, do NOT instruct
|
|
260
|
+
// dispatching the analyst (a forced dispatch to a model the gateway
|
|
261
|
+
// itself reports as unavailable is the failure mode that closed the
|
|
262
|
+
// vision lane in the field).
|
|
263
|
+
const lines = [
|
|
264
|
+
`UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
|
|
265
|
+
'Vision lane unavailable (unic-vision alias is not in the gateway catalog).',
|
|
266
|
+
'Read the image directly with your VERIFIED native vision — do NOT dispatch',
|
|
267
|
+
`ukit-vision-analyst: the gateway reports the alias as unavailable, and a`,
|
|
268
|
+
'forced dispatch would fail at the provider.',
|
|
269
|
+
'',
|
|
270
|
+
`Materialize with: ${materializeCmd}`,
|
|
271
|
+
'Then Read each ABSOLUTE path in images[].path as text (subagents do NOT',
|
|
272
|
+
`inherit image blocks; they can only Read files). For pasted images the`,
|
|
273
|
+
`--session arg above is required — without it the extractor picks the`,
|
|
274
|
+
'newest transcript in the session dir, which after a subagent spawn is',
|
|
275
|
+
'the empty subagent transcript.',
|
|
276
|
+
];
|
|
277
|
+
if (sessionId) {
|
|
278
|
+
lines.push(`Markers armed under sessionId: ${sessionId}; receipts land as analyzed-<sha>.json.`);
|
|
279
|
+
}
|
|
280
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
281
|
+
process.exit(0);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Lane honored (or unverified — missing aliasAvailable defaults to today's
|
|
286
|
+
// unic-vision dispatch). Capability-framed, never provider identity: the
|
|
287
|
+
// remedy is "don't guess — use a verified reader", which holds whether the
|
|
288
|
+
// active model reads images natively or hands off to the specialist.
|
|
289
|
+
const unicNote = (unicMode === true && gatewayResult?.visionModel)
|
|
290
|
+
? ` (UNIC gateway active — ${gatewayResult.visionModel} routes through it.)`
|
|
291
|
+
: '';
|
|
168
292
|
|
|
169
|
-
// Capability-framed, never provider identity: the remedy is "don't guess — use a
|
|
170
|
-
// verified reader", which holds whether the active model reads images natively or
|
|
171
|
-
// must hand off to the specialist.
|
|
172
293
|
const reasonLines = [
|
|
173
294
|
'Advisory: never guess at image contents. If the active model has VERIFIED native',
|
|
174
295
|
'vision for these images it may read them directly; otherwise dispatch the specialist',
|
|
@@ -178,7 +299,9 @@ const { pathToFileURL } = require('url');
|
|
|
178
299
|
const lines = [
|
|
179
300
|
`UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
|
|
180
301
|
...reasonLines,
|
|
181
|
-
|
|
302
|
+
` 1. ${materializeCmd}`,
|
|
303
|
+
' Materialize the images to disk. With --session the extractor targets the',
|
|
304
|
+
' PARENT transcript explicitly, so a subagent spawn cannot drop the image.',
|
|
182
305
|
' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
|
|
183
306
|
' Send the ABSOLUTE paths from images[].path as TEXT (subagents do NOT inherit',
|
|
184
307
|
' image blocks; they can only Read files). Include the task envelope: the ORIGINAL',
|
|
@@ -189,7 +312,7 @@ const { pathToFileURL } = require('url');
|
|
|
189
312
|
if (sessionId) {
|
|
190
313
|
lines.push(` Markers armed under sessionId: ${sessionId} (receipts: analyzed-<sha>.json).`);
|
|
191
314
|
}
|
|
192
|
-
if (
|
|
315
|
+
if (pendingMarkers.some((m) => m?.source === 'url')) {
|
|
193
316
|
lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
|
|
194
317
|
lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
|
|
195
318
|
}
|
|
@@ -67,6 +67,76 @@ const EXT_MEDIA = {
|
|
|
67
67
|
bmp: 'image/bmp',
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Strips wrapping quotes / matching parens / trailing punctuation that an LLM
|
|
72
|
+
* sometimes leaves on a path named in a prompt (e.g. `/tmp/x.png'.`,
|
|
73
|
+
* `(/tmp/x.png).`, `"/tmp/x.png"`). The interior of the path is left alone.
|
|
74
|
+
*
|
|
75
|
+
* Strategy: try the original and several cleaned shapes; return the first one
|
|
76
|
+
* that `fs.existsSync`s. Order is chosen to cover the common artefacts
|
|
77
|
+
* without consuming a wrapping pair's close-bracket as "trailing punctuation":
|
|
78
|
+
* 1. the raw value (a clean absolute path needs no stripping)
|
|
79
|
+
* 2. strip a single trailing run of `.` `,` `;` `:` (cheap; covers `path.`)
|
|
80
|
+
* 3. strip a matching wrapping pair (covers `"path"` and `'path'`)
|
|
81
|
+
* 4. strip the wrapping pair AND any leftover trailing punct
|
|
82
|
+
* (covers `"/tmp/x.png".` and `'/tmp/x.png',`)
|
|
83
|
+
* 5. strip a single trailing `)` `]` `}` (the open was a wrap; the close
|
|
84
|
+
* got left over after a stray final char) then strip the matching pair
|
|
85
|
+
* (covers `(/tmp/x.png).` where the `.` already ate the `)`)
|
|
86
|
+
*/
|
|
87
|
+
function stripPathNoise(raw) {
|
|
88
|
+
const value = String(raw ?? '').trim();
|
|
89
|
+
if (!value) return value;
|
|
90
|
+
const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
|
|
91
|
+
const trailingSoftPunctRe = /[.,;:]+$/; // won't eat a wrapping pair's close
|
|
92
|
+
const trailingAnyPunctRe = /[.,;:)\]}"']+$/;
|
|
93
|
+
const candidates = [];
|
|
94
|
+
|
|
95
|
+
candidates.push(value);
|
|
96
|
+
// (2) drop a soft trailing punct run only — won't consume a wrap-close.
|
|
97
|
+
const noSoft = value.replace(trailingSoftPunctRe, '');
|
|
98
|
+
if (noSoft !== value) candidates.push(noSoft);
|
|
99
|
+
// (3) drop a matching wrapping pair if both ends match.
|
|
100
|
+
if (value.length >= 2) {
|
|
101
|
+
const o = value[0];
|
|
102
|
+
const c = value[value.length - 1];
|
|
103
|
+
if (pairs[o] === c) candidates.push(value.slice(1, -1));
|
|
104
|
+
}
|
|
105
|
+
// (4) drop a wrapping pair, THEN any leftover trailing punct.
|
|
106
|
+
if (value.length >= 2) {
|
|
107
|
+
const o = value[0];
|
|
108
|
+
const c = value[value.length - 1];
|
|
109
|
+
if (pairs[o] === c) {
|
|
110
|
+
const inner = value.slice(1, -1);
|
|
111
|
+
const innerTrim = inner.replace(trailingAnyPunctRe, '');
|
|
112
|
+
if (innerTrim !== inner) candidates.push(innerTrim);
|
|
113
|
+
if (innerTrim !== value.slice(1, -1)) candidates.push(innerTrim);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// (5) drop a soft trailing punct, THEN a matching wrapping pair (covers
|
|
117
|
+
// `(/tmp/x.png).` — soft strip eats `.` then pair strip takes `()`).
|
|
118
|
+
if (noSoft.length >= 2) {
|
|
119
|
+
const o = noSoft[0];
|
|
120
|
+
const c = noSoft[noSoft.length - 1];
|
|
121
|
+
if (pairs[o] === c) candidates.push(noSoft.slice(1, -1));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const c of candidates) {
|
|
126
|
+
if (!c || seen.has(c)) continue;
|
|
127
|
+
seen.add(c);
|
|
128
|
+
try {
|
|
129
|
+
if (fs.existsSync(path.resolve(c))) return c;
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// No candidate resolved to a real file. Return the soft-stripped shape so
|
|
135
|
+
// the caller at least gets a deterministic string and the existing "file
|
|
136
|
+
// not found" branch reports the failure on the cleaned shape.
|
|
137
|
+
return noSoft !== value ? noSoft : value;
|
|
138
|
+
}
|
|
139
|
+
|
|
70
140
|
/**
|
|
71
141
|
* Turns --ref values (local paths and URLs named in the prompt) into markers.
|
|
72
142
|
*
|
|
@@ -76,24 +146,30 @@ const EXT_MEDIA = {
|
|
|
76
146
|
* analyzed-<sha> by filename and never recomputes it. Still routed through
|
|
77
147
|
* crypto.createHash here so this file remains the single owner of the hash.
|
|
78
148
|
*
|
|
149
|
+
* Tolerates absolute paths OUTSIDE the project root (a vision repro sometimes
|
|
150
|
+
* points at a file outside `process.cwd()`), and strips wrapping quotes / trailing
|
|
151
|
+
* punctuation that an LLM may leave on a path named in a prompt — see
|
|
152
|
+
* stripPathNoise() above.
|
|
153
|
+
*
|
|
79
154
|
* Silently drops anything that is not an image ref or cannot be read: this feeds a
|
|
80
155
|
* hook that must never wedge a prompt.
|
|
81
156
|
*/
|
|
82
157
|
export function resolveRefs(values) {
|
|
83
158
|
const out = [];
|
|
84
159
|
const seen = new Set();
|
|
85
|
-
for (const
|
|
86
|
-
const
|
|
160
|
+
for (const raw of values ?? []) {
|
|
161
|
+
const cleaned = stripPathNoise(raw);
|
|
162
|
+
const match = REF_EXT_RE.exec(cleaned);
|
|
87
163
|
if (!match) continue;
|
|
88
164
|
const mediaType = EXT_MEDIA[match[1].toLowerCase()] ?? 'application/octet-stream';
|
|
89
|
-
const isUrl = /^https?:\/\//i.test(
|
|
165
|
+
const isUrl = /^https?:\/\//i.test(cleaned);
|
|
90
166
|
let sha;
|
|
91
167
|
if (isUrl) {
|
|
92
|
-
sha = crypto.createHash('sha256').update(
|
|
168
|
+
sha = crypto.createHash('sha256').update(cleaned, 'utf8').digest('hex');
|
|
93
169
|
} else {
|
|
94
170
|
let bytes;
|
|
95
171
|
try {
|
|
96
|
-
bytes = fs.readFileSync(path.resolve(
|
|
172
|
+
bytes = fs.readFileSync(path.resolve(cleaned));
|
|
97
173
|
} catch {
|
|
98
174
|
continue; // path named in the prompt but not present on disk: nothing to analyse
|
|
99
175
|
}
|
|
@@ -102,7 +178,7 @@ export function resolveRefs(values) {
|
|
|
102
178
|
}
|
|
103
179
|
if (seen.has(sha)) continue;
|
|
104
180
|
seen.add(sha);
|
|
105
|
-
out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value });
|
|
181
|
+
out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value: cleaned });
|
|
106
182
|
}
|
|
107
183
|
return out;
|
|
108
184
|
}
|
|
@@ -149,21 +225,116 @@ function parseArgs(argv) {
|
|
|
149
225
|
}
|
|
150
226
|
|
|
151
227
|
function slugifyCwd(cwd) {
|
|
152
|
-
|
|
228
|
+
// realpathSync collapses `/var` ↔ `/private/var` symlinks (and any other
|
|
229
|
+
// platform-specific canonicalisation) so the slug we compute here is the
|
|
230
|
+
// same string the Claude Code daemon uses to name the same sessions dir.
|
|
231
|
+
// Without this, on macOS process.cwd() can be `/private/var/folders/...`
|
|
232
|
+
// while the on-disk dir the daemon queries is `/var/folders/...`, and the
|
|
233
|
+
// slugs drift apart — silent miss.
|
|
234
|
+
let canonical = cwd;
|
|
235
|
+
try {
|
|
236
|
+
canonical = fs.realpathSync(cwd);
|
|
237
|
+
} catch {
|
|
238
|
+
// cwd may not exist (rare, e.g. a deleted-then-recreated cwd); fall back
|
|
239
|
+
// to the raw path so the script still produces a deterministic slug.
|
|
240
|
+
}
|
|
241
|
+
return canonical.replace(/[^a-zA-Z0-9]/g, '-');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How many newest candidates to scan before giving up on default discovery.
|
|
246
|
+
*
|
|
247
|
+
* The vision failure we're fixing here had the parent Claude Code spawning a
|
|
248
|
+
* subagent whose own transcript is the newest file in the sessions dir — but
|
|
249
|
+
* the subagent's transcript has zero images, while the parent's (older) one
|
|
250
|
+
* has the pasted image. Picking the *very* newest mtime alone returns the
|
|
251
|
+
* subagent's empty transcript and silently drops the image.
|
|
252
|
+
*
|
|
253
|
+
* The fix is a bounded newest-first walk: scan at most the N newest .jsonl
|
|
254
|
+
* candidates and pick the first one that actually contains an image block. N
|
|
255
|
+
* is kept small on purpose: this hook fires on every UserPromptSubmit, so we
|
|
256
|
+
* refuse to do an unbounded directory walk or a full-content parse of every
|
|
257
|
+
* candidate. Pinned via DISCOVERY_SCAN_BOUND so the test suite can verify
|
|
258
|
+
* the bound is enforced (TASK-006 case 7).
|
|
259
|
+
*/
|
|
260
|
+
const DISCOVERY_SCAN_BOUND = 5;
|
|
261
|
+
|
|
262
|
+
/** Cheap "does this transcript look like it has any image blocks?" check.
|
|
263
|
+
*
|
|
264
|
+
* Reads at most 64 KiB from the head of the file (peeks via fs.read with a
|
|
265
|
+
* bounded length — no full-content parse, no JSON.parse) and looks for the
|
|
266
|
+
* `"type":"image"` JSON property the extractor uses to recognise pasted
|
|
267
|
+
* image blocks. False negatives are tolerated: the caller treats a miss as
|
|
268
|
+
* "not image-bearing" and moves on to the next candidate. False positives
|
|
269
|
+
* are NOT tolerated silently: the caller MUST confirm with a full parse
|
|
270
|
+
* (extractImageBlocks), because a transcript's text or sibling keys can
|
|
271
|
+
* legitimately contain the literal substring `"type":"image"` without any
|
|
272
|
+
* real image block — see the fall-through in discoverSessionFile().
|
|
273
|
+
*/
|
|
274
|
+
function transcriptHasImageHead(filePath) {
|
|
275
|
+
let fd = null;
|
|
276
|
+
try {
|
|
277
|
+
fd = fs.openSync(filePath, 'r');
|
|
278
|
+
const stats = fs.fstatSync(fd);
|
|
279
|
+
const bytesToRead = Math.min(stats.size, 64 * 1024);
|
|
280
|
+
if (bytesToRead <= 0) return false;
|
|
281
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
282
|
+
fs.readSync(fd, buf, 0, bytesToRead, 0);
|
|
283
|
+
// The extractor emits JSONL with each line as one JSON object; the
|
|
284
|
+
// pasted-image property is `"type":"image"` (no spaces — same shape
|
|
285
|
+
// Claude Code writes today). Match both `"type":"image"` and the
|
|
286
|
+
// canonical-with-spaces shape for resilience.
|
|
287
|
+
return buf.includes(Buffer.from('"type":"image"'))
|
|
288
|
+
|| buf.includes(Buffer.from('"type": "image"'));
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
} finally {
|
|
292
|
+
if (fd !== null) {
|
|
293
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
153
296
|
}
|
|
154
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Pick a transcript .jsonl to scan for images.
|
|
300
|
+
*
|
|
301
|
+
* Returns `{ path, scanned }` where `scanned` is the ordered list of
|
|
302
|
+
* candidates the bounded walk actually inspected (newest-first, capped at
|
|
303
|
+
* DISCOVERY_SCAN_BOUND). On success `path` is the first candidate whose
|
|
304
|
+
* full JSONL parse yielded at least one image block. On failure `path` is
|
|
305
|
+
* `null` and `scanned` still carries the candidates that were checked so
|
|
306
|
+
* the caller can emit an explicit `STATUS: NO_IMAGE` message that names
|
|
307
|
+
* them.
|
|
308
|
+
*
|
|
309
|
+
* The head-peek is a cheap prefilter, not a promise. A transcript can pass
|
|
310
|
+
* the head-peek (its raw bytes contain the literal substring `"type":"image"`)
|
|
311
|
+
* and still parse to ZERO real image blocks — e.g., a log line that quotes
|
|
312
|
+
* the JSON shape, or a sibling top-level key/value pair where `"type":"image"`
|
|
313
|
+
* appears as structural bytes. In that case we fall through to the next
|
|
314
|
+
* bounded candidate instead of early-stopping. Without this, main() prints
|
|
315
|
+
* a silent imageCount: 0 and never reaches the explicit NO_IMAGE branch the
|
|
316
|
+
* vision router relies on to tell "tried, no images" from a real failure.
|
|
317
|
+
*
|
|
318
|
+
* The sessions root honours `CLAUDE_CONFIG_DIR` when set (the standard
|
|
319
|
+
* Claude Code env var) so contributors / tests can redirect the lookup
|
|
320
|
+
* without touching `~/.claude`. Default stays `os.homedir()/.claude` so
|
|
321
|
+
* production behaviour is unchanged.
|
|
322
|
+
*/
|
|
155
323
|
function discoverSessionFile(cwd) {
|
|
156
324
|
const slug = slugifyCwd(cwd);
|
|
157
|
-
const
|
|
325
|
+
const configRoot = process.env.CLAUDE_CONFIG_DIR
|
|
326
|
+
? path.resolve(process.env.CLAUDE_CONFIG_DIR)
|
|
327
|
+
: path.join(os.homedir(), '.claude');
|
|
328
|
+
const dir = path.join(configRoot, 'projects', slug);
|
|
329
|
+
|
|
158
330
|
let entries;
|
|
159
331
|
try {
|
|
160
332
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
161
333
|
} catch {
|
|
162
|
-
return null;
|
|
334
|
+
return { path: null, scanned: [] };
|
|
163
335
|
}
|
|
164
336
|
|
|
165
|
-
|
|
166
|
-
let newestMtimeMs = -Infinity;
|
|
337
|
+
const candidates = [];
|
|
167
338
|
for (const entry of entries) {
|
|
168
339
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
169
340
|
const full = path.join(dir, entry.name);
|
|
@@ -173,12 +344,31 @@ function discoverSessionFile(cwd) {
|
|
|
173
344
|
} catch {
|
|
174
345
|
continue;
|
|
175
346
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
347
|
+
candidates.push({ full, mtimeMs: stat.mtimeMs });
|
|
348
|
+
}
|
|
349
|
+
// Newest first, bounded.
|
|
350
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
351
|
+
const bounded = candidates.slice(0, DISCOVERY_SCAN_BOUND);
|
|
352
|
+
const scanned = bounded.map((c) => path.basename(c.full));
|
|
353
|
+
|
|
354
|
+
for (const candidate of bounded) {
|
|
355
|
+
if (!transcriptHasImageHead(candidate.full)) continue;
|
|
356
|
+
// The head-peek is a cheap prefilter, not a promise: a transcript can
|
|
357
|
+
// contain the literal substring `"type":"image"` in prose (e.g., a log
|
|
358
|
+
// line that quotes the JSON shape, or a sibling top-level key/value pair
|
|
359
|
+
// where `"type":"image"` appears as structural bytes) yet parse to ZERO
|
|
360
|
+
// real image blocks. The full JSONL parse is the gate — a zero-block
|
|
361
|
+
// result must fall through to the next bounded candidate instead of
|
|
362
|
+
// early-stopping. Otherwise main() prints a silent imageCount: 0 and
|
|
363
|
+
// never reaches the explicit NO_IMAGE branch the vision router relies
|
|
364
|
+
// on to tell "tried, no images" from a real failure.
|
|
365
|
+
const lines = readLines(candidate.full);
|
|
366
|
+
const blocks = extractImageBlocks(lines);
|
|
367
|
+
if (blocks.length > 0) {
|
|
368
|
+
return { path: candidate.full, scanned };
|
|
179
369
|
}
|
|
180
370
|
}
|
|
181
|
-
return
|
|
371
|
+
return { path: null, scanned };
|
|
182
372
|
}
|
|
183
373
|
|
|
184
374
|
function readLines(filePath) {
|
|
@@ -302,11 +492,35 @@ function printResult(opts, result) {
|
|
|
302
492
|
}
|
|
303
493
|
}
|
|
304
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Surface a non-silent refusal when default discovery couldn't find an
|
|
497
|
+
* image-bearing transcript. The caller (vision-router.sh) relies on this to
|
|
498
|
+
* distinguish "nothing to analyse" from a real failure, so we always print
|
|
499
|
+
* the message — both in `--json` (via a STATUS: NO_IMAGE marker line that
|
|
500
|
+
* the contract layer can read) and in human-readable mode.
|
|
501
|
+
*/
|
|
502
|
+
function emitNoImage(scanned, sessionId, opts) {
|
|
503
|
+
const names = scanned.length > 0 ? scanned.join(', ') : '(empty)';
|
|
504
|
+
if (opts.json) {
|
|
505
|
+
// Two-line shape: the first is machine-parseable (imageCount: 0, images: []),
|
|
506
|
+
// the second is the explicit refusal marker so callers/tests can assert on it
|
|
507
|
+
// without parsing the array contents.
|
|
508
|
+
console.log(JSON.stringify({ imageCount: 0, sessionId, images: [] }));
|
|
509
|
+
console.log(`STATUS: NO_IMAGE scanned=${names}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
console.log(`STATUS: NO_IMAGE scanned=${names}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
305
515
|
function main() {
|
|
306
516
|
const opts = parseArgs(process.argv.slice(2));
|
|
307
517
|
const cwd = process.cwd();
|
|
308
518
|
|
|
309
|
-
const
|
|
519
|
+
const discovery = opts.session
|
|
520
|
+
? { path: path.resolve(opts.session), scanned: [] }
|
|
521
|
+
: discoverSessionFile(cwd);
|
|
522
|
+
const sessionPath = discovery.path;
|
|
523
|
+
const scannedNames = discovery.scanned;
|
|
310
524
|
const outDir = opts.out
|
|
311
525
|
? path.resolve(opts.out)
|
|
312
526
|
: path.join(cwd, '.ukit', 'storage', 'cache', 'vision');
|
|
@@ -314,7 +528,23 @@ function main() {
|
|
|
314
528
|
const sessionId = opts.sessionId
|
|
315
529
|
|| (sessionPath ? path.basename(sessionPath, path.extname(sessionPath)) : 'default');
|
|
316
530
|
|
|
317
|
-
|
|
531
|
+
// Default discovery could not find an image-bearing transcript within the
|
|
532
|
+
// bounded scan — surface an explicit refusal instead of a silent zero so
|
|
533
|
+
// the caller can tell "tried, no images" from a real failure. Explicit
|
|
534
|
+
// --session still bypasses this: the caller knows what they asked for.
|
|
535
|
+
if (!sessionPath) {
|
|
536
|
+
if (scannedNames.length > 0) {
|
|
537
|
+
emitNoImage(scannedNames, sessionId, opts);
|
|
538
|
+
} else {
|
|
539
|
+
// No candidates at all (sessions dir missing or empty). Preserve the
|
|
540
|
+
// original zero-output behaviour so existing callers don't see a new
|
|
541
|
+
// refusal shape for a pre-existing "no home / no projects" case.
|
|
542
|
+
printResult(opts, { imageCount: 0, sessionId, images: [] });
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const lines = readLines(sessionPath);
|
|
318
548
|
const blocks = extractImageBlocks(lines);
|
|
319
549
|
const markerDir = path.join(outDir, sessionId);
|
|
320
550
|
// selectImages() picks the most recent N images from the WHOLE transcript, so a resolved
|
|
@@ -384,7 +614,16 @@ function main() {
|
|
|
384
614
|
}
|
|
385
615
|
}
|
|
386
616
|
pruneMarkerDirs(outDir);
|
|
387
|
-
|
|
617
|
+
// images[] invariant: marker receipts (bytes: 0, .json path) NEVER belong
|
|
618
|
+
// here — they live in pendingMarkers[]. images[] is the materialized-only
|
|
619
|
+
// shape; detect/mark-pending mode keeps it empty by definition.
|
|
620
|
+
printResult(opts, {
|
|
621
|
+
imageCount: written.length,
|
|
622
|
+
sessionId,
|
|
623
|
+
images: [],
|
|
624
|
+
pendingMarkers: written,
|
|
625
|
+
alreadyAnalyzed,
|
|
626
|
+
});
|
|
388
627
|
return;
|
|
389
628
|
}
|
|
390
629
|
|
|
@@ -425,6 +664,8 @@ function main() {
|
|
|
425
664
|
images.push({ sha: img.sha, path: filePath, mediaType: img.mediaType, bytes: decoded.length });
|
|
426
665
|
}
|
|
427
666
|
pruneImageFiles(outDir);
|
|
667
|
+
// images[] invariant: every entry has bytes > 0 and a non-.json extension —
|
|
668
|
+
// materialized image files only, never markers.
|
|
428
669
|
printResult(opts, { imageCount: images.length, sessionId, images });
|
|
429
670
|
}
|
|
430
671
|
|
|
@@ -93,10 +93,24 @@ function detectPastedImageViaExtractor({ rootDir = process.cwd() } = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Gateway-aware model pick (TASK-003 aware). Calls detectUnicGateway()
|
|
97
|
-
* avoid a spawn on the hot path.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
96
|
+
* Gateway-aware model pick (TASK-003 aware; TASK-008 lane-fallback). Calls detectUnicGateway()
|
|
97
|
+
* directly (not the CLI) to avoid a spawn on the hot path.
|
|
98
|
+
*
|
|
99
|
+
* Policy (mirrors the hook side in TASK-007 / ws-g.sh):
|
|
100
|
+
* - unicMode true AND aliasAvailable true -> unic-vision (today's behavior).
|
|
101
|
+
* - unicMode true AND aliasAvailable false -> fall through to config fallback (do NOT force a
|
|
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.
|
|
107
|
+
* - unicMode true AND aliasAvailable missing/undefined -> keep today's behavior
|
|
108
|
+
* (return unic-vision). The strict === false check is what preserves the "missing field
|
|
109
|
+
* doesn't change today's behavior" contract from the task spec.
|
|
110
|
+
* - unicMode false -> read orchestration.modelTiers.vision.fallbackModel from config
|
|
111
|
+
* (TASK-001).
|
|
112
|
+
* - Neither available -> omit visionModel and return an advisory; a blind model is never
|
|
113
|
+
* silently named.
|
|
100
114
|
*/
|
|
101
115
|
async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
|
|
102
116
|
let gateway = null;
|
|
@@ -106,12 +120,29 @@ async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
|
|
|
106
120
|
gateway = null;
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
const config = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
|
|
124
|
+
const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
|
|
125
|
+
|
|
126
|
+
if (gateway?.unicMode && gateway.aliasAvailable === false) {
|
|
127
|
+
if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
|
|
128
|
+
return {
|
|
129
|
+
visionModel: fallbackModel.trim(),
|
|
130
|
+
visionAdvisory: 'vision lane detected but the unic-vision alias was positively reported '
|
|
131
|
+
+ 'unavailable; falling back to orchestration.modelTiers.vision.fallbackModel.',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
visionModel: null,
|
|
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.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
if (gateway?.unicMode) {
|
|
110
143
|
return { visionModel: gateway.visionModel || 'unic-vision', visionAdvisory: null };
|
|
111
144
|
}
|
|
112
145
|
|
|
113
|
-
const config = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
|
|
114
|
-
const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
|
|
115
146
|
if (typeof fallbackModel === 'string' && fallbackModel.trim()) {
|
|
116
147
|
return { visionModel: fallbackModel.trim(), visionAdvisory: null };
|
|
117
148
|
}
|
|
@@ -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
|
-
*
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
}
|