@mercury-fw/channel-http 0.10.1 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
8
+
9
+ - The person's language is the one they saved, else the one their client asks for, else the app's default. The HTTP channel passes on the request's `Accept-Language` header (what browsers send), Google Chat the language of the person's Chat client when its event carries one (`InboundTurn.languageHint`). Someone the agent refuses gets it too.
10
+ - A channel declares its own texts in English in `messages`, under its name (`google-chat.`, `http.`), checked like a plugin's; it gets them through `ctx.i18n.forRequest(principal, hint)`.
11
+ - The replies to a confirmation are the core's, in the person's language: `ctx.confirm` returns `{ text, resume? }` and `ctx.resolveConfirmation` the outcome with its `text`. A confirmed action reads "Confirmed and done." instead of its raw result as JSON. `confirmReply`, `RESUME_AFTER_CONFIRM` and `PENDING_CONFIRMATION_NOTE` are gone from the contract, and `tryConfirm` from `@mercury-fw/confirm-engine` (`resolveConfirmation` reports the outcome; the core words it).
12
+ - The stand-in for an answer the model didn't write because the turn staged an action (`core.pending.note`, "Action pending, waiting for confirmation.") is no longer streamed as text: `finalize(text, { pendingNote })` says the text begins with it, and channels drop it through that, never by comparing text.
13
+ - A plain staging's single choice has an empty label: each channel words its own button ("Confirm" on Google Chat, in the person's language). `loginLinkText(login, t)` takes the person's texts.
14
+ - HTTP: `Accept-Language` on `/turn`, `/confirm` and `/login/callback`; `Content-Language` on the responses of `/turn` and `/confirm`; the `final` event carries `pendingNote`; the login page reads in the browser's language.
15
+ - Google Chat: the cards' titles, status lines and confirm button are `google-chat.*` texts; the client's `common.userLocale` is the hint when the event carries it.
16
+ - The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
17
+
18
+ The texts that were in Italian (the cards, the replies to a confirmation, the pending note) are now English by default. An app that wants them in Italian adds their keys to `locales/it.json` (`core.pending.note`, `core.confirm.*`, `core.login.link*`, `google-chat.*`, `http.*`; the log lists the missing ones at startup). A UI on the HTTP channel can hide the pending note through `final`'s `pendingNote`, and words the button of a choice with an empty label itself. A channel written for contract 5 isn't loaded by this core, and the log says why: rebuild it against `CHANNEL_API_VERSION` 6.
19
+
20
+ - ebde9e2: The texts a person sees can be in their language. The core's own fixed texts (the refusal of someone the agent doesn't know, the account-linking replies, the login errors, the tool status lines, the terminal's own lines) are English, each under a key (`core.access.unknown`, …), and an app adds a language with a file of its own: `i18n: { defaultLocale, locales: { it } }` in `mercury.config.ts`, each file mapping keys to texts. A person picks their language by asking the agent, which saves it with `set_preferred_language` (only the languages the app ships; not offered when it ships English alone) and answers in it from then on. Whoever hasn't picked one gets `defaultLocale`, then English. A key a language's file lacks shows as the key itself, and the log names the missing keys at startup. `GET /me` on the HTTP channel also says the caller's language.
21
+
22
+ The texts that were in Italian (tool status lines, the terminal's lines) are now English by default, and the language is no longer one of the facts inferred from conversations.
23
+
24
+ An existing app keeps working, with these changes:
25
+
26
+ 1. `access.unknownMessage` is gone. To keep a custom refusal, put it in `locales/en.json` as `{ "core.access.unknown": "…" }`, and in `mercury.config.ts` import it (`import en from "./locales/en.json";`) and set `i18n: { locales: { en } }`.
27
+ 2. To keep the status and terminal lines in Italian, add `locales/it.json` with the `core.` keys translated (start it as `{}`: the log lists the missing keys at startup), and in `mercury.config.ts` import it (`import it from "./locales/it.json";`) and set `i18n: { defaultLocale: "it", locales: { it } }` (`locales: { en, it }` when you did step 1 too).
28
+ 3. Whenever the config imports a file from `locales/` (steps 1 and 2), the image needs the folder: add `COPY --chown=mercury:mercury locales ./locales` after the `mercury.config.ts` line of the `Dockerfile`, or the app fails at startup.
29
+ 4. For the terminal (`mfw repl`) to follow the language too, add `texts: app.texts,` to `createTerminalProvider({ … })` in `src/repl.ts`; without it the terminal's own lines stay English.
30
+
31
+ ### Patch Changes
32
+
33
+ - 6bb8291: Needs the framework at 0.54.0 or later, the version that brings the contract it's written for (plugin contract 5, channel contract 6): the peer range on `@mercury-fw/plugin-types` and `@mercury-fw/cli-engine` (the plugins) or `@mercury-fw/channel-types` (the channels) starts there.
34
+
3
35
  ## 0.10.1
4
36
 
5
37
  ### Patch Changes
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Gives a [Mercury](https://github.com/lucabro81/mercury-fw) agent an HTTP surface to build a custom UI on: one conversational turn per request, streamed back as Server-Sent Events, plus confirmation and read-only routes over what the agent holds (conversations, the wiki, memory, its plugins).
4
4
 
5
+ This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.54 or later: a core with an older contract skips it, and the log says why.
6
+
5
7
  ```bash
6
8
  bun add @mercury-fw/channel-http
7
9
  ```
@@ -52,6 +54,8 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
52
54
 
53
55
  **Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, an opening has no `conversationId`, the body isn't JSON, or `conversationId` has other characters than the ones above.
54
56
 
57
+ **Languages.** Every text a person reads that isn't the model's (a refusal, a tool's status line, a choice's label, a confirmation's reply, the login page) is in their language: the one they asked the assistant for, else the one the request's `Accept-Language` header asks for (what browsers send on their own), else the app's default. The responses of `POST /turn` and `POST /confirm`, a refusal and the login page say which with `Content-Language` (as it stood when the response started: a language picked during a turn counts from the next one).
58
+
55
59
  Reasoning and answer text arrive as **incremental deltas** — never one finished block — so a UI renders them live; treat the `final` event as the authoritative text. **To cancel** a turn (e.g. a generation that's diverging), close the connection: Mercury aborts the in-flight generation.
56
60
 
57
61
  **SSE events** — each is `event: <name>` followed by `data: <json>`:
@@ -63,9 +67,9 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
63
67
  | `tool` | `{ label, detail, toolCallId, name }` | a tool call starts; `name` is the tool the model called, absent when Mercury is saving something to memory |
64
68
  | `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
65
69
  | `text` | `{ chunk }` | an answer-text delta |
66
- | `pending` | `{ command, token, choices }` | a confirm-required action was staged; send a token back to confirm it — as a later `/turn` `text`, or via `POST /confirm`. `choices` is every way to answer (`{ token, label }`, in order; one, labelled `Conferma`, for a plain staging): the others die with the one sent |
70
+ | `pending` | `{ command, token, choices }` | a confirm-required action was staged; send a token back to confirm it — as a later `/turn` `text`, or via `POST /confirm`. `choices` is every way to answer (`{ token, label }`, in order, each label in the caller's language; one with an empty `label` for a plain staging, which the UI words itself, a "Confirm" button): the others die with the one sent |
67
71
  | `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
68
- | `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
72
+ | `final` | `{ text, pendingNote? }` | the complete answer (also emitted for a token confirmation, with no model turn). `pendingNote` is there when the model wrote nothing because the turn staged an action: the core's stand-in sentence `text` begins with (then any list the turn surfaced). A UI showing its own confirmation hides it; the conversation's transcript keeps it |
69
73
  | `error` | `{ message }` | the turn failed mid-stream |
70
74
 
71
75
  ```bash
@@ -77,7 +81,7 @@ curl -N -X POST http://localhost:4100/turn \
77
81
 
78
82
  ## `GET /login/callback`
79
83
 
80
- Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went.
84
+ Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went, in the language the browser asks for (`Accept-Language`), else the app's default; a login that failed reads in the language of the person who started it.
81
85
 
82
86
  Whoever opens the link and accepts decides which account gets linked: a link belongs to the person it was shown to, and someone else accepting it would link their own account to that person's Mercury identity. A UI should say so next to the link. Checking the linked account against the person is [#179](https://github.com/lucabro81/mercury-fw/issues/179).
83
87
 
@@ -87,7 +91,7 @@ A code for linking another of the caller's accounts to them, for the UI to show:
87
91
 
88
92
  ## `POST /confirm`
89
93
 
90
- Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
94
+ Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation (`text`: the core's reply in the caller's language, "Confirmed and done." or the failure, never the action's raw result), `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
91
95
 
92
96
  ## Read-only introspection
93
97
 
@@ -99,7 +103,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
99
103
  | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
100
104
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
101
105
  | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
102
- | `GET /me` | `{ name }` — how you asked the assistant to call you, `null` when you never said; not there on a core older than this channel |
106
+ | `GET /me` | `{ name, language }` — how you asked the assistant to call you and the language you asked it to use, `null` when you never said; `language` is missing on a core that doesn't know languages, and the route isn't there on a core older than this channel |
103
107
  | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
104
108
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
105
109
  | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, ChannelHostReads, ChannelI18n, ChannelLinking } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
17
17
  /** Bare-token interception before the model. */
@@ -38,6 +38,8 @@ export type HttpProviderDeps = {
38
38
  resumeMaxChars?: number;
39
39
  /** What the pages people read call the assistant. */
40
40
  assistantName?: string;
41
+ /** The texts in each caller's language (`ctx.i18n`). */
42
+ i18n?: ChannelI18n;
41
43
  };
42
44
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
43
45
  stop(): Promise<void>;
@@ -22,13 +22,13 @@
22
22
  * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
23
23
  * package never imports the app.
24
24
  */
25
- import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
26
- /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
27
- export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
25
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
26
+ /** Resolves a bare confirmation token to what to tell the person, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
27
+ export type ConfirmFn = ChannelRuntimeContext["confirm"];
28
28
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
29
- export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
29
+ export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
30
30
  /** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
31
- export type AdmitFn = (principal: Principal) => Promise<Admission>;
31
+ export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
32
32
  export type TurnRequestDeps = {
33
33
  handleTurn: HandleTurn;
34
34
  /** Who is calling, injected from the app's auth provider; `null` = refused. */
@@ -36,6 +36,8 @@ export type TurnRequestDeps = {
36
36
  admit?: AdmitFn;
37
37
  /** Bare-token interception before the model, injected by the core. */
38
38
  confirm: ConfirmFn;
39
+ /** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
40
+ i18n?: ChannelI18n;
39
41
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
40
42
  corsOrigin?: string;
41
43
  /** Test seam for the ephemeral session key when the client sends no conversationId. */
@@ -74,6 +76,8 @@ export type ConfirmRequestDeps = {
74
76
  admit?: AdmitFn;
75
77
  resolveConfirmation: ResolveConfirmationFn;
76
78
  corsOrigin?: string;
79
+ /** The texts in the caller's language, injected by the core: what `Content-Language` says. */
80
+ i18n?: ChannelI18n;
77
81
  };
78
82
  /**
79
83
  * `POST /confirm { token, conversationId }` — the explicit confirmation
@@ -83,7 +87,8 @@ export type ConfirmRequestDeps = {
83
87
  * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
84
88
  * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
85
89
  * pending confirmation and its staged action was consumed and run; the `text`
86
- * then reports whether that execution succeeded. `resolved: false` means the
90
+ * (the core's, in the caller's language) then reports whether that execution
91
+ * succeeded. `resolved: false` means the
87
92
  * token was not a pending confirmation (unknown, expired, already used, or not
88
93
  * even token-shaped). `resume: true` (with no `text`) means the action handed
89
94
  * its result back to the model: the client hears its answer by opening
@@ -117,6 +122,7 @@ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutc
117
122
  export declare function handleLoginCallback(req: Request, deps: {
118
123
  complete: CompleteLoginFn;
119
124
  assistantName?: string;
125
+ i18n?: ChannelI18n;
120
126
  }): Promise<Response>;
121
127
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
122
128
  type ReadRoute = {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The HTTP channel's texts a person sees, in English: the base every
3
+ * instance's language files translate (`ChannelPlugin.messages`). Only the
4
+ * login page: everything else the surface sends is the core's, or the
5
+ * developer-facing errors of its API, which aren't translated.
6
+ */
7
+ import type { Messages, Translate } from "@mercury-fw/channel-types";
8
+ export declare const HTTP_MESSAGES: Messages;
9
+ /** These texts in English, for a server built without the core's (`ChannelI18n`), as tests build it. */
10
+ export declare const englishHttp: Translate;
package/http-provider.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelI18n, ChannelLinking } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -35,6 +35,8 @@ export type HttpProviderDeps = {
35
35
  resumeMaxChars?: number;
36
36
  /** What the pages people read call the assistant. */
37
37
  assistantName?: string;
38
+ /** The texts in each caller's language (`ctx.i18n`). */
39
+ i18n?: ChannelI18n;
38
40
  };
39
41
 
40
42
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -54,6 +56,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
54
56
  ...(deps.linking === undefined ? {} : { linking: deps.linking }),
55
57
  ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
56
58
  ...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
59
+ ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
57
60
  });
58
61
  deps.logins?.accept(deps.logins.callbackUrl);
59
62
  },
package/http-server.ts CHANGED
@@ -25,18 +25,18 @@
25
25
  import {
26
26
  detectLoginRequired,
27
27
  detectPendingConfirmation,
28
- PENDING_CONFIRMATION_NOTE,
29
- RESUME_AFTER_CONFIRM,
30
28
  type HandleTurn,
31
29
  type TurnSink,
32
30
  type Authenticate,
33
31
  type ChannelHostReads,
34
- type ConfirmOutcome,
32
+ type ChannelI18n,
33
+ type ChannelRuntimeContext,
35
34
  type LoginOutcome,
36
35
  type Principal,
37
36
  type Admission,
38
37
  type ChannelLinking,
39
38
  } from "@mercury-fw/channel-types";
39
+ import { englishHttp } from "./messages.ts";
40
40
 
41
41
  /**
42
42
  * A client's conversation id becomes part of the session key and the log
@@ -51,13 +51,24 @@ function forLog(text: string): string {
51
51
  return text.replace(/[\u0000-\u001f\u007f]/g, "?");
52
52
  }
53
53
 
54
- /** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
55
- export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
54
+ /** Resolves a bare confirmation token to what to tell the person, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
55
+ export type ConfirmFn = ChannelRuntimeContext["confirm"];
56
56
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
57
- export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
57
+ export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
58
58
 
59
59
  /** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
60
- export type AdmitFn = (principal: Principal) => Promise<Admission>;
60
+ export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
61
+
62
+ /** The language the caller's client asks for (`Accept-Language`), when it says. */
63
+ function languageHint(req: Request): string | undefined {
64
+ const value = req.headers.get("accept-language")?.trim();
65
+ return value ? value : undefined;
66
+ }
67
+
68
+ /** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
69
+ async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
70
+ return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
71
+ }
61
72
 
62
73
  export type TurnRequestDeps = {
63
74
  handleTurn: HandleTurn;
@@ -66,6 +77,8 @@ export type TurnRequestDeps = {
66
77
  admit?: AdmitFn;
67
78
  /** Bare-token interception before the model, injected by the core. */
68
79
  confirm: ConfirmFn;
80
+ /** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
81
+ i18n?: ChannelI18n;
69
82
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
70
83
  corsOrigin?: string;
71
84
  /** Test seam for the ephemeral session key when the client sends no conversationId. */
@@ -92,7 +105,7 @@ function corsHeaders(origin: string): Record<string, string> {
92
105
  return {
93
106
  "access-control-allow-origin": origin,
94
107
  "access-control-allow-methods": "GET, POST, OPTIONS",
95
- "access-control-allow-headers": "content-type, authorization",
108
+ "access-control-allow-headers": "content-type, authorization, accept-language",
96
109
  };
97
110
  }
98
111
 
@@ -104,20 +117,26 @@ function unauthorized(origin: string): Response {
104
117
  );
105
118
  }
