@mercury-fw/channel-http 0.11.0 → 0.13.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,29 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7c9a038: - `GET /version` answers the framework's version (`{ ok: true, version: "0.59.0" }`) with no token, for a UI to show which Mercury it's talking to.
8
+ - The channel now needs the framework at 0.59 or later (peer range `>=0.59.0 <1.0.0`), the first whose core gives channels its version.
9
+
10
+ ## 0.12.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 1126106: Every turn tells the model when and where the person wrote it.
15
+
16
+ - 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.
17
+ - A resumed conversation's messages come back stamped from the archive's timestamps.
18
+ - 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.
19
+ - 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.
20
+ - The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
21
+ - 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.
22
+ - 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`.
23
+ - Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
24
+
25
+ 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.
26
+
3
27
  ## 0.11.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
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.
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.59 or later, the first whose core gives channels its version (`GET /version`). A core with an older contract skips it, and the log says why.
6
6
 
7
7
  ```bash
8
8
  bun add @mercury-fw/channel-http
@@ -29,7 +29,7 @@ It needs an auth provider next to it, or several (`auth: [a, b]`, tried in order
29
29
 
30
30
  It's active whenever it's declared in `mercury.config.ts`'s `channels` with an auth provider; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). Base URL `http://<host>:<port>`.
31
31
 
32
- **Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth providers decide who the token belongs to: the first that recognizes it. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
32
+ **Authentication.** Every route except `GET /openapi.yaml`, `GET /version`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth providers decide who the token belongs to: the first that recognizes it. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
33
33
 
34
34
  **Conversations belong to whoever opened them.** The session key is `<caller id>:<conversationId>`, so the same `conversationId` sent by someone else is a conversation of their own, and a confirmation token staged in your conversation can't be confirmed from theirs. Each authenticated caller also gets their own episodic memory and wiki area, as on Google Chat.
35
35
 
@@ -56,6 +56,8 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
56
56
 
57
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
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
+
59
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.
60
62
 
61
63
  **SSE events** — each is `event: <name>` followed by `data: <json>`:
@@ -89,6 +91,10 @@ Whoever opens the link and accepts decides which account gets linked: a link bel
89
91
 
90
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.
91
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
+
92
98
  ## `POST /confirm`
93
99
 
94
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.
@@ -103,7 +109,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
103
109
  | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
104
110
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
105
111
  | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
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 |
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 |
107
113
  | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
108
114
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
109
115
  | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
@@ -113,3 +119,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
113
119
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
114
120
 
115
121
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
122
+
123
+ ## `GET /version`
124
+
125
+ Which Mercury this is, for a UI to show: `{ ok: true, version }`, the framework's version as `major.minor.patch` (`"0.59.0"`), the one of `@mercury-fw/core`, not this channel's own. It needs no token.
@@ -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, ChannelI18n, 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,12 +34,16 @@ 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;
41
43
  /** The texts in each caller's language (`ctx.i18n`). */
42
44
  i18n?: ChannelI18n;
45
+ /** The framework's version, served on `/version` (`ctx.frameworkVersion`). */
46
+ version?: string;
43
47
  };
44
48
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
45
49
  stop(): Promise<void>;
@@ -15,14 +15,14 @@
15
15
  * `/login/callback` is where a person's browser comes back after logging in to
16
16
  * a service a plugin acts on as them; the single-use state is its credential.
17
17
  *
18
- * Every route but the OpenAPI document, the login callback and the CORS
18
+ * Every route but the OpenAPI document, `/version`, the login callback and the CORS
19
19
  * preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
20
20
  * before anything runs, and answers 401 when nobody is. A conversation belongs
