@mercury-fw/channel-http 0.10.1 → 0.12.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,54 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1126106: Every turn tells the model when and where the person wrote it.
8
+
9
+ - Each message reaches the model after a line with the weekday, date, time and time zone it was written in (`[Wednesday 2026-10-21 14:32 Europe/Rome]`), and the system prompt says what that line is. The conversation's archive and what a UI shows of it keep the message as written.
10
+ - A resumed conversation's messages come back stamped from the archive's timestamps.
11
+ - The Layer 1 summary keeps when things were said; the episodic summary quotes a date only from those lines, never guessing one. The semantic-fact extractor and the capture card's preview leave the line out.
12
+ - The time zone, always an IANA name (`Europe/Rome`), is the one the person saved, else the one their client is in when the channel passes it on, else the new `i18n.timeZone` in `mercury.config.ts`, else the machine's. A `timeZone` that isn't a zone's name stops the start.
13
+ - The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
14
+ - Channel contract: `InboundTurn.timeZoneHint`, and `ctx.settings.setTimeZone` for a channel with a settings area; `reads.me` also returns the saved `timeZone`. Both are optional additions: the contract version stays 6.
15
+ - HTTP channel: the `X-Time-Zone` header on `POST /turn` (allowed by CORS), `timeZone` in `GET /me`, and `PATCH /me` with `{ timeZone }` to save it. Needs the framework at 0.56 or later; an older core ignores the header and offers no `PATCH /me`.
16
+ - Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
17
+
18
+ To set a default for an app's people, add `timeZone: "Europe/Rome"` (their zone) to `i18n` in `mercury.config.ts`; without it the machine's zone is used, often UTC inside a container.
19
+
20
+ ## 0.11.0
21
+
22
+ ### Minor Changes
23
+
24
+ - e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
25
+
26
+ - 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.
27
+ - 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)`.
28
+ - 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).
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+ - The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
34
+
35
+ 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.
36
+
37
+ - 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.
38
+
39
+ 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.
40
+
41
+ An existing app keeps working, with these changes:
42
+
43
+ 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 } }`.
44
+ 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).
45
+ 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.
46
+ 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.
47
+
48
+ ### Patch Changes
49
+
50
+ - 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.
51
+
3
52
  ## 0.10.1
4
53
 
5
54
  ### 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. `X-Time-Zone` and `PATCH /me` need the framework at 0.56 or later: an older core ignores the header and offers no `PATCH /me`.
6
+
5
7
  ```bash
6
8
  bun add @mercury-fw/channel-http
7
9
  ```
@@ -52,6 +54,10 @@ 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
+
59
+ **Time zone.** The model reads each message with the time the person wrote it, in their time zone: the one they saved, else the one the request's `X-Time-Zone` header names (an IANA name such as `Europe/Rome`, what `Intl.DateTimeFormat().resolvedOptions().timeZone` gives in a browser), else the app's. A UI saves the person's with `PATCH /me`.
60
+
55
61
  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
62
 
57
63
  **SSE events** — each is `event: <name>` followed by `data: <json>`:
@@ -63,9 +69,9 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
63
69
  | `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
70
  | `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
65
71
  | `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 |
72
+ | `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
73
  | `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) |
74
+ | `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
75
  | `error` | `{ message }` | the turn failed mid-stream |
70
76
 