106
119
 
107
- /** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them. */
108
- function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string): Response {
120
+ /** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them, in the language they asked for. */
121
+ function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string, language: Record<string, string>): Response {
109
122
  return Response.json(
110
123
  { ok: false, error: admission.message, reason: admission.reason },
111
- { status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
124
+ { status: admission.reason === "unknown" ? 403 : 503, headers: { ...corsHeaders(origin), ...language } },
112
125
  );
113
126
  }
114
127
 
115
128
  /** The caller of `req`, authenticated and admitted, or the response refusing them. */
116
- async function caller(req: Request, deps: { authenticate: Authenticate; admit?: AdmitFn }, origin: string): Promise<Principal | Response> {
129
+ async function caller(
130
+ req: Request,
131
+ deps: { authenticate: Authenticate; admit?: AdmitFn; i18n?: ChannelI18n },
132
+ origin: string,
133
+ ): Promise<Principal | Response> {
117
134
  const principal = await deps.authenticate(req);
118
135
  if (principal === null) return unauthorized(origin);
119
- const admission = deps.admit ? await deps.admit(principal) : { ok: true as const };
120
- return admission.ok ? principal : notAdmitted(admission, origin);
136
+ const hint = languageHint(req);
137
+ const admission = deps.admit ? await deps.admit(principal, hint) : { ok: true as const };
138
+ // A refused caller is nobody the core knows: their client's language, else the default.
139
+ return admission.ok ? principal : notAdmitted(admission, origin, await contentLanguage(deps.i18n, null, hint));
121
140
  }
122
141
 
123
142
  /** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
@@ -187,6 +206,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
187
206
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
188
207
  }
189
208
  const sessionKey = `${principal.id}:${conversationId}`;
209
+ const hint = languageHint(req);
210
+ const language = await contentLanguage(deps.i18n, principal, hint);
190
211
 
191
212
  // Aborted when the client disconnects (see the stream's `cancel` below) so
192
213
  // the in-flight turn stops instead of running to completion — the "stop"
@@ -215,21 +236,19 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
215
236
 
216
237
  // Same deterministic interception as every other channel — a
217
238
  // previously-approved mutation must never depend on the model.
218
- const confirmReply = opening || resume ? null : await deps.confirm(text, sessionKey, principal);
219
- if (confirmReply !== null && confirmReply !== RESUME_AFTER_CONFIRM) {
220
- send("final", { text: confirmReply });
239
+ const reply = opening || resume ? null : await deps.confirm(text, sessionKey, principal, hint);
240
+ if (reply !== null && !reply.resume) {
241
+ send("final", { text: reply.text });
221
242
  close();
222
243
  return;
223
244
  }
224
245
  // The confirmed action's result goes back to the model: the turn runs again, on this stream.
225
- const resuming = resume || confirmReply === RESUME_AFTER_CONFIRM;
246
+ const resuming = resume || reply?.resume === true;
226
247
 
227
248
  const sink: TurnSink = {
228
249
  onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
229
250
  onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
230
- onTextChunk: (chunk) => {
231
- if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
232
- },
251
+ onTextChunk: (chunk) => send("text", { chunk }),
233
252
  onReasoningChunk: (chunk, id) => send("reasoning", { chunk, id }),
234
253
  onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
235
254
  onStep: (step) => {
@@ -239,7 +258,10 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
239
258
  if (login) send("login", { service: login.service, url: login.url });
240
259
  },
241
260
  onUsage: () => {},
242
- finalize: async (finalText) => send("final", { text: finalText }),
261
+ // The core says when the text is its stand-in for an answer the
262
+ // model didn't write: a UI showing its own confirmation hides it.
263
+ finalize: async (finalText, info) =>
264
+ send("final", info?.pendingNote === undefined ? { text: finalText } : { text: finalText, pendingNote: info.pendingNote }),
243
265
  dispose: () => {},
244
266
  };
245
267
 
@@ -257,6 +279,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
257
279
  ...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
258
280
  ...(opening ? { opening: true as const } : {}),
259
281
  ...(resuming ? { resumeAfterConfirm: true as const } : {}),
282
+ ...(hint === undefined ? {} : { languageHint: hint }),
260
283
  },
261
284
  sink,
262
285
  );
@@ -276,7 +299,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
276
299
  },
277
300
  });
278
301
 
279
- return new Response(stream, { headers: { ...SSE_HEADERS, ...cors } });
302
+ return new Response(stream, { headers: { ...SSE_HEADERS, ...cors, ...language } });
280
303
  }
281
304
 
282
305
  export type ConfirmRequestDeps = {
@@ -284,6 +307,8 @@ export type ConfirmRequestDeps = {
284
307
  admit?: AdmitFn;
285
308
  resolveConfirmation: ResolveConfirmationFn;
286
309
  corsOrigin?: string;
310
+ /** The texts in the caller's language, injected by the core: what `Content-Language` says. */
311
+ i18n?: ChannelI18n;
287
312
  };
288
313
 
289
314
  /**
@@ -294,7 +319,8 @@ export type ConfirmRequestDeps = {
294
319
  * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
295
320
  * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
296
321
  * pending confirmation and its staged action was consumed and run; the `text`
297
- * then reports whether that execution succeeded. `resolved: false` means the
322
+ * (the core's, in the caller's language) then reports whether that execution
323
+ * succeeded. `resolved: false` means the
298
324
  * token was not a pending confirmation (unknown, expired, already used, or not
299
325
  * even token-shaped). `resume: true` (with no `text`) means the action handed
300
326
  * its result back to the model: the client hears its answer by opening
@@ -319,18 +345,19 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
319
345
  if (!CONVERSATION_ID.test(conversationId)) {
320
346
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
321
347
  }
322
- const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
348
+ const hint = languageHint(req);
349
+ const headers = { ...cors, ...(await contentLanguage(deps.i18n, principal, hint)) };
350
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal, hint);
323
351
  switch (outcome.status) {
324
352
  case "not-a-token":
325
353
  case "not-found":
326
- return Response.json({ ok: true, resolved: false }, { headers: cors });
354
+ return Response.json({ ok: true, resolved: false }, { headers });
327
355
  case "ok":
328
- return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
329
356
  case "failed":
330
- return Response.json({ ok: true, resolved: true, text: `Confermato, ma l'esecuzione è fallita: ${outcome.error}` }, { headers: cors });
357
+ return Response.json({ ok: true, resolved: true, text: outcome.text }, { headers });
331
358
  case "resume":
332
359
  // The result went back to the model: the client opens `/turn { resume: true }` to hear its answer.
333
- return Response.json({ ok: true, resolved: true, resume: true }, { headers: cors });
360
+ return Response.json({ ok: true, resolved: true, resume: true }, { headers });
334
361
  }
335
362
  }
336
363
 
@@ -365,11 +392,11 @@ function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request
365
392
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
366
393
 
367
394
  /** A plain-text page for the person's browser, at the end of their login. */
368
- function loginPage(text: string, status: number): Response {
395
+ function loginPage(text: string, status: number, language: Record<string, string> = {}): Response {
369
396
  // The URL carried the code and the state: neither kept nor passed on.
370
397
  return new Response(text, {
371
398
  status,
372
- headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
399
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", ...language },
373
400
  });
374
401
  }
375
402
 
@@ -383,32 +410,41 @@ function loginPage(text: string, status: number): Response {
383
410
  */
384
411
  export async function handleLoginCallback(
385
412
  req: Request,
386
- deps: { complete: CompleteLoginFn; assistantName?: string },
413
+ deps: { complete: CompleteLoginFn; assistantName?: string; i18n?: ChannelI18n },
387
414
  ): Promise<Response> {
388
415
  const assistant = deps.assistantName ?? "Mercury";
416
+ // Nobody is known on this page: the browser's language, then the default.
417
+ const texts = deps.i18n ? await deps.i18n.forRequest(null, languageHint(req)) : undefined;
418
+ const t = texts?.t ?? englishHttp;
419
+ const language: Record<string, string> = texts ? { "content-language": texts.locale } : {};
389
420
  const params = new URL(req.url).searchParams;
390
421
  const error = params.get("error");
391
422
  if (error !== null) {
392
423
  const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
393
- return loginPage(`The login didn't happen (${reason}). Ask ${assistant} again for a new link.`, 400);
424
+ return loginPage(t("http.login.denied", { reason, assistant }), 400, language);
394
425
  }
395
426
  const code = params.get("code");
396
427
  const state = params.get("state");
397
- if (!code || !state) return loginPage(`The login link came back without a code or a state. Ask ${assistant} again for a new link.`, 400);
428
+ if (!code || !state) return loginPage(t("http.login.incomplete", { assistant }), 400, language);
398
429
  const outcome = await deps.complete(state, code);
399
- return outcome.ok
400
- ? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
401
- : loginPage(outcome.error, 400);
430
+ // A failed login's text is the core's, in the language of whoever started it: no header claims otherwise.
431
+ return outcome.ok ? loginPage(t("http.login.done", { service: outcome.service }), 200, language) : loginPage(outcome.error, 400);
402
432
  }
403
433
 
404
434
  /** The login callback route, mounted when the server can complete logins. */
405
435
  function loginRoutes(
406
436
  complete: CompleteLoginFn,
407
437
  assistantName: string | undefined,
438
+ i18n: ChannelI18n | undefined,
408
439
  ): Record<string, { GET: (req: Request) => Promise<Response> }> {
409
440
  return {
410
441
  "/login/callback": {
411
- GET: (req) => handleLoginCallback(req, { complete, ...(assistantName === undefined ? {} : { assistantName }) }),
442
+ GET: (req) =>
443
+ handleLoginCallback(req, {
444
+ complete,
445
+ ...(assistantName === undefined ? {} : { assistantName }),
446
+ ...(i18n === undefined ? {} : { i18n }),
447
+ }),
412
448
  },
413
449
  };
414
450
  }
@@ -481,8 +517,9 @@ export function readRoutes(
481
517
  : {
482
518
  "/me": route(async (_req, principal) => {
483
519
  // Null when the core no longer admits the caller: still a name, null.
484
- const me = (await reads.me!(principal)) as { name: string | null } | null;
485
- return json({ ok: true, name: me?.name ?? null });
520
+ const me = (await reads.me!(principal)) as { name: string | null; language?: string | null } | null;
521
+ // An older core says no language: the reply then has none either.
522
+ return json({ ok: true, name: me?.name ?? null, ...(me?.language === undefined ? {} : { language: me.language }) });
486
523
  }),
487
524
  }),
488
525
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
@@ -547,12 +584,13 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
547
584
  ...(deps.admit === undefined ? {} : { admit: deps.admit }),
548
585
  resolveConfirmation: deps.resolveConfirmation,
549
586
  corsOrigin: origin,
587
+ ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
550
588
  }),
551
589
  OPTIONS: () => preflight(origin),
552
590
  },
