@oharness/engine 0.8.0 → 0.9.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.
@@ -1,33 +1,104 @@
1
+ import type { MediaAccess } from "../../mcp/builtin.ts";
2
+ import type { AuthManager } from "../../auth/manager.ts";
1
3
  import type { McpToolDefinition } from "../../mcp/protocol.ts";
2
4
  import type { Tool, ToolContext } from "../../tools/types.ts";
3
5
  import type { RuntimePlugin } from "../types.ts";
4
6
  export interface StudioPluginOptions {
5
- /** The gateway credential, from the same sources completions resolve. */
6
- secret(): Promise<string | undefined>;
7
+ /** Who calls are made as, and where they go. Asked before every call. */
8
+ access(): Promise<MediaAccess | undefined>;
7
9
  }
10
+ /**
11
+ * The credential media calls go out on: the OHarness account, and nothing else.
12
+ *
13
+ * Media is billed only through our gateway, which charges the account (or the
14
+ * team it bills to) in OHarness credits the way it charges chat. A vendor key
15
+ * the user happens to hold for chat is deliberately not used here: two ways to
16
+ * pay for the same image would be two balances to explain, and a generation
17
+ * the user expected on their plan would land on a different bill.
18
+ *
19
+ * The team rides in the path because the server offers nowhere else: all a
20
+ * caller sets is a base URL and a key.
21
+ */
22
+ export declare function mediaAccess(auth: AuthManager): Promise<MediaAccess | undefined>;
8
23
  export declare function createStudioPlugin(options: StudioPluginOptions): RuntimePlugin;
24
+ /** What the model is told when media exists but the user is not signed in. */
25
+ export declare const SIGN_IN_PROMPT: string;
26
+ /** One upstream tool: what it is called here, and what it produces. */
27
+ export interface FacadeEntry {
28
+ upstream: string;
29
+ name: string;
30
+ /**
31
+ * What generated files are named after; empty for a tool that only reads,
32
+ * which is also what makes it skip confirmation and download nothing.
33
+ */
34
+ kind: string;
35
+ note: string;
36
+ }
9
37
  /**
10
- * What each upstream tool is called here, and what it produces.
38
+ * The tools this file knows by name, and what to say about each.
11
39
  *
12
- * The rename is the whole of the mapping. Parameters are not restated: the
13
- * schema comes from the server, so a model can send anything that server
40
+ * The rename and the note are the whole of it. Parameters are not restated:
41
+ * the schema comes from the server, so a model can send anything that server
14
42
  * accepts and a parameter added upstream works the day it ships. Writing them
15
43
  * out here would be a second, staler copy of somebody else's list — and the
16
44
  * failure mode is silent, since a parameter this file has not heard of would
17
45
  * simply never reach the gateway.
46
+ *
47
+ * A tool missing from here is still offered; see `facadeEntry`.
18
48
  */