71
77
  ```bash
@@ -77,7 +83,7 @@ curl -N -X POST http://localhost:4100/turn \
77
83
 
78
84
  ## `GET /login/callback`
79
85
 
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.
86
+ 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
87
 
82
88
  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
89
 
@@ -85,9 +91,13 @@ Whoever opens the link and accepts decides which account gets linked: a link bel
85
91
 
86
92
  A code for linking another of the caller's accounts to them, for the UI to show: `{ ok: true, code, expiresAt }`, or `400` with the reason when there's none. The person sends the code from the other account, on any channel, twice: the first time Mercury says whom the account would be linked to, the second time it links it, and from then on both accounts are one person (one private area, the same roles). The code lasts ten minutes, works once, and only the caller sees it; the model never does. It belongs to the first account that sends it, so it should be sent in a one-to-one conversation, never in a shared space where someone else could send it first. Over HTTP only an account the instance already admits can send it (anyone else is refused before the turn), so an account a closed instance doesn't know links from a chat.
87
93
 
94
+ ## `PATCH /me`
95
+
96
+ Saves the caller's time zone, for a UI's settings: body `{ timeZone }`, an IANA name such as `Europe/Rome`; returns `{ ok: true, timeZone }` as saved (`europe/rome` is saved as `Europe/Rome`), or `400` with the reason for what isn't a zone's name (a fixed offset such as `+02:00` isn't one) and for a body without it. From then on the caller's turns are stamped with the time there, whatever `X-Time-Zone` says. Not there on a core without settings.
97
+
88
98
  ## `POST /confirm`
89
99
 
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.
100
+ 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
101
 
92
102
  ## Read-only introspection
93
103
 
@@ -99,7 +109,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
99
109
  | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
100
110
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
101
111
  | `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 |
112
+ | `GET /me` | `{ name, language, timeZone }` — how you asked the assistant to call you, the language you asked it to use and the time zone you saved, `null` when you never said; `language` and `timeZone` are missing on a core that doesn't know them, and the route isn't there on a core older than this channel |
103
113
  | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
104
114
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
105
115
  | `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, ChannelSettings } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
17
17
  /** Bare-token interception before the model. */
@@ -34,10 +34,14 @@ export type HttpProviderDeps = {
34
34
  };
35
35
  /** Linking another account to the caller (`POST /link`). */
36
36
  linking?: ChannelLinking;
37
+ /** The person's own settings (`PATCH /me`). */
38
+ settings?: ChannelSettings;
37
39
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
38
40
  resumeMaxChars?: number;
39
41
  /** What the pages people read call the assistant. */
40
42
  assistantName?: string;
43
+ /** The texts in each caller's language (`ctx.i18n`). */
44
+ i18n?: ChannelI18n;
41
45
  };
42
46
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
43
47
  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, type ChannelSettings } 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,10 +122,13 @@ 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
- /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
127
+ /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
122
128
  type ReadRoute = {
123
129
  GET: (req: Request) => Response | Promise<Response>;
130
+ /** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
131
+ PATCH?: (req: Request) => Promise<Response>;
124
132
  OPTIONS: () => Response;
125
133
  };
126
134
  /**
@@ -132,9 +140,10 @@ type ReadRoute = {
132
140
  * their data only; a conversation is named by its conversationId, as on
133
141
  * `/turn`, and its session key is built from the caller.
134
142
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
135
- * same for a 400.
143
+ * same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
144
+ * caller's time zone (a UI's settings area), asking `authenticate` first too.
136
145
  */
137
- export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn): Record<string, ReadRoute>;
146
+ export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn, settings?: ChannelSettings): Record<string, ReadRoute>;
138
147
  export type HttpServerDeps = TurnRequestDeps & {
139
148
  port: number;
140
149
  reads?: ChannelHostReads;
@@ -144,6 +153,8 @@ export type HttpServerDeps = TurnRequestDeps & {
144
153
  completeLogin?: CompleteLoginFn;
145
154
  /** Linking another account to the caller; `/link` is mounted only with it. */
146
155
  linking?: ChannelLinking;
156
+ /** The person's own settings; `PATCH /me` is mounted only with it. */
157
+ settings?: ChannelSettings;
147
158
  /** What the pages people read call the assistant. */
148
159
  assistantName?: string;
149
160
  };
@@ -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, ChannelSettings } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -31,10 +31,14 @@ export type HttpProviderDeps = {
31
31
  logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
32
32
  /** Linking another account to the caller (`POST /link`). */
33
33
  linking?: ChannelLinking;
34
+ /** The person's own settings (`PATCH /me`). */
35
+ settings?: ChannelSettings;
34
36
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
35
37
  resumeMaxChars?: number;
36
38
  /** What the pages people read call the assistant. */
37
39
  assistantName?: string;
40
+ /** The texts in each caller's language (`ctx.i18n`). */
41
+ i18n?: ChannelI18n;
38
42
  };
39
43
 
40
44
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -52,8 +56,10 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
52
56
  corsOrigin: deps.corsOrigin,
53
57
  completeLogin: deps.logins?.complete,
54
58
  ...(deps.linking === undefined ? {} : { linking: deps.linking }),
59
+ ...(deps.settings === undefined ? {} : { settings: deps.settings }),
55
60
  ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
56
61
  ...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
62
+ ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
57
63
  });
58
64
  deps.logins?.accept(deps.logins.callbackUrl);
59
65
  },
package/http-server.ts CHANGED
@@ -25,18 +25,19 @@
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,
38
+ type ChannelSettings,
39
39
  } from "@mercury-fw/channel-types";
40
+ import { englishHttp } from "./messages.ts";
40
41
 
41
42
  /**
42
43
  * A client's conversation id becomes part of the session key and the log
@@ -51,13 +52,30 @@ function forLog(text: string): string {
51
52
  return text.replace(/[\u0000-\u001f\u007f]/g, "?");
52
53
  }
53
54
 
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>;
55
+ /** 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`). */
56
+ export type ConfirmFn = ChannelRuntimeContext["confirm"];
56
57
  /** 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>;
58
+ export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
58
59
 
59
60
  /** 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>;
61
+ export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
62
+
63
+ /** The language the caller's client asks for (`Accept-Language`), when it says. */
64
+ function languageHint(req: Request): string | undefined {
65
+ const value = req.headers.get("accept-language")?.trim();
66
+ return value ? value : undefined;
67
+ }
68
+
69
+ /** The time zone the caller's client says it's in (`X-Time-Zone`, an IANA name), when it says; the core ignores a name that isn't a zone. */
70
+ function timeZoneHint(req: Request): string | undefined {
71
+ const value = req.headers.get("x-time-zone")?.trim();
72
+ return value ? value : undefined;
73
+ }
74
+
75
+ /** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
76
+ async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
77
+ return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
78
+ }
61
79
 
62
80
  export type TurnRequestDeps = {
63
81
  handleTurn: HandleTurn;
@@ -66,6 +84,8 @@ export type TurnRequestDeps = {
66
84
  admit?: AdmitFn;
67
85
  /** Bare-token interception before the model, injected by the core. */
