@mercury-fw/channel-http 0.6.0 → 0.8.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,31 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 812c9c5: - A conversation reopened on the HTTP surface after Mercury freed its history comes back to the model: the summaries of its older part plus its newest messages from the archive, within a budget.
8
+ - What a resumed conversation brings back isn't captured to long-term memory again, only the new messages are.
9
+ - `SESSION_RESUME_MAX_CHARS` sets how much comes back (default 30000, `0` turns it off); `HTTP_SURFACE_RESUME_MAX_CHARS` sets it for the HTTP surface only.
10
+ - `InboundTurn.resume` lets a channel mark a conversation its client reopens.
11
+ - The episodic collection gets a `sessionKey` index.
12
+ - `mfw create` lists `HTTP_SURFACE_RESUME_MAX_CHARS` for the HTTP channel.
13
+ - Core's README explains how Mercury remembers a conversation.
14
+
15
+ ## 0.7.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 2c63bbf: One person across accounts: linking them by a code.
20
+
21
+ - `POST /link` on the HTTP surface hands the caller a one-time code (`xxxx-xxxx-xxxx`, ten minutes, single use). They send it 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.
22
+ - From then on the account is the person it's linked to: one private area, one memory, the same roles. Its earlier area stays where it was, and undoing the link gives it back.
23
+ - A link never touches the terminal, never chains, and is refused for an account the directory already knows as someone else.
24
+ - The links live on the vault's volume, out of the vault's git and out of reach of the model and the HTTP reads.
25
+ - `mfw identity links | link | unlink` lets the operator manage them, for instances where nobody can be shown a code.
26
+ - Channels get the code through the confirmation hook they already call (`ctx.confirm`), and the HTTP surface gets `ctx.linking`.
27
+ - Fixed: `ctx.confirm` refused someone the core doesn't admit before checking that their text was a token at all. On Google Chat, every message from an unknown sender came back as the refusal.
28
+
3
29
  ## 0.6.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -21,6 +21,7 @@ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-
21
21
  | `HTTP_SURFACE_PORT` | The port (default `4100`). |
22
22
  | `HTTP_SURFACE_CORS_ORIGIN` | The origin a browser UI calls from (default `*`). |
23
23
  | `HTTP_SURFACE_PUBLIC_URL` | The address people's browsers reach the surface at (e.g. `https://mercury.example.com`). With it, people log in here to the services plugins act on as them (see [`GET /login/callback`](#get-logincallback)); without it, nobody can. |
24
+ | `HTTP_SURFACE_RESUME_MAX_CHARS` | How many characters of a reopened conversation come back to the model once Mercury has freed its history (default: the core's `SESSION_RESUME_MAX_CHARS`; `0` turns resuming off on this surface). See [how Mercury remembers a conversation](../../libs/core/README.md#how-mercury-remembers-a-conversation). |
24
25
 
25
26
  ## API
26
27
 
@@ -45,7 +46,7 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
45
46
  | field | type | required | description |
46
47
  |---|---|---|---|
47
48
  | `text` | string | yes | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model. |
48
- | `conversationId` | string | no | Opaque, client-owned id that continues one of your conversations: letters, digits, `-` and `_`, up to 128 characters (a UUID fits) once surrounding whitespace is trimmed, `400` otherwise. Omitted ⇒ a fresh one-off session. |
49
+ | `conversationId` | string | no | Opaque, client-owned id that continues one of your conversations: letters, digits, `-` and `_`, up to 128 characters (a UUID fits) once surrounding whitespace is trimmed, `400` otherwise. Sent again, however long after, it continues that conversation, rebuilt from the archive if Mercury had freed it (see `HTTP_SURFACE_RESUME_MAX_CHARS`). Omitted ⇒ a fresh one-off session that can't be continued. |
49
50
 
50
51
  **Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, the body isn't JSON, or `conversationId` has other characters than the ones above.
51
52
 
@@ -78,6 +79,10 @@ Where a person comes back after logging in to a service a plugin acts on as them
78
79
 
79
80
  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).
80
81
 
82
+ ## `POST /link`
83
+
84
+ 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.
85
+
81
86
  ## `POST /confirm`
