scoutline 0.10.0 → 0.10.1

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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Research state file — resume mechanism for interrupted research
3
+ * (tech-plan §3, T07).
4
+ *
5
+ * Research costs 4-250 credits per request. A research task runs
6
+ * asynchronously server-side: POST /research creates it and returns a
7
+ * `request_id`, then GET /research/{id} polls until completion. If the
8
+ * CLI exits (Ctrl-C, crash) mid-poll, the task keeps running and
9
+ * consuming credits. Without a persistence mechanism, the next identical
10
+ * request would POST a SECOND task — a double charge.
11
+ *
12
+ * This module persists `{ requestId, identityHash, createdAt, status }`
13
+ * to `~/.scoutline/research/<state-hash>.json` so the next invocation of
14
+ * the same request detects the in-flight task and polls it instead of
15
+ * creating a new one. The state-hash is deterministic for a given
16
+ * `{provider, capability, credentialFingerprint, request}` tuple (see
17
+ * `computeResearchStateHash`).
18
+ *
19
+ * Boundary rules (ARCHITECTURE.md §2):
20
+ * - May import cache-root resolution and normalized errors.
21
+ * - Must NOT import transport, command presentation, or a Provider
22
+ * Adapter.
23
+ *
24
+ * Resilience contract (tech-plan §3 / G1-G3):
25
+ * - `write()` uses `{ flag: "wx" }` for atomic creation. A concurrent
26
+ * invocation that finds the file already exists gets EEXIST and
27
+ * polls the existing task instead of creating a new one.
28
+ * - `read()` catches JSON parse errors, deletes the corrupt file, and
29
+ * returns `null` (treated as absent → new task created).
30
+ * - `remove()` deletes the file and ignores ENOENT (already gone).
31
+ */
32
+ import crypto from "node:crypto";
33
+ import * as fs from "node:fs/promises";
34
+ import path from "node:path";
35
+ import { researchStateDir } from "./cache.js";
36
+ /**
37
+ * Recursively sort object keys so `JSON.stringify` produces a stable
38
+ * representation regardless of insertion order. Mirrors the cache module's
39
+ * `sortKeysDeep` so the same request hashes identically here and in the
40
+ * response-cache key.
41
+ */
42
+ function sortKeysDeep(value) {
43
+ if (Array.isArray(value)) {
44
+ return value.map((element) => sortKeysDeep(element));
45
+ }
46
+ if (value && typeof value === "object") {
47
+ const input = value;
48
+ const out = {};
49
+ for (const key of Object.keys(input).sort()) {
50
+ out[key] = sortKeysDeep(input[key]);
51
+ }
52
+ return out;
53
+ }
54
+ return value;
55
+ }
56
+ /**
57
+ * Compute the deterministic state-file identity hash for a research
58
+ * request (tech-plan §3 / CR3).
59
+ *
60
+ * state-hash = SHA-256(recursively-key-sorted-JSON({
61
+ * provider, capability, credentialFingerprint, request
62
+ * }))
63
+ *
64
+ * Same canonical approach as `buildProviderCacheKey`'s request hashing,
65
+ * extended to include provider + capability + credential. Rotating the
66
+ * API key orphans old state files (correct — the old task belongs to the
67
+ * old key's billing). The hash never contains a raw credential.
68
+ */
69
+ export function computeResearchStateHash(input) {
70
+ const payload = {
71
+ provider: input.provider,
72
+ capability: input.capability,
73
+ credentialFingerprint: input.credentialFingerprint,
74
+ request: sortKeysDeep(input.request),
75
+ };
76
+ return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Production state-file implementation
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * Build a production {@link ResearchStateFile} backed by files under
83
+ * `~/.scoutline/research/` (via {@link researchStateDir}). One JSON file
84
+ * per in-flight task, named `<state-hash>.json`.
85
+ *
86
+ * - `write()` atomically creates the file with `{ flag: "wx" }`. A
87
+ * concurrent invocation that finds it exists throws EEXIST; the caller
88
+ * catches that and polls the existing task.
89
+ * - `read()` catches JSON parse errors, deletes the corrupt file, and
90
+ * returns `null`.
91
+ * - `remove()` deletes the file and ignores ENOENT.
92
+ */
93
+ export function createProductionResearchStateFile() {
94
+ function filePath(identityHash) {
95
+ return path.join(researchStateDir(), `${identityHash}.json`);
96
+ }
97
+ return {
98
+ async read(identityHash) {
99
+ if (!identityHash)
100
+ return null;
101
+ const file = filePath(identityHash);
102
+ let raw;
103
+ try {
104
+ raw = await fs.readFile(file, "utf8");
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ try {
110
+ const parsed = JSON.parse(raw);
111
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ await fs.unlink(file).catch(() => { });
113
+ return null;
114
+ }
115
+ const obj = parsed;
116
+ const requestId = obj.requestId;
117
+ const storedHash = obj.identityHash;
118
+ const createdAt = obj.createdAt;
119
+ const status = obj.status;
120
+ if (typeof requestId !== "string" ||
121
+ requestId.length === 0 ||
122
+ typeof storedHash !== "string" ||
123
+ typeof createdAt !== "string" ||
124
+ (status !== "pending" && status !== "in_progress")) {
125
+ await fs.unlink(file).catch(() => { });
126
+ return null;
127
+ }
128
+ return { requestId, identityHash: storedHash, createdAt, status };
129
+ }
130
+ catch {
131
+ // Corrupt JSON — delete the file so the next run creates a fresh
132
+ // task instead of forever reading garbage.
133
+ await fs.unlink(file).catch(() => { });
134
+ return null;
135
+ }
136
+ },
137
+ async write(identityHash, state) {
138
+ const dir = researchStateDir();
139
+ await fs.mkdir(dir, { recursive: true });
140
+ const file = filePath(identityHash);
141
+ const payload = JSON.stringify(state);
142
+ // `{ flag: "wx" }` atomically creates the file only if it does not
143
+ // exist. A concurrent invocation that lost the race gets EEXIST;
144
+ // the caller catches it and polls the existing task instead of
145
+ // creating a second one.
146
+ await fs.writeFile(file, payload, { flag: "wx" });
147
+ },
148
+ async remove(identityHash) {
149
+ const file = filePath(identityHash);
150
+ await fs.unlink(file).catch((err) => {
151
+ // ENOENT is expected (already removed, or never written). Swallow
152
+ // it; any other error re-throws.
153
+ if (err && err.code === "ENOENT")
154
+ return;
155
+ throw err;
156
+ });
157
+ },
158
+ };
159
+ }
160
+ /**
161
+ * Convenience helper exported for the Adapter and tests: builds an
162
+ * in-memory {@link ResearchStateFile} that throws EEXIST on a second
163
+ * write to the same hash, mirroring the production `{ flag: "wx" }`
164
+ * contract exactly. The adapter's lifecycle must not depend on whether
165
+ * the state file is disk-backed or memory-backed.
166
+ */
167
+ export function createInMemoryResearchStateFile() {
168
+ const store = new Map();
169
+ return {
170
+ store,
171
+ async read(identityHash) {
172
+ const raw = store.get(identityHash);
173
+ if (raw === undefined)
174
+ return null;
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
178
+ store.delete(identityHash);
179
+ return null;
180
+ }
181
+ const obj = parsed;
182
+ const requestId = obj.requestId;
183
+ const storedHash = obj.identityHash;
184
+ const createdAt = obj.createdAt;
185
+ const status = obj.status;
186
+ if (typeof requestId !== "string" ||
187
+ requestId.length === 0 ||
188
+ typeof storedHash !== "string" ||
189
+ typeof createdAt !== "string" ||
190
+ (status !== "pending" && status !== "in_progress")) {
191
+ store.delete(identityHash);
192
+ return null;
193
+ }
194
+ return { requestId, identityHash: storedHash, createdAt, status };
195
+ }
196
+ catch {
197
+ store.delete(identityHash);
198
+ return null;
199
+ }
200
+ },
201
+ async write(identityHash, state) {
202
+ if (store.has(identityHash)) {
203
+ const err = new Error(`EEXIST: file already exists, write '${identityHash}.json'`);
204
+ err.code = "EEXIST";
205
+ throw err;
206
+ }
207
+ store.set(identityHash, JSON.stringify(state));
208
+ },
209
+ async remove(identityHash) {
210
+ store.delete(identityHash);
211
+ },
212
+ };
213
+ }
214
+ //# sourceMappingURL=research-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"research-state.js","sourceRoot":"","sources":["../../src/lib/research-state.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MiniMax SDK client factory (DESIGN.md §12 — P2-04).
3
+ *
4
+ * This is the ONLY module that imports `mmx-cli/sdk`. The Adapter and
5
+ * registry consume {@link createMiniMaxSdk} plus the injectable
6
+ * {@link MiniMaxSdkConstructor} / {@link MiniMaxSdkPort} types declared
7
+ * in `providers/types.ts`, so the transitional SDK never leaks past
8
+ * this boundary.
9
+ *
10
+ * The characterized SDK reads its own config directory during
11
+ * construction (it calls `getConfigDir()`, which honours
12
+ * `MMX_CONFIG_DIR`). To prevent the SDK from touching the user's real
13
+ * `~/.mmx` state, the factory temporarily points `MMX_CONFIG_DIR` at a
14
+ * unique nonexistent path for the synchronous construction call only,
15
+ * then restores the prior value in `finally`. The temporary directory
16
+ * is NEVER created on disk.
17
+ */
18
+ import type { MiniMaxSdkConstructor, MiniMaxSdkPort } from "../types.js";
19
+ /**
20
+ * Construct a MiniMax SDK port. The optional `constructor` lets tests
21
+ * inject a fake; production omits it and uses the pinned `mmx-cli/sdk`
22
+ * implementation.
23
+ */
24
+ export declare function createMiniMaxSdk(options: {
25
+ apiKey: string;
26
+ region: "global" | "cn";
27
+ baseUrl: string;
28
+ }, constructor?: MiniMaxSdkConstructor): MiniMaxSdkPort;
29
+ //# sourceMappingURL=sdk-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sdk-client.d.ts","sourceRoot":"","sources":["../../../src/providers/minimax/sdk-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,OAAO,KAAK,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAIzE;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EACrE,WAAW,CAAC,EAAE,qBAAqB,GAClC,cAAc,CAoBhB"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MiniMax SDK client factory (DESIGN.md §12 — P2-04).
3
+ *
4
+ * This is the ONLY module that imports `mmx-cli/sdk`. The Adapter and
5
+ * registry consume {@link createMiniMaxSdk} plus the injectable
6
+ * {@link MiniMaxSdkConstructor} / {@link MiniMaxSdkPort} types declared
7
+ * in `providers/types.ts`, so the transitional SDK never leaks past
8
+ * this boundary.
9
+ *
10
+ * The characterized SDK reads its own config directory during
11
+ * construction (it calls `getConfigDir()`, which honours
12
+ * `MMX_CONFIG_DIR`). To prevent the SDK from touching the user's real
13
+ * `~/.mmx` state, the factory temporarily points `MMX_CONFIG_DIR` at a
14
+ * unique nonexistent path for the synchronous construction call only,
15
+ * then restores the prior value in `finally`. The temporary directory
16
+ * is NEVER created on disk.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import { MiniMaxSDK } from "mmx-cli/sdk";
22
+ const MMX_CONFIG_DIR = "MMX_CONFIG_DIR";
23
+ /**
24
+ * Construct a MiniMax SDK port. The optional `constructor` lets tests
25
+ * inject a fake; production omits it and uses the pinned `mmx-cli/sdk`
26
+ * implementation.
27
+ */
28
+ export function createMiniMaxSdk(options, constructor) {
29
+ const Ctor = constructor ?? MiniMaxSDK;
30
+ const hadPrev = Object.prototype.hasOwnProperty.call(process.env, MMX_CONFIG_DIR);
31
+ const prev = process.env[MMX_CONFIG_DIR];
32
+ // A unique nonexistent path. It is deliberately never created.
33
+ const temporaryDir = path.join(tmpdir(), `scoutline-minimax-${randomUUID()}`);
34
+ process.env[MMX_CONFIG_DIR] = temporaryDir;
35
+ try {
36
+ // Construction is synchronous and reads MMX_CONFIG_DIR via the
37
+ // SDK's getConfigDir(). The async search/vision calls happen
38
+ // after this block, with the original env already restored.
39
+ return new Ctor(options);
40
+ }
41
+ finally {
42
+ if (hadPrev) {
43
+ process.env[MMX_CONFIG_DIR] = prev;
44
+ }
45
+ else {
46
+ delete process.env[MMX_CONFIG_DIR];
47
+ }
48
+ }
49
+ }
50
+ //# sourceMappingURL=sdk-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sdk-client.js","sourceRoot":"","sources":["../../../src/providers/minimax/sdk-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAExC;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,OAAqE,EACrE,WAAmC;IAEnC,MAAM,IAAI,GAAG,WAAW,IAAK,UAA+C,CAAC;IAE7E,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;IAClF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACzC,+DAA+D;IAC/D,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,qBAAqB,UAAU,EAAE,EAAE,CAAC,CAAC;IAC9E,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,GAAG,YAAY,CAAC;IAC3C,IAAI,CAAC;QACH,+DAA+D;QAC/D,6DAA6D;QAC7D,4DAA4D;QAC5D,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;YAAS,CAAC;QACT,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,GAAG,IAAI,CAAC;QACrC,CAAC;aAAM,CAAC;YACN,OAAO,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/providers/zai/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAIV,kBAAkB,EAElB,sBAAsB,EAGvB,MAAM,aAAa,CAAC;AAupBrB;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,CAAC,EAAE,sBAAsB,GAAG,kBAAkB,CAyG7F"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/providers/zai/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAKH,OAAO,KAAK,EAIV,kBAAkB,EAElB,sBAAsB,EAGvB,MAAM,aAAa,CAAC;AAwyBrB;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,CAAC,EAAE,sBAAsB,GAAG,kBAAkB,CAyG7F"}
@@ -24,13 +24,14 @@
24
24
  * and location to the Provider. It NEVER sends count.