68
86
  confirm: ConfirmFn;
87
+ /** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
88
+ i18n?: ChannelI18n;
69
89
  /** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
70
90
  corsOrigin?: string;
71
91
  /** Test seam for the ephemeral session key when the client sends no conversationId. */
@@ -91,8 +111,8 @@ const SSE_HEADERS = {
91
111
  function corsHeaders(origin: string): Record<string, string> {
92
112
  return {
93
113
  "access-control-allow-origin": origin,
94
- "access-control-allow-methods": "GET, POST, OPTIONS",
95
- "access-control-allow-headers": "content-type, authorization",
114
+ "access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
115
+ "access-control-allow-headers": "content-type, authorization, accept-language, x-time-zone",
96
116
  };
97
117
  }
98
118
 
@@ -104,20 +124,26 @@ function unauthorized(origin: string): Response {
104
124
  );
105
125
  }
106
126
 
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 {
127
+ /** 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. */
128
+ function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string, language: Record<string, string>): Response {
109
129
  return Response.json(
110
130
  { ok: false, error: admission.message, reason: admission.reason },
111
- { status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
131
+ { status: admission.reason === "unknown" ? 403 : 503, headers: { ...corsHeaders(origin), ...language } },
112
132
  );
113
133
  }
114
134
 
115
135
  /** 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> {
136
+ async function caller(
137
+ req: Request,
138
+ deps: { authenticate: Authenticate; admit?: AdmitFn; i18n?: ChannelI18n },
139
+ origin: string,
140
+ ): Promise<Principal | Response> {
117
141
  const principal = await deps.authenticate(req);
118
142
  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);
143
+ const hint = languageHint(req);
144
+ const admission = deps.admit ? await deps.admit(principal, hint) : { ok: true as const };
145
+ // A refused caller is nobody the core knows: their client's language, else the default.
146
+ return admission.ok ? principal : notAdmitted(admission, origin, await contentLanguage(deps.i18n, null, hint));
121
147
  }
122
148
 
123
149
  /** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
@@ -187,6 +213,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
187
213
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
188
214
  }
189
215
  const sessionKey = `${principal.id}:${conversationId}`;
216
+ const hint = languageHint(req);
217
+ const zone = timeZoneHint(req);
218
+ const language = await contentLanguage(deps.i18n, principal, hint);
190
219
 
191
220
  // Aborted when the client disconnects (see the stream's `cancel` below) so
192
221
  // the in-flight turn stops instead of running to completion — the "stop"
@@ -215,21 +244,19 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
215
244
 
216
245
  // Same deterministic interception as every other channel — a
217
246
  // 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 });
247
+ const reply = opening || resume ? null : await deps.confirm(text, sessionKey, principal, hint);
248
+ if (reply !== null && !reply.resume) {
249
+ send("final", { text: reply.text });
221
250
  close();
222
251
  return;
223
252
  }
224
253
  // 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;
254
+ const resuming = resume || reply?.resume === true;
226
255
 
227
256
  const sink: TurnSink = {
228
257
  onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
229
258
  onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
230
- onTextChunk: (chunk) => {
231
- if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
232
- },
259
+ onTextChunk: (chunk) => send("text", { chunk }),
233
260
  onReasoningChunk: (chunk, id) => send("reasoning", { chunk, id }),
234
261
  onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
235
262
  onStep: (step) => {
@@ -239,7 +266,10 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
239
266
  if (login) send("login", { service: login.service, url: login.url });
240
267
  },
241
268
  onUsage: () => {},
242
- finalize: async (finalText) => send("final", { text: finalText }),
269
+ // The core says when the text is its stand-in for an answer the
270
+ // model didn't write: a UI showing its own confirmation hides it.
271
+ finalize: async (finalText, info) =>
272
+ send("final", info?.pendingNote === undefined ? { text: finalText } : { text: finalText, pendingNote: info.pendingNote }),
243
273
  dispose: () => {},
244
274
  };
245
275
 
@@ -257,6 +287,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
257
287
  ...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
258
288
  ...(opening ? { opening: true as const } : {}),
259
289
  ...(resuming ? { resumeAfterConfirm: true as const } : {}),
290
+ ...(hint === undefined ? {} : { languageHint: hint }),
291
+ ...(zone === undefined ? {} : { timeZoneHint: zone }),
260
292
  },
261
293
  sink,
262
294
  );
@@ -276,7 +308,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
276
308
  },
277
309
  });
278
310
 
279
- return new Response(stream, { headers: { ...SSE_HEADERS, ...cors } });
311
+ return new Response(stream, { headers: { ...SSE_HEADERS, ...cors, ...language } });
280
312
  }
281
313
 
282
314
  export type ConfirmRequestDeps = {
@@ -284,6 +316,8 @@ export type ConfirmRequestDeps = {
284
316
  admit?: AdmitFn;
285
317
  resolveConfirmation: ResolveConfirmationFn;
286
318
  corsOrigin?: string;
319
+ /** The texts in the caller's language, injected by the core: what `Content-Language` says. */
320
+ i18n?: ChannelI18n;
287
321
  };