82
87
 
83
88
  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. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
@@ -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 } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
17
17
  /** Bare-token interception before the model. */
@@ -32,6 +32,10 @@ export type HttpProviderDeps = {
32
32
  accept: (callbackUrl: string) => void;
33
33
  complete: CompleteLoginFn;
34
34
  };
35
+ /** Linking another account to the caller (`POST /link`). */
36
+ linking?: ChannelLinking;
37
+ /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
38
+ resumeMaxChars?: number;
35
39
  };
36
40
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
37
41
  stop(): Promise<void>;
@@ -22,7 +22,7 @@
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 } from "@mercury-fw/channel-types";
25
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
26
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
27
  export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
28
28
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
@@ -40,6 +40,8 @@ export type TurnRequestDeps = {
40
40
  corsOrigin?: string;
41
41
  /** Test seam for the ephemeral session key when the client sends no conversationId. */
42
42
  newSessionKey?: () => string;
43
+ /** How much of a reopened conversation comes back once the core let its history go (`HTTP_SURFACE_RESUME_MAX_CHARS`); absent, the core's own. */
44
+ resumeMaxChars?: number;
43
45
  };
44
46
  /**
45
47
  * Serves the OpenAPI document that describes this surface — the single source
@@ -79,6 +81,20 @@ export type ConfirmRequestDeps = {
79
81
  * even token-shaped). Never touches the model.
80
82
  */
81
83
  export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
84
+ export type LinkRequestDeps = {
85
+ authenticate: Authenticate;
86
+ admit?: AdmitFn;
87
+ /** Hands the caller a link code, injected by the core (`ctx.linking.start`). */
88
+ start: ChannelLinking["start"];
89
+ corsOrigin?: string;
90
+ };
91
+ /**
92
+ * `POST /link` — a one-time code for linking another account to the caller
93
+ * (the custom UI shows it). The person then sends it from the other account,
94
+ * on any channel. Nobody but the caller ever sees it, the model included, and
95
+ * it isn't cached. `400` with the core's reason when there's no code for them.
96
+ */
97
+ export declare function handleLinkRequest(req: Request, deps: LinkRequestDeps): Promise<Response>;
82
98
  /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
83
99
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
84
100
  /**
@@ -115,6 +131,8 @@ export type HttpServerDeps = TurnRequestDeps & {
115
131
  resolveConfirmation: ResolveConfirmationFn;
116
132
  /** Finishes people's logins; `/login/callback` is mounted only with it. */
117
133
  completeLogin?: CompleteLoginFn;
134
+ /** Linking another account to the caller; `/link` is mounted only with it. */
135
+ linking?: ChannelLinking;
118
136
  };
119
137
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
120
138
  * when `reads` is supplied. */
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 } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -29,6 +29,10 @@ export type HttpProviderDeps = {
29
29
  /** People's logins, when the surface has a public URL to be sent back to:
30
30
  * the callback is offered (`accept`) once the server listens. */
31
31
  logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
32
+ /** Linking another account to the caller (`POST /link`). */
33
+ linking?: ChannelLinking;
34
+ /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
35
+ resumeMaxChars?: number;
32
36
  };
33
37
 
34
38
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -45,6 +49,8 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
45
49
  ...(deps.admit === undefined ? {} : { admit: deps.admit }),
46
50
  corsOrigin: deps.corsOrigin,
47
51
  completeLogin: deps.logins?.complete,
52
+ ...(deps.linking === undefined ? {} : { linking: deps.linking }),
53
+ ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
48
54
  });
49
55
  deps.logins?.accept(deps.logins.callbackUrl);
50
56
  },
package/http-server.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  type LoginOutcome,
35
35
  type Principal,
36
36
  type Admission,
37
+ type ChannelLinking,
37
38
  } from "@mercury-fw/channel-types";
38
39
 
39
40
  /**
@@ -68,6 +69,8 @@ export type TurnRequestDeps = {
68
69
  corsOrigin?: string;
69
70
  /** Test seam for the ephemeral session key when the client sends no conversationId. */