553
591
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
554
592
  ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
555
- ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName) : {}),
593
+ ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
556
594
  ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
557
595
  },
558
596
  error: (err) =>
package/index.ts CHANGED
@@ -16,14 +16,16 @@
16
16
  */
17
17
  import type { ChannelPlugin } from "@mercury-fw/channel-types";
18
18
  import { createHttpProvider } from "./http-provider.ts";
19
+ import { HTTP_MESSAGES } from "./messages.ts";
19
20
 
20
21
  export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
21
22
 
22
23
  export const httpChannel: ChannelPlugin = {
23
24
  // The contract this channel is written for, as a literal: importing
24
25
  // CHANNEL_API_VERSION would report whichever contract is installed.
25
- apiVersion: 5,
26
+ apiVersion: 6,
26
27
  name: "http",
28
+ messages: HTTP_MESSAGES,
27
29
  build: (ctx) => {
28
30
  if (!ctx.resolveConfirmation || !ctx.reads) {
29
31
  throw new Error("http channel requires the resolveConfirmation and reads capabilities on the runtime context");
@@ -44,6 +46,7 @@ export const httpChannel: ChannelPlugin = {
44
46
  reads: ctx.reads,
45
47
  authenticate: ctx.authenticate,
46
48
  admit: ctx.admit,
49
+ i18n: ctx.i18n,
47
50
  ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
48
51
  ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
49
52
  ...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
package/messages.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The HTTP channel's texts a person sees, in English: the base every
3
+ * instance's language files translate (`ChannelPlugin.messages`). Only the
4
+ * login page: everything else the surface sends is the core's, or the
5
+ * developer-facing errors of its API, which aren't translated.
6
+ */
7
+ import type { Messages, Translate } from "@mercury-fw/channel-types";
8
+
9
+ export const HTTP_MESSAGES: Messages = {
10
+ // The page a person lands on after logging in to a service (`GET /login/callback`).
11
+ "http.login.denied": "The login didn't happen ({reason}). Ask {assistant} again for a new link.",
12
+ "http.login.incomplete": "The login link came back without a code or a state. Ask {assistant} again for a new link.",
13
+ "http.login.done": "You're logged in to {service}. Go back to the conversation and ask again.",
14
+ };
15
+
16
+ /** These texts in English, for a server built without the core's (`ChannelI18n`), as tests build it. */
17
+ export const englishHttp: Translate = (key, params) =>
18
+ (HTTP_MESSAGES[key] ?? key).replace(/\{(\w+)\}/g, (whole, name: string) =>
19
+ params !== undefined && Object.hasOwn(params, name) ? String(params[name]) : whole,
20
+ );
package/openapi.yaml CHANGED
@@ -101,7 +101,12 @@ paths:
101
101
  speaks first in the conversation named (right after someone signs
102
102
  in): it does only for someone it doesn't know the name of, asking how
103
103
  to call them, and otherwise the stream carries a single `final` with
104
- an empty text.
104
+ an empty text. Every text the person reads that isn't the model's (a
105
+ refusal, a status line, a confirmation's reply) is in their language:
106
+ the one they saved, else the one `Accept-Language` asks for, else the
107
+ instance's default.
108
+ parameters:
109
+ - $ref: "#/components/parameters/AcceptLanguage"
105
110
  requestBody:
106
111
  required: true
107
112
  content:
@@ -125,6 +130,9 @@ paths:
125
130
  service the caller has to log in to first, with the link to open),
126
131
  `final` (the
127
132
  authoritative complete answer), and `error`.
133
+ headers:
134
+ Content-Language:
135
+ $ref: "#/components/headers/ContentLanguage"
128
136
  content:
129
137
  text/event-stream:
130
138
  schema:
@@ -141,7 +149,10 @@ paths:
141
149
  description: >
142
150
  Runs a one-time confirmation token through the same mechanism the
143
151
  bare-token-in-`text` path uses, keyed on the caller's own conversation
144
- (`<caller id>:<conversationId>`). Never touches the model.
152
+ (`<caller id>:<conversationId>`). Never touches the model. The reply
153
+ is in the caller's language, as on `/turn`.
154
+ parameters:
155
+ - $ref: "#/components/parameters/AcceptLanguage"
145
156
  requestBody:
146
157
  required: true
147
158
  content:
@@ -162,6 +173,9 @@ paths:
162
173
  already used). `resolved: true` with `resume: true` and no `text`
163
174
  when the action handed its result back to the model: the answer
164
175
  comes from `POST /turn { resume: true, conversationId }`.
176
+ headers:
177
+ Content-Language:
178
+ $ref: "#/components/headers/ContentLanguage"
165
179
  content:
166
180
  application/json:
167
181
  schema:
@@ -329,8 +343,10 @@ paths:
329
343
  summary: Who the caller is to the assistant
330
344
  description: >
331
345
  How the caller asked to be called, `null` when they never said: a UI
332
- asks for an opening turn after a sign-in only then. Not there on an
333
- older core, which can't say.
346
+ asks for an opening turn after a sign-in only then. Also the language
347
+ they asked the assistant to use, `null` when they never said (missing
348
+ when the core doesn't know languages). Not there on an older core,
349
+ which can't say.
334
350
  responses:
335
351
  "401":
336
352
  $ref: "#/components/responses/Unauthorized"
@@ -339,7 +355,7 @@ paths:
339
355
  "503":
340
356
  $ref: "#/components/responses/NotAdmitted"
341
357
  "200":
342
- description: The caller's name, or null.
358
+ description: The caller's name and language, or null.
343
359
  content:
344
360
  application/json:
345
361
  schema:
@@ -350,6 +366,9 @@ paths:
350
366
  type: boolean
351
367
  name:
352
368
  type: [string, "null"]
369
+ language:
370
+ type: [string, "null"]
371
+ description: A language tag such as `it` or `pt-br`, one the instance ships.
353
372
  /tool-log:
354
373
  get:
355
374
  summary: The tool calls made in the caller's recent turns
@@ -493,20 +512,31 @@ paths:
493
512
  Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
494
513
  person's browser here with `code` and `state` (or `error`); the
495
514
  single-use state is the credential, so no bearer token is needed.
496
- Answers with a plain-text page.
515
+ Answers with a plain-text page, in the language the browser asks for
516
+ (`Accept-Language`), else the instance's default; a failed login reads
517
+ in the language of the person who started it.
497
518
  security: []
498
519
  parameters:
520
+ - $ref: "#/components/parameters/AcceptLanguage"
499
521
  - { name: code, in: query, schema: { type: string } }
500
522
  - { name: state, in: query, schema: { type: string } }
501
523
  - { name: error, in: query, schema: { type: string } }
502
524
  responses:
503
525
  "200":
504
526
  description: Logged in.
527
+ headers:
528
+ Content-Language:
529
+ $ref: "#/components/headers/ContentLanguage"
505
530
  content:
506
531
  text/plain:
507
532
  schema: { type: string }
508
533
  "400":
509
534
  description: Refused, expired, already used, or missing code/state.
535
+ headers:
536
+ Content-Language:
537
+ description: Set on a denied or incomplete login (the browser's language); not on a failed one, which reads in the language of whoever started it.
538
+ schema:
539
+ type: string
510
540
  content:
511
541
  text/plain:
512
542
  schema: { type: string }
@@ -522,6 +552,26 @@ paths:
522
552
  schema:
523
553
  type: string
524
554
  components:
555
+ parameters:
556
+ AcceptLanguage:
557
+ name: Accept-Language
558
+ in: header
559
+ required: false
560
+ description: >
561
+ The languages the client prefers, as browsers send them
562
+ (`it-IT,it;q=0.9,en;q=0.8`). Used for the texts the person reads when
563
+ they saved no language of their own; a language the instance doesn't
564
+ ship is passed over.
565
+ schema:
566
+ type: string
567
+ headers:
568
+ ContentLanguage:
569
+ description: >
570
+ The language of the caller's texts as it stood when the response
571
+ started, one the instance ships (`en`, `it`); a person who picks
572
+ another language during a turn gets it from the next one.
573
+ schema:
574
+ type: string
525
575
  securitySchemes:
526
576
  bearerAuth:
527
577
  type: http
@@ -545,7 +595,11 @@ components:
545
595
  description: >
546
596
  An authenticated caller the core won't talk to: `403` when the app's
547
597
  directory doesn't know them, `503` when it can't be reached. Nothing
548
- behind the route runs.
598
+ behind the route runs. `error` is in the language `Accept-Language`
599
+ asks for, which `Content-Language` names.
600
+ headers:
601
+ Content-Language:
602
+ $ref: "#/components/headers/ContentLanguage"
549
603
  content:
550
604
  application/json:
551
605
  schema:
@@ -616,7 +670,7 @@ components:
616
670
  description: On `pending` — the one-time confirmation token (the first choice's).
617
671
  choices:
618
672
  type: array
619
- description: On `pending` — every way to answer, in order; a plain staging has one, labelled `Conferma`.
673
+ description: On `pending` — every way to answer, in order, each label in the caller's language; a plain staging has one with an empty label, which the UI words itself (a "Confirm" button).
620
674
  items:
621
675
  type: object
622
676
  required: [token, label]
@@ -634,6 +688,13 @@ components:
634
688
  text:
635
689
  type: string
636
690
  description: On `final` — the authoritative complete answer.
691
+ pendingNote:
692
+ type: string
693
+ description: >
694
+ On `final`, when the model wrote nothing because the turn staged an
695
+ action: the stand-in sentence `text` begins with (followed by any
696
+ list the turn surfaced). A UI showing its own confirmation hides
697
+ it; the conversation's transcript keeps it.
637
698
  message:
638
699
  type: string
639
700
  description: On `error` — the failure message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,11 +18,11 @@
18
18
  "access": "public"
19
19
  },
20
20
  "peerDependencies": {
21
- "@mercury-fw/channel-types": ">=0.51.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.54.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.52.0",
25
- "@mercury-fw/plugin-types": "0.52.0",
24
+ "@mercury-fw/channel-types": "0.54.0",
25
+ "@mercury-fw/plugin-types": "0.54.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"