288
322
 
289
323
  /**
@@ -294,7 +328,8 @@ export type ConfirmRequestDeps = {
294
328
  * keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
295
329
  * token staged in someone else's conversation is never found. `resolved: true` means the token matched a
296
330
  * pending confirmation and its staged action was consumed and run; the `text`
297
- * then reports whether that execution succeeded. `resolved: false` means the
331
+ * (the core's, in the caller's language) then reports whether that execution
332
+ * succeeded. `resolved: false` means the
298
333
  * token was not a pending confirmation (unknown, expired, already used, or not
299
334
  * even token-shaped). `resume: true` (with no `text`) means the action handed
300
335
  * its result back to the model: the client hears its answer by opening
@@ -319,18 +354,19 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
319
354
  if (!CONVERSATION_ID.test(conversationId)) {
320
355
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
321
356
  }
322
- const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
357
+ const hint = languageHint(req);
358
+ const headers = { ...cors, ...(await contentLanguage(deps.i18n, principal, hint)) };
359
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal, hint);
323
360
  switch (outcome.status) {
324
361
  case "not-a-token":
325
362
  case "not-found":
326
- return Response.json({ ok: true, resolved: false }, { headers: cors });
363
+ return Response.json({ ok: true, resolved: false }, { headers });
327
364
  case "ok":
328
- return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
329
365
  case "failed":
330
- return Response.json({ ok: true, resolved: true, text: `Confermato, ma l'esecuzione è fallita: ${outcome.error}` }, { headers: cors });
366
+ return Response.json({ ok: true, resolved: true, text: outcome.text }, { headers });
331
367
  case "resume":
332
368
  // 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 });
369
+ return Response.json({ ok: true, resolved: true, resume: true }, { headers });
334
370
  }
335
371
  }
336
372
 
@@ -365,11 +401,11 @@ function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request
365
401
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
366
402
 
367
403
  /** A plain-text page for the person's browser, at the end of their login. */