70
71
  newSessionKey?: () => string;
72
+ /** How much of a reopened conversation comes back once the core let its history go (`HTTP_SURFACE_RESUME_MAX_CHARS`); absent, the core's own. */
73
+ resumeMaxChars?: number;
71
74
  };
72
75
 
73
76
  const SSE_HEADERS = {
@@ -160,10 +163,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
160
163
  return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
161
164
  }
162
165
  const text = body.text;
163
- const conversationId =
164
- typeof body.conversationId === "string" && body.conversationId.trim().length > 0
165
- ? body.conversationId.trim()
166
- : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
166
+ // A conversation the client names is one it reopens; an ephemeral one never comes back.
167
+ const named = typeof body.conversationId === "string" && body.conversationId.trim().length > 0;
168
+ const conversationId = named ? (body.conversationId as string).trim() : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
167
169
  if (!CONVERSATION_ID.test(conversationId)) {
168
170
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
169
171
  }
@@ -233,6 +235,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
233
235
  // The caller's id comes from the auth provider, unchecked here.
234
236
  logPrefix: `[http:${forLog(sessionKey)}] `,
235
237
  abortSignal: abort.signal,
238
+ ...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
236
239
  },
237
240
  sink,
238
241
  );
@@ -305,6 +308,33 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
305
308
  }
306
309
  }
307
310
 
311
+ export type LinkRequestDeps = {
312
+ authenticate: Authenticate;
313
+ admit?: AdmitFn;
314
+ /** Hands the caller a link code, injected by the core (`ctx.linking.start`). */
315
+ start: ChannelLinking["start"];
316
+ corsOrigin?: string;
317
+ };
318
+
319
+ /**
320
+ * `POST /link` — a one-time code for linking another account to the caller
321
+ * (the custom UI shows it). The person then sends it from the other account,
322
+ * on any channel. Nobody but the caller ever sees it, the model included, and
323
+ * it isn't cached. `400` with the core's reason when there's no code for them.
324
+ */
325
+ export async function handleLinkRequest(req: Request, deps: LinkRequestDeps): Promise<Response> {
326
+ const origin = deps.corsOrigin ?? "*";
327
+ const principal = await caller(req, deps, origin);
328
+ if (principal instanceof Response) return principal;
329
+ const started = await deps.start(principal);
330
+ return Response.json(started, { status: started.ok ? 200 : 400, headers: { ...corsHeaders(origin), "cache-control": "no-store" } });
331
+ }
332
+
333
+ /** `POST /link` and its preflight, mounted when the core offers linking. */
334
+ function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request) => Promise<Response>; OPTIONS: () => Response }> {
335
+ return { "/link": { POST: (req) => handleLinkRequest(req, deps), OPTIONS: () => preflight(deps.corsOrigin ?? "*") } };
336
+ }
337
+
308
338
  /** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
309
339
  export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
310
340
 
@@ -439,6 +469,8 @@ export type HttpServerDeps = TurnRequestDeps & {
439
469
  resolveConfirmation: ResolveConfirmationFn;
440
470
  /** Finishes people's logins; `/login/callback` is mounted only with it. */
441
471
  completeLogin?: CompleteLoginFn;
472
+ /** Linking another account to the caller; `/link` is mounted only with it. */
473
+ linking?: ChannelLinking;
442
474
  };
443
475
 
444
476
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -471,6 +503,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
471
503
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
472
504
  ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
473
505
  ...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
506
+ ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
474
507
  },
475
508
  error: (err) =>