25
25
  */
26
26
  import crypto from "node:crypto";
27
+ import { promises as fsPromises } from "node:fs";
27
28
  import { ApiError, AuthError, NetworkError, TimeoutError, UnsupportedOptionError, ValidationError, } from "../../lib/errors.js";
28
29
  import { getMcpToolName } from "../../lib/mcp-config.js";
29
30
  import { ZaiMcpClient, } from "../../lib/mcp-client.js";
30
31
  import { buildLegacyRepositoryCacheKey } from "../../lib/cache.js";
31
32
  import { applySearchTopic } from "../../lib/search-topic.js";
32
33
  import { isZaiConfigured, requireZaiApiKey } from "./credentials.js";
33
- import { resolveImageSource, resolveVideoSource } from "./media.js";
34
+ import { resolveImageSource, resolveVideoSource, fetchImageSource, fetchVideoSource, } from "./media.js";
34
35
  import { createZaiQuotaCapability } from "./quota.js";
35
36
  import { createZaiRepositoryCapability } from "./repository.js";
36
37
  import { createZaiReaderCapability } from "./reader.js";
@@ -397,40 +398,163 @@ function createZaiVisionCapability(options) {
397
398
  // Unsupported operations never reach here: the descriptor-level
398
399
  // gate and `supports()` reject first (defence in depth).
399
400
  resolveApiKey();
400
- const { toolName, args } = buildZaiVisionInvocation(request);
401
- // Disable client-owned cache and retry so shared execution is the
402
- // single policy owner. Vision enables the Z.AI vision MCP server.
403
- const clientOptions = {
404
- enableVision: true,
405
- noCache: true,
406
- disableRetry: true,
407
- };
408
- const client = clientFactory(clientOptions);
401
+ const tempPaths = [];
409
402
  try {
410
- const raw = await invokeZaiVision(client, toolName, args);
411
- return normalizeZaiVisionResult(raw);
403
+ // First attempt: HTTP(S) URLs pass straight through to the
404
+ // Provider's server-side fetcher.
405
+ const first = buildZaiVisionInvocation(request, resolveImageSource, resolveVideoSource);
406
+ try {
407
+ return normalizeZaiVisionResult(await invokeZaiVisionOnce(clientFactory, first.toolName, first.args));
408
+ }
409
+ catch (error) {
410
+ // Automatic URL -> local fallback (Issue E): Z.AI's server-side
411
+ // URL fetcher rejects some image URLs with a fast client error
412
+ // (e.g. code 1210, HTTP 400) and its vision MCP refuses base64
413
+ // data URIs. Retry by fetching each URL source here to a temp
414
+ // file (validated against the same Z.AI media limits) and
415
+ // passing that path. Only fast client errors (400/422) qualify
416
+ // — timeouts/5xx are owned by the shared retry policy and would
417
+ // double latency if re-attempted here.
418
+ if (isUrlVisionSource(request) && isClientSourceError(error)) {
419
+ const fetched = await prefetchVisionUrlSources(request);
420
+ for (const local of fetched.values())
421
+ tempPaths.push(local);
422
+ const resolveImage = makePrefetchResolver(fetched, resolveImageSource);
423
+ const resolveVideo = makePrefetchResolver(fetched, resolveVideoSource);
424
+ const fallback = buildZaiVisionInvocation(request, resolveImage, resolveVideo);
425
+ return normalizeZaiVisionResult(await invokeZaiVisionOnce(clientFactory, fallback.toolName, fallback.args));
426
+ }
427
+ throw error;
428
+ }
412
429
  }
413
430
  finally {
414
- await client.close().catch(() => { });
431
+ await cleanupTempPaths(tempPaths);
415
432
  }
416
433
  },