368
- function loginPage(text: string, status: number): Response {
404
+ function loginPage(text: string, status: number, language: Record<string, string> = {}): Response {
369
405
  // The URL carried the code and the state: neither kept nor passed on.
370
406
  return new Response(text, {
371
407
  status,
372
- headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
408
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", ...language },
373
409
  });
374
410
  }
375
411
 
@@ -383,39 +419,50 @@ function loginPage(text: string, status: number): Response {
383
419
  */
384
420
  export async function handleLoginCallback(
385
421
  req: Request,
386
- deps: { complete: CompleteLoginFn; assistantName?: string },
422
+ deps: { complete: CompleteLoginFn; assistantName?: string; i18n?: ChannelI18n },
387
423
  ): Promise<Response> {
388
424
  const assistant = deps.assistantName ?? "Mercury";
425
+ // Nobody is known on this page: the browser's language, then the default.
426
+ const texts = deps.i18n ? await deps.i18n.forRequest(null, languageHint(req)) : undefined;
427
+ const t = texts?.t ?? englishHttp;
428
+ const language: Record<string, string> = texts ? { "content-language": texts.locale } : {};
389
429
  const params = new URL(req.url).searchParams;
390
430
  const error = params.get("error");
391
431
  if (error !== null) {
392
432
  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);
433
+ return loginPage(t("http.login.denied", { reason, assistant }), 400, language);
394
434
  }
395
435
  const code = params.get("code");
396
436
  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);
437
+ if (!code || !state) return loginPage(t("http.login.incomplete", { assistant }), 400, language);
398
438
  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);
439
+ // A failed login's text is the core's, in the language of whoever started it: no header claims otherwise.
440
+ return outcome.ok ? loginPage(t("http.login.done", { service: outcome.service }), 200, language) : loginPage(outcome.error, 400);
402
441
  }
403
442
 
404
443
  /** The login callback route, mounted when the server can complete logins. */
405
444
  function loginRoutes(
406
445
  complete: CompleteLoginFn,
407
446
  assistantName: string | undefined,
447
+ i18n: ChannelI18n | undefined,
408
448
  ): Record<string, { GET: (req: Request) => Promise<Response> }> {
409
449
  return {
410
450
  "/login/callback": {
411
- GET: (req) => handleLoginCallback(req, { complete, ...(assistantName === undefined ? {} : { assistantName }) }),
451
+ GET: (req) =>
452
+ handleLoginCallback(req, {
453
+ complete,
454
+ ...(assistantName === undefined ? {} : { assistantName }),
455
+ ...(i18n === undefined ? {} : { i18n }),
456
+ }),
412
457
  },
413
458
  };
414
459
  }
415
460
 
