toolnexus 0.16.0 → 0.17.0

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,158 @@
1
+ export type ContentPart = {
2
+ type: "text";
3
+ text: string;
4
+ } | {
5
+ type: "image";
6
+ mimeType: string;
7
+ data?: string;
8
+ url?: string;
9
+ } | {
10
+ type: "file";
11
+ mimeType: string;
12
+ data?: string;
13
+ url?: string;
14
+ name?: string;
15
+ } | {
16
+ type: "audio";
17
+ mimeType: string;
18
+ data?: string;
19
+ url?: string;
20
+ };
21
+ /** A prompt is a string or an ordered list of parts — ordering is semantic to a model. */
22
+ export type PromptInput = string | ContentPart[];
23
+ /** The two provider styles that have a request path (`toGemini` is declarations only). */
24
+ export type PartStyle = "openai" | "anthropic";
25
+ /** How a part got here. Drives the unsupported-part rule (§8A): the caller asked for an
26
+ * attached part, so silently changing it is the betrayal; nobody asked for a part an MCP
27
+ * server volunteered, so failing the run over it is a regression. */
28
+ export type PartProvenance = "attached" | "derived";
29
+ /** What to do with a part the style cannot represent; overrides provenance uniformly. */
30
+ export type UnsupportedPartMode = "error" | "text";
31
+ /** Why a part could not be constructed or sent (§1B / §8A). `code` is the machine-readable half. */
32
+ export declare class ContentPartError extends Error {
33
+ readonly code: "source-conflict" | "source-missing" | "unknown-extension" | "too-large" | "unsupported";
34
+ constructor(message: string, code: "source-conflict" | "source-missing" | "unknown-extension" | "too-large" | "unsupported");
35
+ }
36
+ /**
37
+ * The media extension table (§6 `read`) — fixed, shared with the edge constructors,
38
+ * identical in every port. No magic-byte sniffing and no platform mime database:
39
+ * `/etc/mime.types` varies per machine and would break cross-port parity.
40
+ */
41
+ export declare const MEDIA_TYPES: Record<string, {
42
+ mimeType: string;
43
+ type: "image" | "file" | "audio";
44
+ }>;
45
+ /** Lower-cased extension without the dot, or "" when there is none. */
46
+ export declare function extensionOf(p: string): string;
47
+ /** The table entry for a path/URL, or undefined when the extension is not media. */
48
+ export declare function mediaTypeFor(p: string): {
49
+ mimeType: string;
50
+ type: "image" | "file" | "audio";
51
+ } | undefined;
52
+ /** Part kind for a mime type: `image/*` ⇒ image, `audio/*` ⇒ audio, everything else ⇒ file. */
53
+ export declare function partTypeForMime(mimeType: string): "image" | "file" | "audio";
54
+ /** Decoded byte length of standard, padded base64 — without decoding it (RFC 4648 §4:
55
+ * `floor(len/4)*3 - padCount`). `<bytes>` in every rendered string is a plain integer, never
56
+ * a fraction, so this floors the group count rather than assuming `len` is a multiple of 4. */
57
+ export declare function base64Bytes(b64: string): number;
58
+ /** Decoded byte length of a part's payload; a url-only part carries no bytes here. */
59
+ export declare function partBytes(part: ContentPart): number;
60
+ /** §1B token estimate for a part, from its decoded byte count: `max(85, floor(bytes/750))`.
61
+ * Measured against real providers: image cost is not proportional to bytes, so this is an
62
+ * explicit estimate, not a count — but it is normative and lives with the part logic so a
63
+ * budgeting helper (e.g. `agents.estimateTokens`) cannot silently diverge from it. */
64
+ export declare const PART_BYTES_PER_TOKEN = 750;
65
+ export declare const MIN_PART_TOKENS = 85;
66
+ export declare function partTokens(bytes: number): number;
67
+ /**
68
+ * How a part appears in a log line or a §9 event: `{type, mimeType, bytes}` — never `data`.
69
+ * Same rule as never-log-headers: part payloads are user content.
70
+ */
71
+ export declare function describePart(part: ContentPart): Record<string, unknown>;
72
+ /** A one-line, payload-free rendering of a part — for `output` text and placeholders. */
73
+ export declare function summarizePart(part: ContentPart): string;
74
+ /** Validate the §1B invariant: exactly one of `data` / `url`. */
75
+ export declare function validatePart(part: ContentPart, maxPartBytes?: number): ContentPart;
76
+ /** True when a value looks like a `ContentPart` (as opposed to a provider-native block). */
77
+ export declare function isContentPart(v: unknown): v is ContentPart;
78
+ /** A text part. */
79
+ export declare function text(value: string): ContentPart;
80
+ /** Options for {@link attach}. */
81
+ export interface AttachOptions {
82
+ /** Overrides the extension table; **required** for raw bytes and for an unlisted extension. */
83
+ mimeType?: string;
84
+ /** Filename carried on a `file` part (OpenAI's `file.filename`). Defaults to the basename. */
85
+ name?: string;
86
+ /** Reject a payload over this many **decoded** bytes (never the +33% base64 string). */
87
+ maxPartBytes?: number;
88
+ }
89
+ /** Everything the edge constructor accepts (§1B): a path / `data:` / `https:` string, native
90
+ * bytes in any of their JS spellings, or the host's own file object. Whatever comes in, only
91
+ * `mimeType` + base64 `data` (or a `url`) lands in the part — accept broadly, store narrowly. */
92
+ export type AttachSource = string | Uint8Array | ArrayBuffer | ArrayBufferView | Blob;
93
+ /**
94
+ * The edge constructor: hand it whatever you actually have and get a path-free part.
95
+ *
96
+ * | given | becomes |
97
+ * |---|---|
98
+ * | a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
99
+ * | native bytes (`Uint8Array`/`Buffer`/`ArrayBuffer`/any `ArrayBufferView`) | base64d now; `mimeType` required |
100
+ * | a `Blob` / `File` | read to bytes **now**; mime from `blob.type`, else the extension table via `File.name` |
101
+ * | `data:<mime>;base64,<b64>` | parsed into `{mimeType, data}` — never stored as a url |
102
+ * | an `http(s):` URL | kept as `url` |
103
+ */
104
+ export declare function attach(source: AttachSource, opts?: AttachOptions): Promise<ContentPart>;
105
+ /**
106
+ * Build a part from a `Blob` or `File`, consuming it **eagerly** — a part holding a half-read
107
+ * stream would not survive the transcript boundary any better than a path does (§1B).
108
+ *
109
+ * Mime comes from `blob.type` when it is non-empty, else from the fixed extension table applied
110
+ * to `File.name`; an explicit `opts.mimeType` beats both. A `File`'s name is carried onto a
111
+ * `file` part unless `opts.name` overrides it.
112
+ */
113
+ export declare function fromBlob(blob: Blob, opts?: AttachOptions): Promise<ContentPart>;
114
+ /** Build a part from in-memory bytes. `mimeType` is required — bytes carry no extension. */
115
+ export declare function fromBytes(bytes: Uint8Array | ArrayBuffer | ArrayBufferView, mimeType: string, opts?: AttachOptions): ContentPart;
116
+ /** Read a file now and base64 it now, so the part never carries the path (§1B). */
117
+ export declare function fromPath(p: string, opts?: AttachOptions): Promise<ContentPart>;
118
+ /** Parse `data:<mime>;base64,<b64>` into `{mimeType, data}`, so two spellings of the same
119
+ * bytes cannot diverge downstream. */
120
+ export declare function fromDataUrl(url: string, opts?: AttachOptions): ContentPart;
121
+ /** Keep an `http(s):` URL as a url part; mime comes from the table or an explicit override. */
122
+ export declare function fromUrl(url: string, opts?: AttachOptions): ContentPart;
123
+ /**
124
+ * Encode one part into its provider block, or `undefined` when the style defines no shape
125
+ * for it. Refusals are explicit, never a fall-through: `openai × file+url` (Chat Completions
126
+ * has no URL form for a file) and `anthropic × audio` (the provider defines no audio block).
127
+ */
128
+ export declare function encodePart(part: ContentPart, style: PartStyle): Record<string, any> | undefined;
129
+ /** Context for {@link encodeParts}: which wire, who asked for the part, and the override. */
130
+ export interface EncodeOptions {
131
+ style: PartStyle;
132
+ provenance: PartProvenance;
133
+ onUnsupportedPart?: UnsupportedPartMode;
134
+ maxPartBytes?: number;
135
+ }
136
+ /** Test seam: forget which unsupported (style, type) pairs have already been warned about. */
137
+ export declare function resetUnsupportedWarnings(): void;
138
+ /** The text a degraded (non-failing) unsupported part leaves behind — never silence. */
139
+ export declare function unsupportedPlaceholder(part: ContentPart): string;
140
+ /**
141
+ * Encode a list of parts for one style, applying the §8A rules:
142
+ * an **attached** part the style cannot represent raises before any HTTP call; a **derived**
143
+ * part degrades to a text placeholder and warns once. `onUnsupportedPart` overrides both.
144
+ * Every emitted block is asserted against the style's allowlist.
145
+ *
146
+ * `maxPartBytes` (§1B) is enforced here too, at assembly, over EVERY part regardless of
147
+ * provenance — a part that arrived from an MCP server never passed through an edge
148
+ * constructor, so a limit it can walk around is not a limit. Going over follows the exact
149
+ * same provenance split as an unsupported block: an attached part errors, a derived one
150
+ * degrades to the placeholder so a remote server still cannot fail the run.
151
+ */
152
+ export declare function encodeParts(parts: readonly ContentPart[], opts: EncodeOptions): Record<string, any>[];
153
+ /**
154
+ * Read an inbound OpenAI-shaped content block back into a `ContentPart` (§11). Accepts both
155
+ * a `ContentPart` written literally and the provider-native block the same part encodes to,
156
+ * so a caller's own OpenAI messages translate as faithfully as ours do.
157
+ */
158
+ export declare function inboundPart(block: unknown): ContentPart | undefined;
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Multimodal content parts (SPEC.md §1B, §8A).
3
+ *
4
+ * One `ContentPart` union — `text | image | file | audio` — is how non-text data enters
5
+ * the loop (a prompt), comes back from a tool (`ToolResult.parts`), and survives MCP.
6
+ * A part is **bytes-or-URL, never a path**: a path does not survive a persisted and
7
+ * replayed transcript, nor the MCP / A2A process boundary, so the edge constructors
8
+ * (`attach`, `text`) normalise a path / native bytes / a `data:` URL at construction.
9
+ *
10
+ * Emission lives here too, not in `adapters.ts` (which is tool-*schema* only): each
11
+ * `(style, part.type)` pair has either a defined block shape or an explicit refusal, and
12
+ * the encoded block is checked against a **positive allowlist** before it can reach the
13
+ * wire — because an unknown block type upstream returns HTTP 200 with the content
14
+ * silently discarded, which is the exact bug this module exists to remove.
15
+ */
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ /** Why a part could not be constructed or sent (§1B / §8A). `code` is the machine-readable half. */
19
+ export class ContentPartError extends Error {
20
+ code;
21
+ constructor(message, code) {
22
+ super(message);
23
+ this.code = code;
24
+ this.name = "ContentPartError";
25
+ }
26
+ }
27
+ /**
28
+ * The media extension table (§6 `read`) — fixed, shared with the edge constructors,
29
+ * identical in every port. No magic-byte sniffing and no platform mime database:
30
+ * `/etc/mime.types` varies per machine and would break cross-port parity.
31
+ */
32
+ export const MEDIA_TYPES = {
33
+ png: { mimeType: "image/png", type: "image" },
34
+ jpg: { mimeType: "image/jpeg", type: "image" },
35
+ jpeg: { mimeType: "image/jpeg", type: "image" },
36
+ gif: { mimeType: "image/gif", type: "image" },
37
+ webp: { mimeType: "image/webp", type: "image" },
38
+ pdf: { mimeType: "application/pdf", type: "file" },
39
+ mp3: { mimeType: "audio/mpeg", type: "audio" },
40
+ wav: { mimeType: "audio/wav", type: "audio" },
41
+ };
42
+ /** Lower-cased extension without the dot, or "" when there is none. */
43
+ export function extensionOf(p) {
44
+ const ext = path.extname(p);
45
+ return ext ? ext.slice(1).toLowerCase() : "";
46
+ }
47
+ /** The table entry for a path/URL, or undefined when the extension is not media. */
48
+ export function mediaTypeFor(p) {
49
+ return MEDIA_TYPES[extensionOf(p)];
50
+ }
51
+ /** Part kind for a mime type: `image/*` ⇒ image, `audio/*` ⇒ audio, everything else ⇒ file. */
52
+ export function partTypeForMime(mimeType) {
53
+ if (mimeType.startsWith("image/"))
54
+ return "image";
55
+ if (mimeType.startsWith("audio/"))
56
+ return "audio";
57
+ return "file";
58
+ }
59
+ /** Decoded byte length of standard, padded base64 — without decoding it (RFC 4648 §4:
60
+ * `floor(len/4)*3 - padCount`). `<bytes>` in every rendered string is a plain integer, never
61
+ * a fraction, so this floors the group count rather than assuming `len` is a multiple of 4. */
62
+ export function base64Bytes(b64) {
63
+ const n = b64.length;
64
+ if (n === 0)
65
+ return 0;
66
+ let pad = 0;
67
+ if (b64.endsWith("=="))
68
+ pad = 2;
69
+ else if (b64.endsWith("="))
70
+ pad = 1;
71
+ return Math.floor(n / 4) * 3 - pad;
72
+ }
73
+ /** Decoded byte length of a part's payload; a url-only part carries no bytes here. */
74
+ export function partBytes(part) {
75
+ if (part.type === "text")
76
+ return Buffer.byteLength(part.text, "utf8");
77
+ return part.data ? base64Bytes(part.data) : 0;
78
+ }
79
+ /** §1B token estimate for a part, from its decoded byte count: `max(85, floor(bytes/750))`.
80
+ * Measured against real providers: image cost is not proportional to bytes, so this is an
81
+ * explicit estimate, not a count — but it is normative and lives with the part logic so a
82
+ * budgeting helper (e.g. `agents.estimateTokens`) cannot silently diverge from it. */
83
+ export const PART_BYTES_PER_TOKEN = 750;
84
+ export const MIN_PART_TOKENS = 85;
85
+ export function partTokens(bytes) {
86
+ return Math.max(MIN_PART_TOKENS, Math.floor(bytes / PART_BYTES_PER_TOKEN));
87
+ }
88
+ /**
89
+ * How a part appears in a log line or a §9 event: `{type, mimeType, bytes}` — never `data`.
90
+ * Same rule as never-log-headers: part payloads are user content.
91
+ */
92
+ export function describePart(part) {
93
+ if (part.type === "text")
94
+ return { type: "text", bytes: partBytes(part) };
95
+ return { type: part.type, mimeType: part.mimeType, bytes: partBytes(part) };
96
+ }
97
+ /** A one-line, payload-free rendering of a part — for `output` text and placeholders. */
98
+ export function summarizePart(part) {
99
+ return part.type === "text" ? part.text : `${part.type} (${part.mimeType}, ${partBytes(part)} bytes)`;
100
+ }
101
+ /** Validate the §1B invariant: exactly one of `data` / `url`. */
102
+ export function validatePart(part, maxPartBytes) {
103
+ if (part.type === "text")
104
+ return part;
105
+ const hasData = part.data !== undefined && part.data !== null;
106
+ const hasUrl = part.url !== undefined && part.url !== null;
107
+ if (hasData && hasUrl) {
108
+ throw new ContentPartError(`content part "${part.type}" carries both data and url — supply exactly one`, "source-conflict");
109
+ }
110
+ if (!hasData && !hasUrl) {
111
+ throw new ContentPartError(`content part "${part.type}" carries neither data nor url — supply exactly one`, "source-missing");
112
+ }
113
+ if (maxPartBytes !== undefined && hasData) {
114
+ const bytes = base64Bytes(part.data);
115
+ if (bytes > maxPartBytes) {
116
+ throw new ContentPartError(`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${maxPartBytes}`, "too-large");
117
+ }
118
+ }
119
+ return part;
120
+ }
121
+ /** True when a value looks like a `ContentPart` (as opposed to a provider-native block). */
122
+ export function isContentPart(v) {
123
+ if (!v || typeof v !== "object")
124
+ return false;
125
+ const t = v.type;
126
+ if (t === "text")
127
+ return typeof v.text === "string";
128
+ if (t === "image" || t === "file" || t === "audio")
129
+ return typeof v.mimeType === "string";
130
+ return false;
131
+ }
132
+ // --------------------------------------------------------------------------- //
133
+ // Edge constructors — a path / bytes / a data: URL never enters the part itself.
134
+ // --------------------------------------------------------------------------- //
135
+ /** A text part. */
136
+ export function text(value) {
137
+ return { type: "text", text: value };
138
+ }
139
+ /** A `Blob`/`File` — duck-typed, so a Blob from another realm or a polyfill is accepted too. */
140
+ function isBlobLike(v) {
141
+ return !!v && typeof v === "object" && typeof v.arrayBuffer === "function" && !ArrayBuffer.isView(v);
142
+ }
143
+ /** Normalise any native byte spelling to a `Uint8Array` view over the same bytes — an
144
+ * `ArrayBufferView` that is not a `Uint8Array` must be re-viewed, not passed to
145
+ * `Buffer.from`, which would read it as an array of numbers. */
146
+ function toBytes(source) {
147
+ if (source instanceof Uint8Array)
148
+ return source;
149
+ if (ArrayBuffer.isView(source))
150
+ return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
151
+ return new Uint8Array(source);
152
+ }
153
+ /** The typed §1B refusal for a name/path whose extension is not in the fixed table. */
154
+ function unknownExtension(nameOrPath) {
155
+ const ext = extensionOf(nameOrPath);
156
+ return new ContentPartError(`no mime type for extension "${ext || "(none)"}" — pass an explicit mimeType (mime is never sniffed)`, "unknown-extension");
157
+ }
158
+ /**
159
+ * The edge constructor: hand it whatever you actually have and get a path-free part.
160
+ *
161
+ * | given | becomes |
162
+ * |---|---|
163
+ * | a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
164
+ * | native bytes (`Uint8Array`/`Buffer`/`ArrayBuffer`/any `ArrayBufferView`) | base64d now; `mimeType` required |
165
+ * | a `Blob` / `File` | read to bytes **now**; mime from `blob.type`, else the extension table via `File.name` |
166
+ * | `data:<mime>;base64,<b64>` | parsed into `{mimeType, data}` — never stored as a url |
167
+ * | an `http(s):` URL | kept as `url` |
168
+ */
169
+ export async function attach(source, opts = {}) {
170
+ if (typeof source === "string") {
171
+ if (source.startsWith("data:"))
172
+ return fromDataUrl(source, opts);
173
+ if (/^https?:\/\//i.test(source))
174
+ return fromUrl(source, opts);
175
+ return fromPath(source, opts);
176
+ }
177
+ if (isBlobLike(source))
178
+ return fromBlob(source, opts);
179
+ if (!opts.mimeType) {
180
+ throw new ContentPartError("attach(bytes) requires an explicit mimeType — bytes carry no extension to read", "unknown-extension");
181
+ }
182
+ return fromBytes(toBytes(source), opts.mimeType, opts);
183
+ }
184
+ /**
185
+ * Build a part from a `Blob` or `File`, consuming it **eagerly** — a part holding a half-read
186
+ * stream would not survive the transcript boundary any better than a path does (§1B).
187
+ *
188
+ * Mime comes from `blob.type` when it is non-empty, else from the fixed extension table applied
189
+ * to `File.name`; an explicit `opts.mimeType` beats both. A `File`'s name is carried onto a
190
+ * `file` part unless `opts.name` overrides it.
191
+ */
192
+ export async function fromBlob(blob, opts = {}) {
193
+ const name = typeof blob.name === "string" ? String(blob.name) : undefined;
194
+ const mimeType = opts.mimeType || (blob.type || undefined) || (name ? mediaTypeFor(name)?.mimeType : undefined);
195
+ if (!mimeType)
196
+ throw unknownExtension(name ?? "");
197
+ const bytes = new Uint8Array(await blob.arrayBuffer());
198
+ return finish(partTypeForMime(mimeType), mimeType, { data: Buffer.from(bytes).toString("base64") }, name ? { name, ...opts } : opts);
199
+ }
200
+ /** Build a part from in-memory bytes. `mimeType` is required — bytes carry no extension. */
201
+ export function fromBytes(bytes, mimeType, opts = {}) {
202
+ const data = Buffer.from(toBytes(bytes)).toString("base64");
203
+ return finish(partTypeForMime(mimeType), mimeType, { data }, opts);
204
+ }
205
+ /** Read a file now and base64 it now, so the part never carries the path (§1B). */
206
+ export async function fromPath(p, opts = {}) {
207
+ const mimeType = opts.mimeType ?? mediaTypeFor(p)?.mimeType;
208
+ if (!mimeType)
209
+ throw unknownExtension(p);
210
+ const bytes = await fs.readFile(p);
211
+ return finish(partTypeForMime(mimeType), mimeType, { data: bytes.toString("base64") }, { name: path.basename(p), ...opts });
212
+ }
213
+ /** Parse `data:<mime>;base64,<b64>` into `{mimeType, data}`, so two spellings of the same
214
+ * bytes cannot diverge downstream. */
215
+ export function fromDataUrl(url, opts = {}) {
216
+ const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(url);
217
+ if (!m)
218
+ throw new ContentPartError("malformed data: URL", "source-missing");
219
+ const mimeType = opts.mimeType ?? (m[1] || "application/octet-stream");
220
+ const data = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3]), "utf8").toString("base64");
221
+ return finish(partTypeForMime(mimeType), mimeType, { data }, opts);
222
+ }
223
+ /** Keep an `http(s):` URL as a url part; mime comes from the table or an explicit override. */
224
+ export function fromUrl(url, opts = {}) {
225
+ const mimeType = opts.mimeType ?? mediaTypeFor(new URL(url).pathname)?.mimeType;
226
+ if (!mimeType)
227
+ throw unknownExtension(new URL(url).pathname);
228
+ return finish(partTypeForMime(mimeType), mimeType, { url }, opts);
229
+ }
230
+ function finish(type, mimeType, source, opts) {
231
+ const part = { type, mimeType, ...source };
232
+ if (type === "file" && opts.name)
233
+ part.name = opts.name;
234
+ return validatePart(part, opts.maxPartBytes);
235
+ }
236
+ // --------------------------------------------------------------------------- //
237
+ // §8A emission — the positive allowlist, and the unsupported-part rule.
238
+ // --------------------------------------------------------------------------- //
239
+ /** The ONLY block types that may reach each style's wire. A part encoding to anything
240
+ * else never leaves this module (map-and-hope is how content gets silently discarded). */
241
+ const ALLOWLIST = {
242
+ openai: new Set(["text", "image_url", "file", "input_audio"]),
243
+ anthropic: new Set(["text", "image", "document"]),
244
+ };
245
+ /** OpenAI's `input_audio.format` is a bare format name, not a mime type. */
246
+ const AUDIO_FORMATS = { "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/wav": "wav", "audio/x-wav": "wav" };
247
+ const dataUrl = (mimeType, data) => `data:${mimeType};base64,${data}`;
248
+ /**
249
+ * Encode one part into its provider block, or `undefined` when the style defines no shape
250
+ * for it. Refusals are explicit, never a fall-through: `openai × file+url` (Chat Completions
251
+ * has no URL form for a file) and `anthropic × audio` (the provider defines no audio block).
252
+ */
253
+ export function encodePart(part, style) {
254
+ if (part.type === "text")
255
+ return { type: "text", text: part.text };
256
+ if (style === "openai") {
257
+ switch (part.type) {
258
+ case "image":
259
+ return { type: "image_url", image_url: { url: part.data ? dataUrl(part.mimeType, part.data) : part.url } };
260
+ case "file":
261
+ // `file_data` REQUIRES the `data:<mime>;base64,` prefix — a bare base64 string is a 400.
262
+ if (!part.data)
263
+ return undefined;
264
+ return { type: "file", file: { filename: part.name ?? "file", file_data: dataUrl(part.mimeType, part.data) } };
265
+ case "audio":
266
+ if (!part.data)
267
+ return undefined;
268
+ return { type: "input_audio", input_audio: { data: part.data, format: AUDIO_FORMATS[part.mimeType] ?? part.mimeType.replace(/^audio\//, "") } };
269
+ }
270
+ }
271
+ switch (part.type) {
272
+ case "image":
273
+ return part.data
274
+ ? { type: "image", source: { type: "base64", media_type: part.mimeType, data: part.data } }
275
+ : { type: "image", source: { type: "url", url: part.url } };
276
+ case "file":
277
+ return part.data
278
+ ? { type: "document", source: { type: "base64", media_type: part.mimeType, data: part.data } }
279
+ : { type: "document", source: { type: "url", url: part.url } };
280
+ case "audio":
281
+ return undefined; // Anthropic defines no audio block — a named refusal, not an oversight.
282
+ }
283
+ }
284
+ const warnedUnsupported = new Set();
285
+ /** Test seam: forget which unsupported (style, type) pairs have already been warned about. */
286
+ export function resetUnsupportedWarnings() {
287
+ warnedUnsupported.clear();
288
+ }
289
+ /** The text a degraded (non-failing) unsupported part leaves behind — never silence. */
290
+ export function unsupportedPlaceholder(part) {
291
+ return `[unsupported ${part.type} part (${part.mimeType}, ${partBytes(part)} bytes)]`;
292
+ }
293
+ /** Warn once per (style, type, reason) — the same latch the unsupported-block path uses,
294
+ * keyed separately (by `reason`, not by the message text) so an oversize warning and a
295
+ * no-block warning never suppress each other, and a differently-sized oversize part of the
296
+ * same type doesn't re-trigger the latch. */
297
+ function warnUnsupportedOnce(style, type, reason, message) {
298
+ const key = `${style}:${type}:${reason}`;
299
+ if (warnedUnsupported.has(key))
300
+ return;
301
+ warnedUnsupported.add(key);
302
+ console.warn(`[toolnexus] ${message}`);
303
+ }
304
+ /**
305
+ * Encode a list of parts for one style, applying the §8A rules:
306
+ * an **attached** part the style cannot represent raises before any HTTP call; a **derived**
307
+ * part degrades to a text placeholder and warns once. `onUnsupportedPart` overrides both.
308
+ * Every emitted block is asserted against the style's allowlist.
309
+ *
310
+ * `maxPartBytes` (§1B) is enforced here too, at assembly, over EVERY part regardless of
311
+ * provenance — a part that arrived from an MCP server never passed through an edge
312
+ * constructor, so a limit it can walk around is not a limit. Going over follows the exact
313
+ * same provenance split as an unsupported block: an attached part errors, a derived one
314
+ * degrades to the placeholder so a remote server still cannot fail the run.
315
+ */
316
+ export function encodeParts(parts, opts) {
317
+ const mode = opts.onUnsupportedPart ?? (opts.provenance === "attached" ? "error" : "text");
318
+ const out = [];
319
+ for (const raw of parts) {
320
+ const part = validatePart(raw);
321
+ if (opts.maxPartBytes !== undefined && part.type !== "text") {
322
+ const bytes = partBytes(part);
323
+ if (bytes > opts.maxPartBytes) {
324
+ if (mode === "error") {
325
+ throw new ContentPartError(`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes}`, "too-large");
326
+ }
327
+ warnUnsupportedOnce(opts.style, part.type, "too-large", `content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes} — sending a text placeholder`);
328
+ out.push({ type: "text", text: unsupportedPlaceholder(part) });
329
+ continue;
330
+ }
331
+ }
332
+ const block = encodePart(part, opts.style);
333
+ if (block && ALLOWLIST[opts.style].has(String(block.type))) {
334
+ out.push(block);
335
+ continue;
336
+ }
337
+ if (mode === "error") {
338
+ throw new ContentPartError(`provider style "${opts.style}" defines no block for a "${part.type}" content part` +
339
+ (part.type !== "text" ? ` (${part.mimeType})` : ""), "unsupported");
340
+ }
341
+ warnUnsupportedOnce(opts.style, part.type, "unsupported", `provider style "${opts.style}" has no block for a "${part.type}" part — sending a text placeholder`);
342
+ out.push({ type: "text", text: unsupportedPlaceholder(part) });
343
+ }
344
+ return out;
345
+ }
346
+ /**
347
+ * Read an inbound OpenAI-shaped content block back into a `ContentPart` (§11). Accepts both
348
+ * a `ContentPart` written literally and the provider-native block the same part encodes to,
349
+ * so a caller's own OpenAI messages translate as faithfully as ours do.
350
+ */
351
+ export function inboundPart(block) {
352
+ if (isContentPart(block))
353
+ return block;
354
+ if (!block || typeof block !== "object")
355
+ return undefined;
356
+ const b = block;
357
+ if (b.type === "image_url" && b.image_url?.url) {
358
+ const url = String(b.image_url.url);
359
+ try {
360
+ return url.startsWith("data:") ? fromDataUrl(url) : { type: "image", mimeType: mediaTypeFor(new URL(url).pathname)?.mimeType ?? "image/*", url };
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ if (b.type === "file" && typeof b.file?.file_data === "string") {
367
+ const part = fromDataUrl(String(b.file.file_data));
368
+ part.type = "file";
369
+ if (b.file.filename)
370
+ part.name = String(b.file.filename);
371
+ return part;
372
+ }
373
+ if (b.type === "input_audio" && typeof b.input_audio?.data === "string") {
374
+ const fmt = String(b.input_audio.format ?? "");
375
+ const mimeType = fmt === "mp3" ? "audio/mpeg" : fmt === "wav" ? "audio/wav" : `audio/${fmt || "*"}`;
376
+ return { type: "audio", mimeType, data: String(b.input_audio.data) };
377
+ }
378
+ return undefined;
379
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./content.js";
2
+ export * from "./wire.js";
1
3
  export * from "./types.js";
2
4
  export * from "./mcp.js";
3
5
  export * from "./skill.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./content.js";
2
+ export * from "./wire.js";
1
3
  export * from "./types.js";
2
4
  export * from "./mcp.js";
3
5
  export * from "./skill.js";
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
3
+ import { type ContentPart } from "./content.js";
3
4
  /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
4
5
  export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
5
6
  /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
@@ -37,6 +38,14 @@ export declare function parseMcpConfig(input: string | object): McpConfig;
37
38
  * environment, not the committed config. Values are never logged.
38
39
  */
39
40
  export declare function expandEnvHeaders(headers?: Record<string, string>): Record<string, string> | undefined;
41
+ /**
42
+ * §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
43
+ * server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
44
+ * `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
45
+ * carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
46
+ * takes it there.
47
+ */
48
+ export declare function mcpContentToParts(content: unknown): ContentPart[] | undefined;
40
49
  export interface McpSource {
41
50
  tools: Tool[];
42
51
  status: Record<string, McpStatus>;