417
434
  };
418
435
  return capability;
419
436
  }
437
+ /**
438
+ * Perform one vision transport attempt. A fresh client is constructed per
439
+ * attempt (the Adapter never retries internally; shared execution owns the
440
+ * retry policy) and closed exactly once in `finally`. Close failure never
441
+ * replaces a successful result nor masks the primary failure.
442
+ */
443
+ async function invokeZaiVisionOnce(clientFactory, toolName, args) {
444
+ const clientOptions = {
445
+ enableVision: true,
446
+ noCache: true,
447
+ disableRetry: true,
448
+ };
449
+ const client = clientFactory(clientOptions);
450
+ try {
451
+ return await invokeZaiVision(client, toolName, args);
452
+ }
453
+ finally {
454
+ await client.close().catch(() => { });
455
+ }
456
+ }
457
+ /**
458
+ * Whether a `VisionRequest` carries at least one HTTP(S) source. The
459
+ * URL->local fallback only applies when a Provider-attempted URL is the
460
+ * likely cause of a client error; local-file sources already resolved
461
+ * before transport and are not re-attempted with a fetch.
462
+ */
463
+ function isUrlVisionSource(request) {
464
+ return visionSourceUrls(request).length > 0;
465
+ }
466
+ /**
467
+ * Collect the HTTP(S) source strings carried by a `VisionRequest`.
468
+ * `diff` carries two image sources; `video` carries one video source;
469
+ * every other operation carries one image source.
470
+ */
471
+ function visionSourceUrls(request) {
472
+ const out = [];
473
+ const pushIfUrl = (s) => {
474
+ if (typeof s === "string" && (s.startsWith("http://") || s.startsWith("https://"))) {
475
+ out.push(s);
476
+ }
477
+ };
478
+ switch (request.operation) {
479
+ case "diff":
480
+ pushIfUrl(request.expectedSource);
481
+ pushIfUrl(request.actualSource);
482
+ break;
483
+ case "video":
484
+ pushIfUrl(request.source);
485
+ break;
486
+ default:
487
+ pushIfUrl(request.source);
488
+ }
489
+ return out;
490
+ }
491
+ /**
492
+ * Whether a normalized vision error is a fast client error that signals
493
+ * the Provider rejected the image source (400/422). Such errors are NOT
494
+ * retried by the shared policy (4xx is terminal), so the URL->local
495
+ * fallback adds value without conflicting with it.
496
+ */
497
+ function isClientSourceError(error) {
498
+ return error instanceof ApiError && (error.statusCode === 400 || error.statusCode === 422);
499
+ }
500
+ /**
501
+ * Prefetch each HTTP(S) source URL on the request to a validated temp
502
+ * file path, returning a Map from the original URL to its local path.
503
+ * Video sources use the video media limits; every other source uses the
504
+ * image limits. A fetch failure throws the normalized media error and
505
+ * aborts the fallback (the original client error is then surfaced).
506
+ */
507
+ async function prefetchVisionUrlSources(request) {
508
+ const isVideo = request.operation === "video";
509
+ const fetched = new Map();
510
+ for (const url of visionSourceUrls(request)) {
511
+ const local = isVideo ? await fetchVideoSource(url) : await fetchImageSource(url);
512
+ fetched.set(url, local);
513
+ }
514
+ return fetched;
515
+ }
516
+ /**
517
+ * Build a sync media resolver for the fallback attempt. A source present
518
+ * in the prefetched `urlToPath` map is substituted with its local temp
519
+ * path; every other source falls through to the normal passthrough
520
+ * resolver (`resolveImageSource`/`resolveVideoSource`).
521
+ */
522
+ function makePrefetchResolver(urlToPath, passthrough) {
523
+ return (source) => urlToPath.get(source) ?? passthrough(source);
524
+ }
525
+ /**
526
+ * Unlink every prefetched temp file. Each unlink is best-effort: a
527
+ * missing file or permission failure never replaces a successful result
528
+ * nor masks a primary failure.
529
+ */
530
+ async function cleanupTempPaths(tempPaths) {
531
+ for (const p of tempPaths) {
532
+ try {
533
+ await fsPromises.unlink(p);
534
+ }
535
+ catch {
536
+ // Best-effort cleanup; ignore.
537
+ }
538
+ }
539
+ }
420
540
  /**
421
541
  * Map a discriminated `VisionRequest` to its dedicated Z.AI MCP tool name
422
- * and arguments, resolving media through the Z.AI media Module. Field
542
+ * and arguments, resolving media through the supplied resolvers. Field
423
543
  * names mirror the characterized transport schema (see `mcp-client.ts`
424
544
  * and the live discovery fixtures). Optional fields are omitted when
425
545
  * absent so the Provider receives the same request shape Phase 1 sent.
546
+ *
547
+ * The image/video resolvers are injected so the first attempt can pass
548
+ * HTTP(S) URLs straight through (`resolveImageSource`) while a fallback
549
+ * attempt can substitute a fetched temp-file path (`fetchImageSource`).
426
550
  */