21
21
  * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
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 ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
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
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
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`). */
@@ -124,9 +124,11 @@ export declare function handleLoginCallback(req: Request, deps: {
124
124
  assistantName?: string;
125
125
  i18n?: ChannelI18n;
126
126
  }): Promise<Response>;
127
- /** 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. */
128
128
  type ReadRoute = {
129
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>;
130
132
  OPTIONS: () => Response;
131
133
  };
132
134
  /**
@@ -138,9 +140,10 @@ type ReadRoute = {
138
140
  * their data only; a conversation is named by its conversationId, as on
139
141
  * `/turn`, and its session key is built from the caller.
140
142
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
141
- * 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.
142
145
  */
143
- 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>;
144
147
  export type HttpServerDeps = TurnRequestDeps & {
145
148
  port: number;
146
149
  reads?: ChannelHostReads;
@@ -150,8 +153,12 @@ export type HttpServerDeps = TurnRequestDeps & {
150
153
  completeLogin?: CompleteLoginFn;
151
154
  /** Linking another account to the caller; `/link` is mounted only with it. */
152
155
  linking?: ChannelLinking;
156
+ /** The person's own settings; `PATCH /me` is mounted only with it. */
157
+ settings?: ChannelSettings;
153
158
  /** What the pages people read call the assistant. */
154
159
  assistantName?: string;
160
+ /** The framework's version; `/version` is mounted only with it. */
161
+ version?: string;
155
162
  };
156
163
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
157
164
  * when `reads` is supplied. */
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The HTTP channel plugin: an opt-in conversational HTTP surface (SSE `/turn`,
3
- * `/confirm`, read-only introspection routes, the OpenAPI doc) that a custom UI
3
+ * `/confirm`, read-only introspection routes, the OpenAPI doc, `/version`) that a custom UI
4
4
  * calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
5
5
  * does not import the app. Enabled by listing it in `mercury.config.ts` — there
6
6
  * is no runtime inert condition (unlike Google Chat's subscription): if it's
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, ChannelI18n, 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,12 +31,16 @@ 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;
38
40
  /** The texts in each caller's language (`ctx.i18n`). */
39
41
  i18n?: ChannelI18n;
42
+ /** The framework's version, served on `/version` (`ctx.frameworkVersion`). */
43
+ version?: string;
40
44
  };
41
45
 
42
46
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -54,9 +58,11 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
54
58
  corsOrigin: deps.corsOrigin,
55
59
  completeLogin: deps.logins?.complete,
56
60
  ...(deps.linking === undefined ? {} : { linking: deps.linking }),
61
+ ...(deps.settings === undefined ? {} : { settings: deps.settings }),
57
62
  ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
58
63
  ...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
59
64
  ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
65
+ ...(deps.version === undefined ? {} : { version: deps.version }),
60
66
  });
61
67
  deps.logins?.accept(deps.logins.callbackUrl);
62
68
  },
package/http-server.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * `/login/callback` is where a person's browser comes back after logging in to
16
16
  * a service a plugin acts on as them; the single-use state is its credential.
17
17
  *
18
- * Every route but the OpenAPI document, the login callback and the CORS
18
+ * Every route but the OpenAPI document, `/version`, the login callback and the CORS
19
19
  * preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
20
20
  * before anything runs, and answers 401 when nobody is. A conversation belongs
21
21
  * to whoever opened it: the session key is `<principal.id>:<conversationId>`.
@@ -35,6 +35,7 @@ import {
35
35
  type Principal,
36
36
  type Admission,
37
37
  type ChannelLinking,
38
+ type ChannelSettings,
38
39
  } from "@mercury-fw/channel-types";
39
40
  import { englishHttp } from "./messages.ts";
40
41
 
@@ -65,6 +66,12 @@ function languageHint(req: Request): string | undefined {
65
66
  return value ? value : undefined;
66
67
  }
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
+
68
75
  /** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
69
76
  async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
70
77
  return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
@@ -104,8 +111,8 @@ const SSE_HEADERS = {
104
111
  function corsHeaders(origin: string): Record<string, string> {
105
112
  return {
106
113
  "access-control-allow-origin": origin,
107
- "access-control-allow-methods": "GET, POST, OPTIONS",
108
- "access-control-allow-headers": "content-type, authorization, accept-language",
114
+ "access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
115
+ "access-control-allow-headers": "content-type, authorization, accept-language, x-time-zone",
109
116
  };
110
117
  }
111
118
 
@@ -207,6 +214,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
207
214
  }
208
215
  const sessionKey = `${principal.id}:${conversationId}`;
209
216
  const hint = languageHint(req);
217
+ const zone = timeZoneHint(req);
210
218
  const language = await contentLanguage(deps.i18n, principal, hint);
211
219
 
212
220
  // Aborted when the client disconnects (see the stream's `cancel` below) so
@@ -280,6 +288,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
280
288
  ...(opening ? { opening: true as const } : {}),
281
289
  ...(resuming ? { resumeAfterConfirm: true as const } : {}),
282
290
  ...(hint === undefined ? {} : { languageHint: hint }),
291
+ ...(zone === undefined ? {} : { timeZoneHint: zone }),
283
292
  },
284
293
  sink,
285
294
  );
@@ -449,9 +458,11 @@ function loginRoutes(
449
458
  };
450
459
  }
451
460
 
452
- /** 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. */
453
462
  type ReadRoute = {
454
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>;
455
466
  OPTIONS: () => Response;
456
467
  };
457
468
 
@@ -464,13 +475,15 @@ type ReadRoute = {
464
475
  * their data only; a conversation is named by its conversationId, as on
465
476
  * `/turn`, and its session key is built from the caller.
466
477
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
467
- * 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.
468
480
  */
469
481
  export function readRoutes(
470
482
  reads: ChannelHostReads,
471
483
  authenticate: Authenticate,
472
484
  corsOrigin = "*",
473
485
  admit?: AdmitFn,
486
+ settings?: ChannelSettings,
474
487
  ): Record<string, ReadRoute> {
475
488
  const cors = corsHeaders(corsOrigin);
476
489
  const json = (payload: object, status = 200): Response =>
@@ -515,12 +528,31 @@ export function readRoutes(
515
528
  ...(reads.me === undefined
516
529
  ? {}
517
530
  : {
518
- "/me": route(async (_req, principal) => {
519
- // Null when the core no longer admits the caller: still a 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 }) });
523
- }),
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
+ },
524
556
  }),
525
557
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
526
558
  "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
@@ -547,6 +579,19 @@ export function readRoutes(
547
579
  };
548
580
  }
549
581
 
582
+ /**
583
+ * `GET /version`: which Mercury this is (the framework's version), for any
584
+ * front end to show. Nobody is asked who they are, like `/openapi.yaml`.
585
+ */
586
+ function versionRoutes(version: string, corsOrigin = "*"): Record<string, ReadRoute> {
587
+ return {
588
+ "/version": {
589
+ GET: () => Response.json({ ok: true, version }, { headers: corsHeaders(corsOrigin) }),
590
+ OPTIONS: () => preflight(corsOrigin),
591
+ },
592
+ };
593
+ }
594
+
550
595
  export type HttpServerDeps = TurnRequestDeps & {
551
596
  port: number;
552
597
  reads?: ChannelHostReads;
@@ -556,8 +601,12 @@ export type HttpServerDeps = TurnRequestDeps & {
556
601
  completeLogin?: CompleteLoginFn;
557
602
  /** Linking another account to the caller; `/link` is mounted only with it. */
558
603
  linking?: ChannelLinking;
604
+ /** The person's own settings; `PATCH /me` is mounted only with it. */
605
+ settings?: ChannelSettings;
559
606
  /** What the pages people read call the assistant. */
560
607
  assistantName?: string;
608
+ /** The framework's version; `/version` is mounted only with it. */
609
+ version?: string;
561
610
  };
562
611
 
563
612
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -589,7 +638,8 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
589
638
  OPTIONS: () => preflight(origin),
590
639
  },
591
640
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
592
- ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
641
+ ...(deps.version === undefined ? {} : versionRoutes(deps.version, origin)),
642
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings) : {}),
593
643
  ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
594
644
  ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
595
645
  },
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The HTTP channel plugin: an opt-in conversational HTTP surface (SSE `/turn`,
3
- * `/confirm`, read-only introspection routes, the OpenAPI doc) that a custom UI
3
+ * `/confirm`, read-only introspection routes, the OpenAPI doc, `/version`) that a custom UI
4
4
  * calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
5
5
  * does not import the app. Enabled by listing it in `mercury.config.ts` — there
6
6
  * is no runtime inert condition (unlike Google Chat's subscription): if it's
@@ -48,8 +48,10 @@ export const httpChannel: ChannelPlugin = {
48
48
  admit: ctx.admit,
49
49
  i18n: ctx.i18n,
50
50
  ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
51
+ ...(ctx.settings === undefined ? {} : { settings: ctx.settings }),
51
52
  ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
52
53
  ...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
54
+ ...(ctx.frameworkVersion === undefined ? {} : { version: ctx.frameworkVersion }),
53
55
  ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
54
56
  });
55
57
  },
package/openapi.yaml CHANGED
@@ -14,7 +14,7 @@ info:
14
14
 
15
15
  ## Authentication
16
16
 
17
- Every route except this document and the `OPTIONS` preflights needs
17
+ Every route except this document, `/version` and the `OPTIONS` preflights needs
18
18
  `Authorization: Bearer <token>`. The app's auth provider decides what a
19
19
  valid token is (an OpenID Connect access token, a static test token); a
20
20
  missing or refused one gets `401` with `WWW-Authenticate: Bearer`, before
@@ -104,9 +104,12 @@ paths:
104
104
  an empty text. Every text the person reads that isn't the model's (a
105
105
  refusal, a status line, a confirmation's reply) is in their language:
106
106
  the one they saved, else the one `Accept-Language` asks for, else the
107
- instance's default.
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.
108
110
  parameters:
109
111
  - $ref: "#/components/parameters/AcceptLanguage"
112
+ - $ref: "#/components/parameters/TimeZone"
110
113
  requestBody:
111
114
  required: true
112
115
  content:
@@ -344,9 +347,9 @@ paths:
344
347
  description: >
345
348
  How the caller asked to be called, `null` when they never said: a UI
346
349
  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.
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.
350
353
  responses:
351
354
  "401":
352
355
  $ref: "#/components/responses/Unauthorized"
@@ -355,7 +358,7 @@ paths:
355
358
  "503":
356
359
  $ref: "#/components/responses/NotAdmitted"
357
360
  "200":
358
- description: The caller's name and language, or null.
361
+ description: The caller's name, language and time zone, or null.
359
362
  content:
360
363
  application/json:
361
364
  schema:
@@ -369,6 +372,52 @@ paths:
369
372
  language:
370
373
  type: [string, "null"]
371
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
372
421
  /tool-log:
373
422
  get:
374
423
  summary: The tool calls made in the caller's recent turns
@@ -540,6 +589,27 @@ paths:
540
589
  content:
541
590
  text/plain:
542
591
  schema: { type: string }
592
+ /version:
593
+ get:
594
+ summary: Which Mercury this is
595
+ description: >
596
+ The framework's version, `major.minor.patch`, for a UI to show. Needs
597
+ no bearer token.
598
+ security: []
599
+ responses:
600
+ "200":
601
+ description: The framework's version.
602
+ content:
603
+ application/json:
604
+ schema:
605
+ type: object
606
+ required: [ok, version]
607
+ properties:
608
+ ok: { type: boolean, const: true }
609
+ version:
610
+ type: string
611
+ pattern: '^\d+\.\d+\.\d+$'
612
+ examples: ["0.59.0"]
543
613
  /openapi.yaml:
544
614
  get:
545
615
  summary: This OpenAPI document
@@ -564,6 +634,17 @@ components:
564
634
  ship is passed over.
565
635
  schema:
566
636
  type: string
637
+ TimeZone:
638
+ name: X-Time-Zone
639
+ in: header
640
+ required: false
641
+ description: >
642
+ The time zone the client is in, by its IANA name, as a browser gives
643
+ it (`Intl.DateTimeFormat().resolvedOptions().timeZone`). Used for the
644
+ turn's time when the person saved no time zone of their own; a name
645
+ that isn't a zone is passed over.
646
+ schema:
647
+ type: string
567
648
  headers:
568
649
  ContentLanguage:
569
650
  description: >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.11.0",
3
+ "version": "0.13.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.54.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.59.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.54.0",
25
- "@mercury-fw/plugin-types": "0.54.0",
24
+ "@mercury-fw/channel-types": "0.59.0",
25
+ "@mercury-fw/plugin-types": "0.59.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"