@oharness/engine 0.7.1 → 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.
- package/dist/agent/agent.d.ts +1 -0
- package/dist/agent/agent.js +5 -0
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/permissions.js +8 -0
- package/dist/agent/permissions.js.map +1 -1
- package/dist/auth/manager.d.ts +8 -1
- package/dist/auth/manager.js +26 -4
- package/dist/auth/manager.js.map +1 -1
- package/dist/auth/providers/official.js +3 -2
- package/dist/auth/providers/official.js.map +1 -1
- package/dist/auth/store.d.ts +14 -8
- package/dist/auth/store.js +87 -3
- package/dist/auth/store.js.map +1 -1
- package/dist/browser/cdp.d.ts +75 -0
- package/dist/browser/cdp.js +136 -0
- package/dist/browser/cdp.js.map +1 -0
- package/dist/browser/chrome.d.ts +19 -0
- package/dist/browser/chrome.js +306 -0
- package/dist/browser/chrome.js.map +1 -0
- package/dist/browser/extension.d.ts +35 -0
- package/dist/browser/extension.js +297 -0
- package/dist/browser/extension.js.map +1 -0
- package/dist/browser/host-manifest.d.ts +78 -0
- package/dist/browser/host-manifest.js +166 -0
- package/dist/browser/host-manifest.js.map +1 -0
- package/dist/browser/host.d.ts +27 -0
- package/dist/browser/host.js +311 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/index.d.ts +26 -0
- package/dist/browser/index.js +15 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/login-import.d.ts +98 -0
- package/dist/browser/login-import.js +244 -0
- package/dist/browser/login-import.js.map +1 -0
- package/dist/browser/plugin.d.ts +109 -0
- package/dist/browser/plugin.js +22 -0
- package/dist/browser/plugin.js.map +1 -0
- package/dist/browser/presence.d.ts +29 -0
- package/dist/browser/presence.js +54 -0
- package/dist/browser/presence.js.map +1 -0
- package/dist/browser/provider.d.ts +17 -0
- package/dist/browser/provider.js +13 -0
- package/dist/browser/provider.js.map +1 -0
- package/dist/browser/screencast.d.ts +42 -0
- package/dist/browser/screencast.js +99 -0
- package/dist/browser/screencast.js.map +1 -0
- package/dist/browser/session.d.ts +320 -0
- package/dist/browser/session.js +837 -0
- package/dist/browser/session.js.map +1 -0
- package/dist/browser/snapshot.d.ts +49 -0
- package/dist/browser/snapshot.js +145 -0
- package/dist/browser/snapshot.js.map +1 -0
- package/dist/browser/socket.d.ts +15 -0
- package/dist/browser/socket.js +32 -0
- package/dist/browser/socket.js.map +1 -0
- package/dist/browser/target.d.ts +112 -0
- package/dist/browser/target.js +20 -0
- package/dist/browser/target.js.map +1 -0
- package/dist/browser/tools.d.ts +3 -0
- package/dist/browser/tools.js +537 -0
- package/dist/browser/tools.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js.map +1 -1
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +14 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/harness.d.ts +49 -0
- package/dist/harness.js +100 -4
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/builtin.d.ts +65 -8
- package/dist/mcp/builtin.js +66 -35
- package/dist/mcp/builtin.js.map +1 -1
- package/dist/mcp/ottoport-updates.d.ts +90 -0
- package/dist/mcp/ottoport-updates.js +228 -0
- package/dist/mcp/ottoport-updates.js.map +1 -0
- package/dist/providers/piai.js +7 -2
- package/dist/providers/piai.js.map +1 -1
- package/dist/runtime-plugins/builtin/studio.d.ts +87 -16
- package/dist/runtime-plugins/builtin/studio.js +374 -62
- package/dist/runtime-plugins/builtin/studio.js.map +1 -1
- package/dist/runtime-plugins/host.js +8 -2
- package/dist/runtime-plugins/host.js.map +1 -1
- package/dist/tools/define.d.ts +1 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/define.js.map +1 -1
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js.map +1 -1
- package/package.json +4 -8
|
@@ -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
|
-
/**
|
|
6
|
-
|
|
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
|
-
*
|
|
38
|
+
* The tools this file knows by name, and what to say about each.
|
|
11
39
|
*
|
|
12
|
-
* The rename
|
|
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:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
29
|
-
* here: the server validates its own inputs, and a second
|
|
30
|
-
* its JSON Schema would only be a less accurate one —
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
73
|
-
* the
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
-
|
|
148
|
+
throw err;
|
|
89
149
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
*
|
|
276
|
+
* Look for a newer media server, without anyone waiting on it.
|
|
113
277
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
|
169
|
-
* here: the server validates its own inputs, and a second
|
|
170
|
-
* its JSON Schema would only be a less accurate one —
|
|
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 =
|
|
468
|
+
const schema = facadeSchema(def.inputSchema);
|
|
176
469
|
return {
|
|
177
470
|
name: entry.name,
|
|
178
|
-
description:
|
|
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
|
|
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
|
*
|