19
- export declare const FACADE: Array<{
20
- upstream: string;
21
- name: string;
22
- kind: string;
23
- note: string;
24
- }>;
49
+ export declare const FACADE: FacadeEntry[];
50
+ /**
51
+ * The name a tool is offered under: the known one, or one derived from the
52
+ * upstream name with the vendor taken off — `ottoport_remove_background`
53
+ * becomes `RemoveBackground`.
54
+ */
55
+ export declare function facadeName(upstream: string): string;
56
+ /**
57
+ * How to present a tool, whether or not this file has heard of it.
58
+ *
59
+ * An unknown tool is assumed to spend money unless its name says it only
60
+ * looks something up. Guessing the other way would let a new paid tool skip
61
+ * confirmation, and the cost of that guess is a charge the user never agreed
62
+ * to; guessing this way costs one extra click on a lookup.
63
+ */
64
+ export declare function facadeEntry(upstream: string): FacadeEntry;
65
+ /**
66
+ * A server-written description, in this harness's words.
67
+ *
68
+ * The vendor's name is not part of the product, and the server's text is full
69
+ * of it — "through an OttoPort image model", "call ottoport_list_models".
70
+ * Left alone, the second is worse than cosmetic: it names a tool the model
71
+ * does not have, under the name it is offered as here. So tool references
72
+ * become the names they are offered under, the vendor's name is dropped, and
73
+ * a sentence about something withheld or refused is dropped with it.
74
+ */
75
+ export declare function rebrand(text: string): string;
76
+ /**
77
+ * The server's input schema, as this harness offers it: descriptions in its
78
+ * words at every depth, and without the parameters the gateway refuses.
79
+ */
80
+ export declare function facadeSchema(inputSchema: unknown): {
81
+ properties?: Record<string, unknown>;
82
+ [k: string]: unknown;
83
+ };
25
84
  /**
26
85
  * One upstream tool, wearing this harness's name and manners.
27
86
  *
28
- * `inputSchema` is the server's own, with `path` added. Nothing validates it
29
- * here: the server validates its own inputs, and a second opinion derived from
30
- * its JSON Schema would only be a less accurate one — the same reasoning the
31
- * MCP tool adapter states.
87
+ * `inputSchema` is the server's own, rebranded, with `path` added. Nothing
88
+ * validates it here: the server validates its own inputs, and a second
89
+ * opinion derived from its JSON Schema would only be a less accurate one —
90
+ * the same reasoning the MCP tool adapter states.
91
+ */
92
+ export declare function facadeTool(entry: FacadeEntry, def: McpToolDefinition, call: (tool: string, args: Record<string, unknown>, ctx: ToolContext) => Promise<string>): Tool;
93
+ /**
94
+ * Where one output goes: into a folder, or under a name.
95
+ *
96
+ * A model asked for "a kitten picture" passes `media/kitten.png` as often as
97
+ * `media/` — and treating every path as a folder saved that one as
98
+ * `media/kitten.png/image-….jpg`, a folder named like an image with the image
99
+ * inside it. A path whose last part has an extension is a file name. The
100
+ * extension kept is the one the bytes have, because renaming a JPEG to .png
101
+ * makes a file that some viewers refuse; the name asked for is kept. A second
102
+ * output of the same call is numbered beside the first.
32
103
  */
33
- export declare function facadeTool(entry: (typeof FACADE)[number], def: McpToolDefinition, call: (tool: string, args: Record<string, unknown>, ctx: ToolContext) => Promise<string>): Tool;
104
+ export declare function destination(into: string | undefined, kind: string, index: number, extension: string): string;
@@ -1,11 +1,14 @@
1
1
  import path from "node:path";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { McpClient } from "../../mcp/client.js";
4
- import { ottoportMediaServer } from "../../mcp/builtin.js";
4
+ import { WITHHELD_TOOLS, bundledOttoport, ottoportCacheRoot, ottoportMediaServer, serverEntry } from "../../mcp/builtin.js";
5
+ import { updateOttoport } from "../../mcp/ottoport-updates.js";
6
+ import { officialBaseUrl, officialTeam } from "../../auth/providers/official.js";
5
7
  import { normalizeObjectSchema } from "../../util/schema.js";
6
8
  import { toolError, toolText } from "../../tools/types.js";
7
9
  /**
8
- * Image, video, speech and music, as four ordinary tools.
10
+ * Image, video, speech and music — and whatever else the media server adds —
11
+ * as ordinary tools.
9
12
  *
10
13
  * The generation itself belongs to a vendor and always will: the models change
11
14
  * weekly, each takes different parameters, and keeping a copy of that here
@@ -32,13 +35,45 @@ import { toolError, toolText } from "../../tools/types.js";
32
35
  */
33
36
  /** Where generated files land when the caller does not say. */
34
37
  const DEFAULT_DIR = "media";