427
- function buildZaiVisionInvocation(request) {
551
+ function buildZaiVisionInvocation(request, resolveImage, resolveVideo) {
428
552
  switch (request.operation) {
429
553
  case "interpret-image":
430
554
  return {
431
555
  toolName: VISION_ANALYZE_TOOL_PUBLIC_NAME,
432
556
  args: {
433
- image_source: resolveImageSource(request.source),
557
+ image_source: resolveImage(request.source),
434
558
  prompt: request.instruction,
435
559
  },
436
560
  };
@@ -438,14 +562,14 @@ function buildZaiVisionInvocation(request) {
438
562
  return {
439
563
  toolName: VISION_UI_TO_ARTIFACT_TOOL_PUBLIC_NAME,
440
564
  args: {
441
- image_source: resolveImageSource(request.source),
565
+ image_source: resolveImage(request.source),
442
566
  output_type: request.outputType,
443
567
  prompt: request.instruction,
444
568
  },
445
569
  };
446
570
  case "extract-text": {
447
571
  const args = {
448
- image_source: resolveImageSource(request.source),
572
+ image_source: resolveImage(request.source),
449
573
  prompt: request.instruction,
450
574
  };
451
575
  if (request.programmingLanguage) {
@@ -455,7 +579,7 @@ function buildZaiVisionInvocation(request) {
455
579
  }
456
580
  case "diagnose-error": {
457
581
  const args = {
458
- image_source: resolveImageSource(request.source),
582
+ image_source: resolveImage(request.source),
459
583
  prompt: request.instruction,
460
584
  };
461
585
  if (request.context) {
@@ -465,7 +589,7 @@ function buildZaiVisionInvocation(request) {
465
589
  }
466
590
  case "diagram": {
467
591
  const args = {
468
- image_source: resolveImageSource(request.source),
592
+ image_source: resolveImage(request.source),
469
593
  prompt: request.instruction,
470
594
  };
471
595
  if (request.diagramType) {
@@ -475,7 +599,7 @@ function buildZaiVisionInvocation(request) {
475
599
  }
476
600
  case "chart": {
477
601
  const args = {
478
- image_source: resolveImageSource(request.source),
602
+ image_source: resolveImage(request.source),
479
603
  prompt: request.instruction,
480
604
  };
481
605
  if (request.focus) {
@@ -487,8 +611,8 @@ function buildZaiVisionInvocation(request) {
487
611
  return {
488
612
  toolName: VISION_DIFF_TOOL_PUBLIC_NAME,
489
613
  args: {
490
- expected_image_source: resolveImageSource(request.expectedSource),
491
- actual_image_source: resolveImageSource(request.actualSource),
614
+ expected_image_source: resolveImage(request.expectedSource),
615
+ actual_image_source: resolveImage(request.actualSource),
492
616
  prompt: request.instruction,
493
617
  },
494
618
  };
@@ -496,7 +620,7 @@ function buildZaiVisionInvocation(request) {
496
620
  return {
497
621
  toolName: VISION_VIDEO_TOOL_PUBLIC_NAME,
498
622
  args: {
499
- video_source: resolveVideoSource(request.source),
623
+ video_source: resolveVideo(request.source),
500
624
  prompt: request.instruction,
501
625
  },
502
626
  };