416
- /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
461
+ /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
417
462
  type ReadRoute = {
418
463
  GET: (req: Request) => Response | Promise<Response>;
464
+ /** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
465
+ PATCH?: (req: Request) => Promise<Response>;
419
466
  OPTIONS: () => Response;
420
467
  };
421
468
 
@@ -428,13 +475,15 @@ type ReadRoute = {
428
475
  * their data only; a conversation is named by its conversationId, as on
429
476
  * `/turn`, and its session key is built from the caller.
430
477
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
431
- * same for a 400.
478
+ * same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
479
+ * caller's time zone (a UI's settings area), asking `authenticate` first too.
432
480
  */
433
481
  export function readRoutes(
434
482
  reads: ChannelHostReads,
435
483
  authenticate: Authenticate,
436
484
  corsOrigin = "*",
437
485
  admit?: AdmitFn,
486
+ settings?: ChannelSettings,
438
487
  ): Record<string, ReadRoute> {
439
488
  const cors = corsHeaders(corsOrigin);
440
489
  const json = (payload: object, status = 200): Response =>
@@ -479,11 +528,31 @@ export function readRoutes(
479
528
  ...(reads.me === undefined
480
529
  ? {}
481
530
  : {
482
- "/me": route(async (_req, principal) => {
483
- // 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 });
486
- }),
531
+ "/me": {
532
+ ...route(async (_req, principal) => {
533
+ // Null when the core no longer admits the caller: still a name, null.
534
+ const me = (await reads.me!(principal)) as { name: string | null; language?: string | null; timeZone?: string | null } | null;
535
+ // An older core says no language or time zone: the reply then has none either.
536
+ return json({
537
+ ok: true,
538
+ name: me?.name ?? null,
539
+ ...(me?.language === undefined ? {} : { language: me.language }),
540
+ ...(me?.timeZone === undefined ? {} : { timeZone: me.timeZone }),
541
+ });
542
+ }),
543
+ ...(settings === undefined
544
+ ? {}
545
+ : {
546
+ PATCH: async (req: Request) => {
547
+ const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
548
+ if (principal instanceof Response) return principal;
549
+ const body = (await req.json().catch(() => null)) as { timeZone?: unknown } | null;
550
+ if (typeof body?.timeZone !== "string") return badRequest('the body must be {"timeZone": "<IANA name>"}');
551
+ const saved = await settings.setTimeZone(principal, body.timeZone);
552
+ return saved.ok ? json({ ok: true, timeZone: saved.value }) : badRequest(saved.error);
553
+ },
554
+ }),
555
+ },
487
556
  }),
488
557
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
489
558
  "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
@@ -519,6 +588,8 @@ export type HttpServerDeps = TurnRequestDeps & {
519
588
  completeLogin?: CompleteLoginFn;
520
589
  /** Linking another account to the caller; `/link` is mounted only with it. */
521
590
  linking?: ChannelLinking;
591
+ /** The person's own settings; `PATCH /me` is mounted only with it. */
592
+ settings?: ChannelSettings;
522
593
  /** What the pages people read call the assistant. */
523
594
  assistantName?: string;
524
595
  };
@@ -547,12 +618,13 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
547
618
  ...(deps.admit === undefined ? {} : { admit: deps.admit }),
548
619
  resolveConfirmation: deps.resolveConfirmation,
549
620
  corsOrigin: origin,
621
+ ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
550
622
  }),
551
623
  OPTIONS: () => preflight(origin),
552
624
  },
553
625
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
554
- ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
555
- ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName) : {}),
626
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings) : {}),
627
+ ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
556
628
  ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
557
629
  },
558
630
  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,7 +46,9 @@ 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 }),
51
+ ...(ctx.settings === undefined ? {} : { settings: ctx.settings }),
48
52
  ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
49
53
  ...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
50
54
  ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
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,15 @@ 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. The model reads the person's message after when
108
+ and where they wrote it: in the time zone they saved, else the one
109
+ `X-Time-Zone` says, else the instance's.
110
+ parameters:
111
+ - $ref: "#/components/parameters/AcceptLanguage"
112
+ - $ref: "#/components/parameters/TimeZone"
105
113
  requestBody:
106
114
  required: true
107
115
  content:
@@ -125,6 +133,9 @@ paths:
125
133
  service the caller has to log in to first, with the link to open),