38
+ /**
39
+ * How long an account token must still have when a call starts.
40
+ *
41
+ * The server holds the key it was started with for as long as it runs, and a
42
+ * video is a job it polls for up to ten minutes. A token that lapsed halfway
43
+ * through would lose a generation that has already been paid for, so the
44
+ * token is renewed early rather than at the last minute.
45
+ */
46
+ const ACCOUNT_TOKEN_VALID_MS = 15 * 60_000;
47
+ /**
48
+ * The credential media calls go out on: the OHarness account, and nothing else.
49
+ *
50
+ * Media is billed only through our gateway, which charges the account (or the
51
+ * team it bills to) in OHarness credits the way it charges chat. A vendor key
52
+ * the user happens to hold for chat is deliberately not used here: two ways to
53
+ * pay for the same image would be two balances to explain, and a generation
54
+ * the user expected on their plan would land on a different bill.
55
+ *
56
+ * The team rides in the path because the server offers nowhere else: all a
57
+ * caller sets is a base URL and a key.
58
+ */
59
+ export async function mediaAccess(auth) {
60
+ // An exported key wins over a session, the same order `officialAuth` loads in.
61
+ const session = process.env["OHARNESS_API_KEY"]
62
+ ? undefined
63
+ : await auth.accessToken("official", ACCOUNT_TOKEN_VALID_MS).catch(() => undefined);
64
+ const key = session ?? (await auth.secret("official").catch(() => undefined));
65
+ if (!key)
66
+ return undefined;
67
+ const team = officialTeam();
68
+ return { key, baseUrl: `${officialBaseUrl()}${team ? `/teams/${team}` : ""}` };
69
+ }
35
70
  export function createStudioPlugin(options) {
36
71
  return {
37
72
  id: "studio",
38
73
  version: "1.0.0",
39
74
  apiVersion: 1,
40
75
  // The id names it in config and code; this is what the panel shows.
41
- title: "AI Creative Media Plugin",
76
+ title: "OH Creative Media Generator",
42
77
  description: "Image, video, speech and music generation",
43
78
  parseConfig(raw) {
44
79
  if (raw !== undefined && (typeof raw !== "object" || raw === null)) {
@@ -47,76 +82,229 @@ export function createStudioPlugin(options) {
47
82
  return {};
48
83
  },
49
84
  async setup(ctx) {
50
- /**
51
- * No credential, no tools — and no subprocess either.
52
- *
53
- * Registering them anyway would put five tool definitions in the system
54
- * prompt of every request for every user who will never generate an
55
- * image, and each one would fail on the first call.
56
- */
57
- if (!(await options.secret()))
58
- return;
59
- const config = ottoportMediaServer();
60
- if (!config) {
85
+ // Resolved once, so every server this session starts is the same
86
+ // version: a newer one downloaded mid-session waits for the next.
87
+ const entry = serverEntry();
88
+ if (!entry) {
61
89
  ctx.logger.warn("studio tools unavailable: the gateway client is not installed");
62
90
  return;
63
91
  }
64
- const client = new McpClient("studio", config, ctx.logger, undefined, async () => options.secret());
65
- ctx.onDispose(async () => {
66
- await client.close().catch(() => { });
67
- });
68
92
  /**
69
- * Connected during setup, because the tools are built from what the
70
- * server says it takes.
93
+ * No credential, no tools — and no subprocess either, until there is
94
+ * one.
95
+ *
96
+ * Registering them anyway would put every media tool's definition in
97
+ * the system prompt of every request for every user who will never
98
+ * generate an image, and each one would fail on the first call.
71
99
  *
72
- * The alternative — connecting on the first call — would mean writing
73
- * the parameters out here, and a copy of someone else's parameter list
74
- * goes stale silently: a field added upstream would never reach the
75
- * gateway, and nothing would report it. One subprocess for an account
76
- * that actually has a media gateway is the cheaper mistake.
100
+ * But signing in is something people do after the app is open — on a
101
+ * first launch it is the usual order — and a plugin that decided once,
102
+ * at startup, left them with no media until a restart they had no
103
+ * reason to think of. So while signed out it offers a short paragraph
104
+ * instead, and that paragraph is read at the start of every turn: the
105
+ * turn that finds the user signed in starts the server and registers
106
+ * the tools, and since a turn reads its prompt before its tool list,
107
+ * that same turn can already use them.
77
108
  */
78
- let advertised;
109
+ let started;
110
+ const start = () => (started ??= enable(ctx, entry).then((ok) => {
111
+ // A gateway that could not be reached is tried again next turn.
112
+ if (!ok)
113
+ started = undefined;
114
+ return ok;
115
+ }));
116
+ if (await options.access()) {
117
+ await start();
118
+ return;
119
+ }
120
+ ctx.logger.info("studio tools waiting: not signed in to OHarness");
121
+ // Without it a model asked for an image says it cannot make images,
122
+ // which is untrue and sends the user nowhere. This way it says what to
123
+ // do about it — and stops saying it the moment it no longer applies.
124
+ ctx.prompts.register(async () => {
125
+ if (started && (await started))
126
+ return undefined;
127
+ if (!(await options.access()))
128
+ return SIGN_IN_PROMPT;
129
+ await start();
130
+ return undefined;
131
+ });
132
+ },
133
+ };
134
+ /**
135
+ * Start the media server and register what it offers. False when it could
136
+ * not be started, which is never fatal: a gateway that cannot be reached is
137
+ * a session without media tools, not a session that will not start.
138
+ */
139
+ async function enable(ctx, entry) {
140
+ checkForUpdate(ctx.logger, ctx.signal);
141
+ const open = async (access) => {
142
+ const client = new McpClient("studio", ottoportMediaServer(access, entry), ctx.logger);
79
143
  try {
80
144
  await client.connect();
81
- advertised = await client.listTools();
82
145
  }
83
146
  catch (err) {
84
- // Never fatal. A gateway that cannot be reached is a session without
85
- // media tools, not a session that will not start.
86
- ctx.logger.warn("studio tools unavailable", { error: String(err) });
87
147
  await client.close().catch(() => { });
88
- return;
148
+ throw err;
89
149
  }
90
- const call = async (tool, args, tctx) => {
91
- const result = await client.callTool(tool, args, tctx.signal);
92
- const text = result.content
93
- .map((block) => (block.type === "text" ? block.text : ""))
94
- .join("")
95
- .trim();
96
- if (result.isError)
97
- throw new Error(text || "the gateway refused the request");
98
- return text;
99
- };
100
- for (const entry of FACADE) {
101
- const def = advertised.find((candidate) => candidate.name === entry.upstream);
102
- // A tool this gateway does not serve is simply not offered; registering
103
- // one from a hardcoded list would put a button here that cannot work.
104
- if (!def)
105
- continue;
106
- ctx.tools.register(facadeTool(entry, def, call));
150
+ return client;
151
+ };
152
+ /**
153
+ * The server for the credential as it stands now.
154
+ *
155
+ * An account token is renewed while the session runs, and the server
156
+ * holds whatever key it was started with — so a new token means a new
157
+ * server. The old one is closed only once nothing is using it: closing
158
+ * it under a video that is still rendering would throw away a
159
+ * generation that has already been charged.
160
+ */
161
+ let live;
162
+ const retire = (connection) => {
163
+ connection.retired = true;
164
+ if (connection.users === 0) {
165
+ void connection.client.then((client) => client.close()).catch(() => { });
107
166
  }
108
- },
109
- };
167
+ };
168
+ const acquire = async () => {
169
+ const access = await options.access();
170
+ if (!access)
171
+ throw new Error("not signed in — sign in to OHarness to generate media");
172
+ const signature = `${access.baseUrl}\n${access.key}`;
173
+ if (live?.signature !== signature) {
174
+ if (live)
175
+ retire(live);
176
+ const fresh = { signature, client: open(access), users: 0, retired: false };
177
+ // A server that would not start is not kept: the next call tries again.
178
+ fresh.client.catch(() => {
179
+ if (live === fresh)
180
+ live = undefined;
181
+ });
182
+ live = fresh;
183
+ }
184
+ live.users += 1;
185
+ return live;
186
+ };
187
+ const release = (connection) => {
188
+ connection.users -= 1;
189
+ if (connection.retired)
190
+ retire(connection);
191
+ };
192
+ ctx.onDispose(async () => {
193
+ if (live)
194
+ retire(live);
195
+ });
196
+ /**
197
+ * Connected during setup, because the tools are built from what the
198
+ * server says it takes.
199
+ *
200
+ * The alternative — connecting on the first call — would mean writing
201
+ * the parameters out here, and a copy of someone else's parameter list
202
+ * goes stale silently: a field added upstream would never reach the
203
+ * gateway, and nothing would report it. One subprocess for an account
204
+ * that actually has a media gateway is the cheaper mistake.
205
+ */
206
+ let advertised;
207
+ const connection = await acquire().catch(() => undefined);
208
+ try {
209
+ if (!connection)
210
+ throw new Error("could not start the media server");
211
+ advertised = await (await connection.client).listTools();
212
+ }
213
+ catch (err) {
214
+ // Never fatal. A gateway that cannot be reached is a session without
215
+ // media tools, not a session that will not start.
216
+ ctx.logger.warn("studio tools unavailable", { error: String(err) });
217
+ if (live)
218
+ retire(live);
219
+ live = undefined;
220
+ return false;
221
+ }
222
+ finally {
223
+ if (connection)
224
+ release(connection);
225
+ }
226
+ const call = async (tool, args, tctx) => {
227
+ const connection = await acquire();
228
+ let result;
229
+ try {
230
+ result = await (await connection.client).callTool(tool, args, tctx.signal);
231
+ }
232
+ finally {
233
+ release(connection);
234
+ }
235
+ const text = result.content
236
+ .map((block) => (block.type === "text" ? block.text : ""))
237
+ .join("")
238
+ .trim();
239
+ if (result.isError)
240
+ throw new Error(text || "the gateway refused the request");
241
+ return text;
242
+ };
243
+ /**
244
+ * Whatever the server advertises, less what is withheld.
245
+ *
246
+ * Not a list of the tools this file knows: the server gains tools on
247
+ * its vendor's schedule, and each should reach the model the day it
248
+ * ships rather than the day a release here catches up. A tool this
249
+ * gateway does not serve is equally never offered — a button that
250
+ * cannot work is worse than none.
251
+ */
252
+ const taken = new Set();
253
+ for (const def of advertised) {
254
+ if (WITHHELD_TOOLS.includes(def.name))
255
+ continue;
256
+ const entry = facadeEntry(def.name);
257
+ // Two upstream names that fold to one here would otherwise have the
258
+ // second silently replace the first.
259
+ if (taken.has(entry.name)) {
260
+ ctx.logger.warn("studio tool skipped: name already taken", { tool: def.name, name: entry.name });
261
+ continue;
262
+ }
263
+ taken.add(entry.name);
264
+ ctx.tools.register(facadeTool(entry, def, call));
265
+ }
266
+ ctx.logger.info("studio tools registered", { tools: taken.size });
267
+ return true;
268
+ }
110
269
  }
270
+ /** What the model is told when media exists but the user is not signed in. */
271
+ export const SIGN_IN_PROMPT = "Image, video, speech and music generation is available in OHarness, but only with an OHarness " +
272
+ "account: it is billed to the account's credits, and this user is not signed in. If they ask for " +
273
+ "media, say so and ask them to sign in — from the account menu in the app, or with " +
274
+ "`oharness auth login official` in a terminal. The tools appear as soon as they have.";
111
275
  /**
112
- * What each upstream tool is called here, and what it produces.
276
+ * Look for a newer media server, without anyone waiting on it.
113
277
  *
114
- * The rename is the whole of the mapping. Parameters are not restated: the
115
- * schema comes from the server, so a model can send anything that server
278
+ * Unawaited and unthrowing by construction: a slow or absent registry is a
279
+ * session that keeps the server it has, never one that starts late.
280
+ */
281
+ function checkForUpdate(logger, signal) {
282
+ const bundled = bundledOttoport();
283
+ if (!bundled)
284
+ return;
285
+ void updateOttoport({ root: ottoportCacheRoot(), current: bundled.version, signal })
286
+ .then((result) => {
287
+ if (result.status === "installed") {
288
+ logger.info("media server updated; the new version is used from the next session", { version: result.version });
289
+ }
290
+ else if (result.status === "failed") {
291
+ // Debug, not a warning: offline is an ordinary way to be.
292
+ logger.debug("media server update check failed", { error: result.error });
293
+ }
294
+ })
295
+ .catch(() => { });
296
+ }
297
+ /**
298
+ * The tools this file knows by name, and what to say about each.
299
+ *
300
+ * The rename and the note are the whole of it. Parameters are not restated:
301
+ * the schema comes from the server, so a model can send anything that server
116
302
  * accepts and a parameter added upstream works the day it ships. Writing them
117
303
  * out here would be a second, staler copy of somebody else's list — and the
118
304
  * failure mode is silent, since a parameter this file has not heard of would
119
305
  * simply never reach the gateway.
306
+ *
307
+ * A tool missing from here is still offered; see `facadeEntry`.
120
308
  */
121
309
  export const FACADE = [
122
310
  {
@@ -157,25 +345,130 @@ export const FACADE = [
157
345
  "full-length track costs several dollars — ask for the length the user actually needs.",
158
346
  },
159
347
  ];
348
+ const VENDOR_PREFIX = /^ottoport_/;
349
+ /** Said of every tool this file does not know that may spend money. */
350
+ const UNKNOWN_SPEND_NOTE = "Saves what it produces into the workspace and returns the paths. Billed to the user's OHarness " +
351
+ "credits — do not repeat a call unless the user asked for another.";
352
+ /**
353
+ * The name a tool is offered under: the known one, or one derived from the
354
+ * upstream name with the vendor taken off — `ottoport_remove_background`
355
+ * becomes `RemoveBackground`.
356
+ */
357
+ export function facadeName(upstream) {
358
+ const known = FACADE.find((entry) => entry.upstream === upstream);
359
+ if (known)
360
+ return known.name;
361
+ return upstream
362
+ .replace(VENDOR_PREFIX, "")
363
+ .split(/[^A-Za-z0-9]+/)
364
+ .filter(Boolean)
365
+ .map((part) => part[0].toUpperCase() + part.slice(1))
366
+ .join("");
367
+ }
368
+ /**
369
+ * How to present a tool, whether or not this file has heard of it.
370
+ *
371
+ * An unknown tool is assumed to spend money unless its name says it only
372
+ * looks something up. Guessing the other way would let a new paid tool skip
373
+ * confirmation, and the cost of that guess is a charge the user never agreed
374
+ * to; guessing this way costs one extra click on a lookup.
375
+ */
376
+ export function facadeEntry(upstream) {
377
+ const known = FACADE.find((entry) => entry.upstream === upstream);
378
+ if (known)
379
+ return known;
380
+ const bare = upstream.replace(VENDOR_PREFIX, "");
381
+ const name = facadeName(upstream);
382
+ if (/^(list|get|describe|search)_/.test(bare))
383
+ return { upstream, name, kind: "", note: "" };
384
+ // Named for what it makes, as a file prefix: `generate_3d` → `3d-…`.
385
+ const kind = bare
386
+ .replace(/^generate_/, "")
387
+ .toLowerCase()
388
+ .replace(/[^a-z0-9]+/g, "-")
389
+ .replace(/^-+|-+$/g, "");
390
+ return { upstream, name, kind: kind || "media", note: UNKNOWN_SPEND_NOTE };
391
+ }
392
+ /** Parameters the account gateway refuses: it delivers results itself. */
393
+ const REFUSED_PARAMETERS = ["webhook_url", "webhook_secret"];
394
+ /**
395
+ * A server-written description, in this harness's words.
396
+ *
397
+ * The vendor's name is not part of the product, and the server's text is full
398
+ * of it — "through an OttoPort image model", "call ottoport_list_models".
399
+ * Left alone, the second is worse than cosmetic: it names a tool the model
400
+ * does not have, under the name it is offered as here. So tool references
401
+ * become the names they are offered under, the vendor's name is dropped, and
402
+ * a sentence about something withheld or refused is dropped with it.
403
+ */
404
+ export function rebrand(text) {
405
+ const sentences = text.split(/(?<=[.!?])\s+/).flatMap((sentence) => {
406
+ if (WITHHELD_TOOLS.some((tool) => sentence.includes(tool)))
407
+ return [];
408
+ if (!REFUSED_PARAMETERS.some((param) => sentence.includes(param)))
409
+ return [sentence];
410
+ // "…returns a video URL, unless `webhook_url` is given." keeps its first
411
+ // half; a sentence that is only about the webhook goes altogether.
412
+ const trimmed = sentence.replace(/,[^,]*webhook_(?:url|secret)[^,.!?]*(?=[.!?]?$)/, "");
413
+ return trimmed === sentence ? [] : [trimmed];
414
+ });
415
+ return sentences
416
+ .join(" ")
417
+ .replace(/\bottoport_[a-z0-9_]+/g, (tool) => facadeName(tool))
418
+ .replace(/\b(an?)\s+OttoPort(?:'s)?\s+(\w)/gi, (_m, article, next) => {
419
+ const vowel = /[aeiou]/i.test(next);
420
+ const upper = article[0] === "A";
421
+ return `${vowel ? (upper ? "An" : "an") : upper ? "A" : "a"} ${next}`;
422
+ })
423
+ .replace(/\bOttoPort(?:'s)?\s+(\w)/gi, (_m, next, offset, whole) =>
424
+ // At the start of a sentence the next word now starts it.
425
+ /(^|[.!?]\s*)$/.test(whole.slice(0, offset)) ? next.toUpperCase() : next)
426
+ .replace(/\bOttoPort\b/gi, "the gateway");
427
+ }
428
+ /**
429
+ * The server's input schema, as this harness offers it: descriptions in its
430
+ * words at every depth, and without the parameters the gateway refuses.
431
+ */
432
+ export function facadeSchema(inputSchema) {
433
+ const walk = (node) => {
434
+ if (Array.isArray(node))
435
+ return node.map(walk);
436
+ if (!node || typeof node !== "object")
437
+ return node;
438
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => [
439
+ key,
440
+ key === "description" && typeof value === "string" ? rebrand(value) : walk(value),
441
+ ]));
442
+ };
443
+ const schema = walk(normalizeObjectSchema((inputSchema ?? {})));
444
+ if (schema.properties) {
445
+ schema.properties = Object.fromEntries(Object.entries(schema.properties).filter(([key]) => !REFUSED_PARAMETERS.includes(key)));
446
+ }
447
+ if (Array.isArray(schema.required)) {
448
+ schema.required = schema.required.filter((key) => !REFUSED_PARAMETERS.includes(key));
449
+ }
450
+ return schema;
451
+ }
160
452
  /** The one parameter that is ours: the gateway has no idea where files go. */
161
453
  const PATH_PROPERTY = {
162
454
  type: "string",
163
- description: `Where to save the result. Relative to the project; default ${DEFAULT_DIR}/.`,
455
+ description: `Where to save the result, relative to the project: a folder, or a file name (the file gets the ` +
456
+ `format the model actually returned, so kitten.png may be saved as kitten.jpg). Default ${DEFAULT_DIR}/.`,
164
457
  };
165
458
  /**
166
459
  * One upstream tool, wearing this harness's name and manners.
167
460
  *
168
- * `inputSchema` is the server's own, with `path` added. Nothing validates it
169
- * here: the server validates its own inputs, and a second opinion derived from
170
- * its JSON Schema would only be a less accurate one — the same reasoning the
171
- * MCP tool adapter states.
461
+ * `inputSchema` is the server's own, rebranded, with `path` added. Nothing
462
+ * validates it here: the server validates its own inputs, and a second
463
+ * opinion derived from its JSON Schema would only be a less accurate one —
464
+ * the same reasoning the MCP tool adapter states.
172
465
  */
173
466
  export function facadeTool(entry, def, call) {
174
467
  const generates = entry.kind !== "";
175
- const schema = normalizeObjectSchema(def.inputSchema ?? {});
468
+ const schema = facadeSchema(def.inputSchema);
176
469
  return {
177
470
  name: entry.name,
178
- description: `${def.description ?? entry.name} ${entry.note}`,
471
+ description: [rebrand(def.description ?? entry.name), entry.note].filter(Boolean).join(" "),
179
472
  inputSchema: {
180
473
  ...schema,
181
474
  ...(generates
@@ -301,8 +594,7 @@ async function download(url, ctx, into, kind, index) {
301
594
  }
302
595
  /** One file into the workspace, named for what made it. */
303
596
  async function write(bytes, ctx, into, kind, index, extension) {
304
- const name = `${kind}-${Date.now()}${index ? `-${index + 1}` : ""}${extension}`;
305
- const target = path.join(into ?? DEFAULT_DIR, name);
597
+ const target = destination(into, kind, index, extension);
306
598
  // Through the workspace policy, so a model-supplied `path` cannot write
307
599
  // outside the roots this session is allowed to touch.
308
600
  const absolute = ctx.host.resolvePath?.(target, "write") ?? path.resolve(ctx.cwd, target);
@@ -310,6 +602,26 @@ async function write(bytes, ctx, into, kind, index, extension) {
310
602
  await writeFile(absolute, bytes);
311
603
  return path.relative(ctx.cwd, absolute) || absolute;
312
604
  }
605
+ /**
606
+ * Where one output goes: into a folder, or under a name.
607
+ *
608
+ * A model asked for "a kitten picture" passes `media/kitten.png` as often as
609
+ * `media/` — and treating every path as a folder saved that one as
610
+ * `media/kitten.png/image-….jpg`, a folder named like an image with the image
611
+ * inside it. A path whose last part has an extension is a file name. The
612
+ * extension kept is the one the bytes have, because renaming a JPEG to .png
613
+ * makes a file that some viewers refuse; the name asked for is kept. A second
614
+ * output of the same call is numbered beside the first.
615
+ */
616
+ export function destination(into, kind, index, extension) {
617
+ const numbered = index ? `-${index + 1}` : "";
618
+ const asked = into ? path.extname(into) : "";
619
+ if (into && asked && !into.endsWith("/") && !into.endsWith(path.sep)) {
620
+ const stem = into.slice(0, -asked.length);
621
+ return `${stem}${numbered}${extension || asked}`;
622
+ }
623
+ return path.join(into ?? DEFAULT_DIR, `${kind}-${Date.now()}${numbered}${extension}`);
624
+ }
313
625
  /**
314
626
  * An extension for a media type, when it says something useful.
315
627
  *