476
509
  Response.json(
package/index.ts CHANGED
@@ -35,6 +35,7 @@ export const httpChannel: ChannelPlugin = {
35
35
  // the address their browser reaches the surface at.
36
36
  const publicUrl = ctx.env.HTTP_SURFACE_PUBLIC_URL?.trim().replace(/\/+$/, "");
37
37
  const logins = publicUrl ? ctx.logins : undefined;
38
+ const resumeMaxChars = parseResumeMaxChars(ctx.env.HTTP_SURFACE_RESUME_MAX_CHARS);
38
39
  return createHttpProvider({
39
40
  port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
40
41
  corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
@@ -43,7 +44,17 @@ export const httpChannel: ChannelPlugin = {
43
44
  reads: ctx.reads,
44
45
  authenticate: ctx.authenticate,
45
46
  admit: ctx.admit,
47
+ ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
48
+ ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
46
49
  ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
47
50
  });
48
51
  },
49
52
  };
53
+
54
+ /** `HTTP_SURFACE_RESUME_MAX_CHARS`: unset or empty leaves the core's budget, otherwise a whole number from 0 up (0 turns resuming off on this surface); throws on anything else. */
55
+ function parseResumeMaxChars(text: string | undefined): number | undefined {
56
+ const trimmed = text?.trim() ?? "";
57
+ if (trimmed === "") return undefined;
58
+ if (!/^\d+$/.test(trimmed)) throw new Error(`HTTP_SURFACE_RESUME_MAX_CHARS must be a whole number from 0 up, got "${trimmed}"`);
59
+ return Number(trimmed);
60
+ }
package/openapi.yaml CHANGED
@@ -49,6 +49,13 @@ info:
49
49
  by the verbatim archive; they degrade to empty when the vector store is
50
50
  unreachable).
51
51
 
52
+ A conversation never ends: sent again with the same `conversationId`, a
53
+ turn continues it however long after. Mercury frees a conversation's
54
+ in-memory history once it has been idle a while; the next turn rebuilds
55
+ it from the archive (a summary of its older part and its newest messages,
56
+ within `HTTP_SURFACE_RESUME_MAX_CHARS`), so the model still knows what the
57
+ person sees on screen.
58
+
52
59
 
53
60
  ## Streaming and cancellation
54
61
 
@@ -146,6 +153,45 @@ paths:
146
153
  application/json:
147
154
  schema:
148
155
  $ref: "#/components/schemas/Error"
156
+ /link:
157
+ post:
158
+ summary: A code for linking another account to the caller
159
+ description: >
160
+ A one-time code (`xxxx-xxxx-xxxx`, valid for ten minutes) the person
161
+ sends from another account, on any channel, twice: the first time
162
+ Mercury says whom the account would be linked to, the second time it
163
+ links it. From then on both accounts are one person. Only the caller
164
+ sees the code; the model never does.
165
+ responses:
166
+ "401":
167
+ $ref: "#/components/responses/Unauthorized"
168
+ "403":
169
+ $ref: "#/components/responses/NotAdmitted"
170
+ "503":
171
+ $ref: "#/components/responses/NotAdmitted"
172
+ "400":
173
+ description: No code for this caller, with the reason.
174
+ content:
175
+ application/json:
176
+ schema:
177
+ $ref: "#/components/schemas/Error"
178
+ "200":
179
+ description: The code and when it expires.
180
+ content:
181
+ application/json:
182
+ schema:
183
+ type: object
184
+ required: [ok, code, expiresAt]
185
+ properties:
186
+ ok:
187
+ type: boolean
188
+ enum: [true]
189
+ code:
190
+ type: string
191
+ pattern: "^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$"
192
+ expiresAt:
193
+ type: string
194
+ format: date-time
149
195
  /conversation:
150
196
  get:
151
197
  summary: A conversation's durable transcript
@@ -481,7 +527,7 @@ components:
481
527
  conversationId:
482
528
  type: string
483
529
  pattern: "^[A-Za-z0-9_-]{1,128}$"
484
- description: Opaque, client-owned; letters, digits, `-` and `_` (a UUID fits). Omit to start a fresh ephemeral conversation.
530
+ description: Opaque, client-owned; letters, digits, `-` and `_` (a UUID fits). Omit to start a fresh ephemeral conversation, which can't be continued.
485
531
  TurnEvent:
486
532
  type: object
487
533
  description: The shape carried by each SSE `data:` line, per event type.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "@mercury-fw/channel-types": ">=0.36.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.42.0",
25
- "@mercury-fw/plugin-types": "0.42.0",
24
+ "@mercury-fw/channel-types": "0.45.0",
25
+ "@mercury-fw/plugin-types": "0.45.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"