126
134
  `final` (the
127
135
  authoritative complete answer), and `error`.
136
+ headers:
137
+ Content-Language:
138
+ $ref: "#/components/headers/ContentLanguage"
128
139
  content:
129
140
  text/event-stream:
130
141
  schema:
@@ -141,7 +152,10 @@ paths:
141
152
  description: >
142
153
  Runs a one-time confirmation token through the same mechanism the
143
154
  bare-token-in-`text` path uses, keyed on the caller's own conversation
144
- (`<caller id>:<conversationId>`). Never touches the model.
155
+ (`<caller id>:<conversationId>`). Never touches the model. The reply
156
+ is in the caller's language, as on `/turn`.
157
+ parameters:
158
+ - $ref: "#/components/parameters/AcceptLanguage"
145
159
  requestBody:
146
160
  required: true
147
161
  content:
@@ -162,6 +176,9 @@ paths:
162
176
  already used). `resolved: true` with `resume: true` and no `text`
163
177
  when the action handed its result back to the model: the answer
164
178
  comes from `POST /turn { resume: true, conversationId }`.
179
+ headers:
180
+ Content-Language:
181
+ $ref: "#/components/headers/ContentLanguage"
165
182
  content:
166
183
  application/json:
167
184
  schema:
@@ -329,8 +346,10 @@ paths:
329
346
  summary: Who the caller is to the assistant
330
347
  description: >
331
348
  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.
349
+ asks for an opening turn after a sign-in only then. Also the language
350
+ they asked the assistant to use and the time zone they saved, `null`
351
+ when they never said (missing on a core that doesn't know them). Not
352
+ there on an older core, which can't say.
334
353
  responses:
335
354
  "401":
336
355
  $ref: "#/components/responses/Unauthorized"
@@ -339,7 +358,7 @@ paths:
339
358
  "503":
340
359
  $ref: "#/components/responses/NotAdmitted"
341
360
  "200":
342
- description: The caller's name, or null.
361
+ description: The caller's name, language and time zone, or null.
343
362
  content:
344
363
  application/json:
345
364
  schema:
@@ -350,6 +369,55 @@ paths:
350
369
  type: boolean
351
370
  name:
352
371
  type: [string, "null"]
372
+ language:
373
+ type: [string, "null"]
374
+ description: A language tag such as `it` or `pt-br`, one the instance ships.
375
+ timeZone:
376
+ type: [string, "null"]
377
+ description: The time zone's IANA name, such as `Europe/Rome`.
378
+ patch:
379
+ summary: Saves the caller's time zone
380
+ description: >
381
+ The time zone the caller is in, by its IANA name, for a UI's settings:
382
+ from then on their turns are stamped with the time there, whatever
383
+ `X-Time-Zone` says. Saved as the IANA list writes it (`europe/rome` is
384
+ `Europe/Rome`). Not there on a core without settings.
385
+ requestBody:
386
+ required: true
387
+ content:
388
+ application/json:
389
+ schema:
390
+ type: object
391
+ required: [timeZone]
392
+ properties:
393
+ timeZone:
394
+ type: string
395
+ description: An IANA name such as `Europe/Rome`; a fixed offset (`+02:00`) isn't one.
396
+ responses:
397
+ "400":
398
+ description: The body has no `timeZone`, or it isn't a zone's name.
399
+ content:
400
+ application/json:
401
+ schema:
402
+ $ref: "#/components/schemas/Error"
403
+ "401":
404
+ $ref: "#/components/responses/Unauthorized"
405
+ "403":
406
+ $ref: "#/components/responses/NotAdmitted"
407
+ "503":
408
+ $ref: "#/components/responses/NotAdmitted"
409
+ "200":
410
+ description: The time zone as saved.
411
+ content:
412
+ application/json:
413
+ schema:
414
+ type: object
415
+ required: [ok, timeZone]
416
+ properties:
417
+ ok:
418
+ type: boolean
419
+ timeZone:
420
+ type: string
353
421
  /tool-log:
354
422
  get:
355
423
  summary: The tool calls made in the caller's recent turns
@@ -493,20 +561,31 @@ paths:
493
561
  Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
494
562
  person's browser here with `code` and `state` (or `error`); the
495
563
  single-use state is the credential, so no bearer token is needed.
496
- Answers with a plain-text page.
564
+ Answers with a plain-text page, in the language the browser asks for
565
+ (`Accept-Language`), else the instance's default; a failed login reads
566
+ in the language of the person who started it.
497
567
  security: []
498
568
  parameters:
569
+ - $ref: "#/components/parameters/AcceptLanguage"
499
570
  - { name: code, in: query, schema: { type: string } }
500
571
  - { name: state, in: query, schema: { type: string } }
501
572
  - { name: error, in: query, schema: { type: string } }
502
573
  responses:
503
574
  "200":
504
575
  description: Logged in.
576
+ headers:
577
+ Content-Language:
578
+ $ref: "#/components/headers/ContentLanguage"
505
579
  content:
506
580
  text/plain:
507
581
  schema: { type: string }
508
582
  "400":
509
583
  description: Refused, expired, already used, or missing code/state.
584
+ headers:
585
+ Content-Language:
586
+ 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.
587
+ schema:
588
+ type: string
510
589
  content:
511
590
  text/plain:
512
591
  schema: { type: string }
@@ -522,6 +601,37 @@ paths:
522
601
  schema:
523
602
  type: string
524
603
  components:
604
+ parameters:
605
+ AcceptLanguage:
606
+ name: Accept-Language
607
+ in: header
608
+ required: false
609
+ description: >
610
+ The languages the client prefers, as browsers send them
611
+ (`it-IT,it;q=0.9,en;q=0.8`). Used for the texts the person reads when
612
+ they saved no language of their own; a language the instance doesn't
613
+ ship is passed over.
614
+ schema:
615
+ type: string
616
+ TimeZone:
617
+ name: X-Time-Zone
618
+ in: header
619
+ required: false
620
+ description: >
621
+ The time zone the client is in, by its IANA name, as a browser gives
622
+ it (`Intl.DateTimeFormat().resolvedOptions().timeZone`). Used for the
623
+ turn's time when the person saved no time zone of their own; a name
624
+ that isn't a zone is passed over.
625
+ schema:
626
+ type: string
627
+ headers:
628
+ ContentLanguage:
629
+ description: >
630
+ The language of the caller's texts as it stood when the response
631
+ started, one the instance ships (`en`, `it`); a person who picks
632
+ another language during a turn gets it from the next one.
633
+ schema:
634
+ type: string
525
635
  securitySchemes:
526
636
  bearerAuth:
527
637
  type: http
@@ -545,7 +655,11 @@ components:
545
655
  description: >
546
656
  An authenticated caller the core won't talk to: `403` when the app's
547
657
  directory doesn't know them, `503` when it can't be reached. Nothing
548
- behind the route runs.
658
+ behind the route runs. `error` is in the language `Accept-Language`
659
+ asks for, which `Content-Language` names.
660
+ headers:
661
+ Content-Language:
662
+ $ref: "#/components/headers/ContentLanguage"
549
663
  content:
550
664
  application/json:
551
665
  schema:
@@ -616,7 +730,7 @@ components:
616
730
  description: On `pending` — the one-time confirmation token (the first choice's).
617
731
  choices:
618
732
  type: array
619
- description: On `pending` — every way to answer, in order; a plain staging has one, labelled `Conferma`.
733
+ 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
734
  items:
621
735
  type: object
622
736
  required: [token, label]
@@ -634,6 +748,13 @@ components:
634
748
  text:
635
749
  type: string
636
750
  description: On `final` — the authoritative complete answer.
751
+ pendingNote:
752
+ type: string
753
+ description: >
754
+ On `final`, when the model wrote nothing because the turn staged an
755
+ action: the stand-in sentence `text` begins with (followed by any
756
+ list the turn surfaced). A UI showing its own confirmation hides
757
+ it; the conversation's transcript keeps it.
637
758
  message:
638
759
  type: string
639
760
  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.12.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.56.0",
25
+ "@mercury-fw/plugin-types": "0.56.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"