@mercury-fw/channel-http 0.1.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/README.md +86 -0
- package/dist/http-provider.d.ts +27 -0
- package/dist/http-server.d.ts +88 -0
- package/dist/index.d.ts +16 -0
- package/http-provider.ts +48 -0
- package/http-server.ts +329 -0
- package/index.ts +34 -0
- package/openapi.yaml +418 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @mercury-fw/channel-http
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun add @mercury-fw/channel-http
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { httpChannel } from "@mercury-fw/channel-http";
|
|
11
|
+
|
|
12
|
+
channels: [httpChannel],
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Variable | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `HTTP_SURFACE_PORT` | The port (default `4100`). |
|
|
18
|
+
| `HTTP_SURFACE_CORS_ORIGIN` | The origin a browser UI calls from (default `*`). |
|
|
19
|
+
|
|
20
|
+
## API
|
|
21
|
+
|
|
22
|
+
It's active whenever it's declared in `mercury.config.ts`'s `channels`; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). **No authentication** — do not publish the port outside the container network (same posture as the admin panel). Base URL `http://<host>:<port>`.
|
|
23
|
+
|
|
24
|
+
**CORS** is enabled on every response and every route answers an `OPTIONS` preflight, so a browser UI on another origin can call it. The allowed origin is `HTTP_SURFACE_CORS_ORIGIN` (default `*`; no credentials are used).
|
|
25
|
+
|
|
26
|
+
The full contract is described by an **OpenAPI document**, served raw at `GET /openapi.yaml` and published as a rendered docs page on GitHub Pages (see `packages/channels/channel-http/openapi.yaml`, the single source of truth).
|
|
27
|
+
|
|
28
|
+
All responses are JSON except `POST /turn`, which streams `text/event-stream`. Every JSON response is either `{ "ok": true, ... }` or, on error, `{ "ok": false, "error": "<message>" }` with HTTP `400`/`500`.
|
|
29
|
+
|
|
30
|
+
## `POST /turn`
|
|
31
|
+
|
|
32
|
+
Runs one conversational turn; the reply streams back as Server-Sent Events.
|
|
33
|
+
|
|
34
|
+
**Request body** (`application/json`):
|
|
35
|
+
|
|
36
|
+
| field | type | required | description |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `text` | string | yes | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model. |
|
|
39
|
+
| `conversationId` | string | no | Opaque, client-owned id that continues a conversation. Omitted ⇒ a fresh one-off session. |
|
|
40
|
+
|
|
41
|
+
**Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing or the body isn't JSON.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
**SSE events** — each is `event: <name>` followed by `data: <json>`:
|
|
46
|
+
|
|
47
|
+
| event | data | when |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `reasoning` | `{ chunk, id }` | a model reasoning delta |
|
|
50
|
+
| `reasoning_end` | `{ id, failed }` | a reasoning block ends |
|
|
51
|
+
| `tool` | `{ label, detail, toolCallId }` | a tool call starts |
|
|
52
|
+
| `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
|
|
53
|
+
| `text` | `{ chunk }` | an answer-text delta |
|
|
54
|
+
| `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
|
|
55
|
+
| `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
|
|
56
|
+
| `error` | `{ message }` | the turn failed mid-stream |
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
curl -N -X POST http://localhost:4100/turn \
|
|
60
|
+
-H 'content-type: application/json' \
|
|
61
|
+
-d '{"text":"Quante issue nel progetto KAN?","conversationId":"c1"}'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## `POST /confirm`
|
|
65
|
+
|
|
66
|
+
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. Never invokes the model.
|
|
67
|
+
|
|
68
|
+
## Read-only introspection
|
|
69
|
+
|
|
70
|
+
All `GET`, all JSON, all reporting state already held in-process.
|
|
71
|
+
|
|
72
|
+
| endpoint | `data` on success |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `GET /conversation?id=<conversationId>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, timestamp }], nextOffset }` — a conversation's durable transcript in order; `400` if `id` is missing |
|
|
75
|
+
| `GET /conversations?limit=<n>` | `{ conversations: [{ sessionKey, lastTimestamp, preview }] }` — known conversations, most-recently-active first |
|
|
76
|
+
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
77
|
+
| `GET /confirmations` | `{ pending: [{ sessionKey, binary, args, expiresAt }] }` — tokens are deliberately never included |
|
|
78
|
+
| `GET /tool-log` | `{ entries: [...] }` |
|
|
79
|
+
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
80
|
+
| `GET /wiki/list` | `{ files: [...] }` |
|
|
81
|
+
| `GET /wiki/read?path=<vault-path>` | `{ content }` — `400` if `path` is missing |
|
|
82
|
+
| `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
|
|
83
|
+
| `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of the named episodic/semantic collection |
|
|
84
|
+
| `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
|
|
85
|
+
|
|
86
|
+
Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP channel's `Provider` — a thin wrapper that makes the HTTP surface a
|
|
3
|
+
* first-class channel the composition root starts and stops. The conversational
|
|
4
|
+
* mechanics (the `/turn` endpoint, SSE, the confirm interception) live in
|
|
5
|
+
* `http-server.ts`; this file only satisfies the `Provider` contract around it.
|
|
6
|
+
*
|
|
7
|
+
* `start` launches the Bun.serve and resolves immediately — the server runs in
|
|
8
|
+
* the background, so the composition root proceeds. `notify` is a no-op: HTTP is
|
|
9
|
+
* request/response, with no persistent connection to push a proactive message to
|
|
10
|
+
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
|
+
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
|
+
*/
|
|
13
|
+
import { type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
+
import type { Provider, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
|
+
export type HttpProviderDeps = {
|
|
16
|
+
port: number;
|
|
17
|
+
/** Bare-token interception before the model. */
|
|
18
|
+
confirm: ConfirmFn;
|
|
19
|
+
/** Structured resolver for the `/confirm` endpoint's `resolved` flag. */
|
|
20
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
21
|
+
reads?: ChannelHostReads;
|
|
22
|
+
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
23
|
+
corsOrigin?: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP surface's Bun.serve and its one conversational endpoint (4a).
|
|
3
|
+
* `POST /turn` runs a real model turn and streams the result back as
|
|
4
|
+
* Server-Sent Events, mirroring what `terminal-provider.ts` does with console
|
|
5
|
+
* chunks: reasoning/text deltas as they arrive, a `pending` event carrying the
|
|
6
|
+
* token when a turn stages a confirm-required action, a `final` event with the
|
|
7
|
+
* complete answer. A bare confirmation token in `text` is resolved by the
|
|
8
|
+
* injected `confirm` capability, before the model is ever consulted.
|
|
9
|
+
*
|
|
10
|
+
* `handleTurnRequest` is exported (and takes its collaborators as deps — the
|
|
11
|
+
* injected `confirm` and the ephemeral session key) so the streaming behaviour
|
|
12
|
+
* is unit-tested without standing up a socket. The read-only routes (4b) mount
|
|
13
|
+
* alongside `/turn` here, driven by the injected `reads` getters.
|
|
14
|
+
*
|
|
15
|
+
* No auth by design — this surface is opt-in (enabled by listing this channel in
|
|
16
|
+
* `mercury.config.ts`) and must not be reachable from outside the container
|
|
17
|
+
* network, the same posture as the admin panel. Confirm resolution is injected
|
|
18
|
+
* (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
19
|
+
*/
|
|
20
|
+
import { type HandleTurn, type ChannelHostReads, type ConfirmOutcome } from "@mercury-fw/channel-types";
|
|
21
|
+
/** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
22
|
+
export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
|
|
23
|
+
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
24
|
+
export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
|
|
25
|
+
export type TurnRequestDeps = {
|
|
26
|
+
handleTurn: HandleTurn;
|
|
27
|
+
/** Bare-token interception before the model, injected by the core. */
|
|
28
|
+
confirm: ConfirmFn;
|
|
29
|
+
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
30
|
+
corsOrigin?: string;
|
|
31
|
+
/** Test seam for the ephemeral session key when the client sends no conversationId. */
|
|
32
|
+
newSessionKey?: () => string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Serves the OpenAPI document that describes this surface — the single source
|
|
36
|
+
* of truth in `apps/mercury/openapi.yaml`, also rendered as the GitHub Pages
|
|
37
|
+
* API docs. Served raw as `text/yaml` (no YAML parser needed at runtime); most
|
|
38
|
+
* tooling (Redoc, Swagger UI, Postman) reads YAML directly.
|
|
39
|
+
*/
|
|
40
|
+
export declare function openApiResponse(corsOrigin?: string): Response;
|
|
41
|
+
/**
|
|
42
|
+
* Runs one `POST /turn` request and returns an SSE stream Response. The body is
|
|
43
|
+
* `{ text, conversationId? }`; `conversationId` (opaque, client-owned) becomes
|
|
44
|
+
* the session key so a client can continue a conversation — Mercury already
|
|
45
|
+
* keys its histories by session, so many conversations run concurrently, each
|
|
46
|
+
* one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
|
|
47
|
+
* session. Never throws: a bad body is a 400, a mid-turn failure is an `error`
|
|
48
|
+
* event on the stream.
|
|
49
|
+
*/
|
|
50
|
+
export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
|
|
51
|
+
export type ConfirmRequestDeps = {
|
|
52
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
53
|
+
corsOrigin?: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* `POST /confirm { token, conversationId }` — the explicit confirmation
|
|
57
|
+
* endpoint. A nicer contract for a UI than re-POSTing the bare token as `text`
|
|
58
|
+
* to `/turn`, but the exact same mechanism underneath: it resolves the token
|
|
59
|
+
* through the injected `resolveConfirmation`, keyed on
|
|
60
|
+
* `conversationId` as the session. `resolved: true` means the token matched a
|
|
61
|
+
* pending confirmation and its staged action was consumed and run; the `text`
|
|
62
|
+
* then reports whether that execution succeeded. `resolved: false` means the
|
|
63
|
+
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
64
|
+
* even token-shaped). Never touches the model.
|
|
65
|
+
*/
|
|
66
|
+
export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
|
|
67
|
+
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
68
|
+
type ReadRoute = {
|
|
69
|
+
GET: (req: Request) => Response | Promise<Response>;
|
|
70
|
+
OPTIONS: () => Response;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Builds the read-only routes, each carrying CORS headers on its `GET` and a
|
|
74
|
+
* shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
|
|
75
|
+
* reach them. `jsonRoute` wraps a getter into a `GET` that always JSON-encodes
|
|
76
|
+
* with the CORS headers merged in; `badRequest` does the same for a 400.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readRoutes(reads: ChannelHostReads, corsOrigin?: string): Record<string, ReadRoute>;
|
|
79
|
+
export type HttpServerDeps = TurnRequestDeps & {
|
|
80
|
+
port: number;
|
|
81
|
+
reads?: ChannelHostReads;
|
|
82
|
+
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
83
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
84
|
+
};
|
|
85
|
+
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
86
|
+
* when `reads` is supplied. */
|
|
87
|
+
export declare function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.serve>;
|
|
88
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
|
|
5
|
+
* does not import the app. Enabled by listing it in `mercury.config.ts` — there
|
|
6
|
+
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
|
+
* declared, it starts.
|
|
8
|
+
*
|
|
9
|
+
* `build` reads its port and CORS origin from `env` and takes the confirm
|
|
10
|
+
* capabilities and in-process reads from the injected context (dependency
|
|
11
|
+
* inversion). Those two are optional on the contract but required here, so a
|
|
12
|
+
* context missing them throws — the loader isolates it fail-soft.
|
|
13
|
+
*/
|
|
14
|
+
import { type ChannelPlugin } from "@mercury-fw/channel-types";
|
|
15
|
+
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
16
|
+
export declare const httpChannel: ChannelPlugin;
|
package/http-provider.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP channel's `Provider` — a thin wrapper that makes the HTTP surface a
|
|
3
|
+
* first-class channel the composition root starts and stops. The conversational
|
|
4
|
+
* mechanics (the `/turn` endpoint, SSE, the confirm interception) live in
|
|
5
|
+
* `http-server.ts`; this file only satisfies the `Provider` contract around it.
|
|
6
|
+
*
|
|
7
|
+
* `start` launches the Bun.serve and resolves immediately — the server runs in
|
|
8
|
+
* the background, so the composition root proceeds. `notify` is a no-op: HTTP is
|
|
9
|
+
* request/response, with no persistent connection to push a proactive message to
|
|
10
|
+
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
|
+
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
|
+
*/
|
|
13
|
+
import { startHttpServer, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
+
import type { Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
|
+
|
|
16
|
+
export type HttpProviderDeps = {
|
|
17
|
+
port: number;
|
|
18
|
+
/** Bare-token interception before the model. */
|
|
19
|
+
confirm: ConfirmFn;
|
|
20
|
+
/** Structured resolver for the `/confirm` endpoint's `resolved` flag. */
|
|
21
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
22
|
+
reads?: ChannelHostReads;
|
|
23
|
+
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
24
|
+
corsOrigin?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
28
|
+
let server: ReturnType<typeof startHttpServer> | undefined;
|
|
29
|
+
return {
|
|
30
|
+
async start(handleTurn: HandleTurn): Promise<void> {
|
|
31
|
+
server = startHttpServer({
|
|
32
|
+
port: deps.port,
|
|
33
|
+
handleTurn,
|
|
34
|
+
confirm: deps.confirm,
|
|
35
|
+
resolveConfirmation: deps.resolveConfirmation,
|
|
36
|
+
reads: deps.reads,
|
|
37
|
+
corsOrigin: deps.corsOrigin,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
async notify(): Promise<{ sessionKey: string }> {
|
|
41
|
+
// No proactive push channel over HTTP (request/response only).
|
|
42
|
+
return { sessionKey: "" };
|
|
43
|
+
},
|
|
44
|
+
async stop(): Promise<void> {
|
|
45
|
+
server?.stop();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/http-server.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP surface's Bun.serve and its one conversational endpoint (4a).
|
|
3
|
+
* `POST /turn` runs a real model turn and streams the result back as
|
|
4
|
+
* Server-Sent Events, mirroring what `terminal-provider.ts` does with console
|
|
5
|
+
* chunks: reasoning/text deltas as they arrive, a `pending` event carrying the
|
|
6
|
+
* token when a turn stages a confirm-required action, a `final` event with the
|
|
7
|
+
* complete answer. A bare confirmation token in `text` is resolved by the
|
|
8
|
+
* injected `confirm` capability, before the model is ever consulted.
|
|
9
|
+
*
|
|
10
|
+
* `handleTurnRequest` is exported (and takes its collaborators as deps — the
|
|
11
|
+
* injected `confirm` and the ephemeral session key) so the streaming behaviour
|
|
12
|
+
* is unit-tested without standing up a socket. The read-only routes (4b) mount
|
|
13
|
+
* alongside `/turn` here, driven by the injected `reads` getters.
|
|
14
|
+
*
|
|
15
|
+
* No auth by design — this surface is opt-in (enabled by listing this channel in
|
|
16
|
+
* `mercury.config.ts`) and must not be reachable from outside the container
|
|
17
|
+
* network, the same posture as the admin panel. Confirm resolution is injected
|
|
18
|
+
* (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
detectPendingConfirmation,
|
|
22
|
+
PENDING_CONFIRMATION_NOTE,
|
|
23
|
+
type HandleTurn,
|
|
24
|
+
type TurnSink,
|
|
25
|
+
type ChannelHostReads,
|
|
26
|
+
type ConfirmOutcome,
|
|
27
|
+
} from "@mercury-fw/channel-types";
|
|
28
|
+
|
|
29
|
+
/** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
30
|
+
export type ConfirmFn = (token: string, sessionKey: string, userId: string) => Promise<string | null>;
|
|
31
|
+
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
32
|
+
export type ResolveConfirmationFn = (token: string, sessionKey: string, userId: string) => Promise<ConfirmOutcome>;
|
|
33
|
+
|
|
34
|
+
export type TurnRequestDeps = {
|
|
35
|
+
handleTurn: HandleTurn;
|
|
36
|
+
/** Bare-token interception before the model, injected by the core. */
|
|
37
|
+
confirm: ConfirmFn;
|
|
38
|
+
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
39
|
+
corsOrigin?: string;
|
|
40
|
+
/** Test seam for the ephemeral session key when the client sends no conversationId. */
|
|
41
|
+
newSessionKey?: () => string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const SSE_HEADERS = {
|
|
45
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
46
|
+
"cache-control": "no-cache",
|
|
47
|
+
connection: "keep-alive",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The CORS headers echoed on every response so a browser UI served from a
|
|
52
|
+
* different origin (the separate custom-UI project) can call this surface.
|
|
53
|
+
* No credentials are ever used here, so a wildcard origin is safe; a specific
|
|
54
|
+
* origin can still be pinned via `HTTP_SURFACE_CORS_ORIGIN`.
|
|
55
|
+
*/
|
|
56
|
+
function corsHeaders(origin: string): Record<string, string> {
|
|
57
|
+
return {
|
|
58
|
+
"access-control-allow-origin": origin,
|
|
59
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
60
|
+
"access-control-allow-headers": "content-type",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
65
|
+
function preflight(origin: string): Response {
|
|
66
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Serves the OpenAPI document that describes this surface — the single source
|
|
71
|
+
* of truth in `apps/mercury/openapi.yaml`, also rendered as the GitHub Pages
|
|
72
|
+
* API docs. Served raw as `text/yaml` (no YAML parser needed at runtime); most
|
|
73
|
+
* tooling (Redoc, Swagger UI, Postman) reads YAML directly.
|
|
74
|
+
*/
|
|
75
|
+
export function openApiResponse(corsOrigin = "*"): Response {
|
|
76
|
+
const file = Bun.file(new URL("./openapi.yaml", import.meta.url));
|
|
77
|
+
return new Response(file, {
|
|
78
|
+
headers: { "content-type": "text/yaml; charset=utf-8", ...corsHeaders(corsOrigin) },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Runs one `POST /turn` request and returns an SSE stream Response. The body is
|
|
84
|
+
* `{ text, conversationId? }`; `conversationId` (opaque, client-owned) becomes
|
|
85
|
+
* the session key so a client can continue a conversation — Mercury already
|
|
86
|
+
* keys its histories by session, so many conversations run concurrently, each
|
|
87
|
+
* one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
|
|
88
|
+
* session. Never throws: a bad body is a 400, a mid-turn failure is an `error`
|
|
89
|
+
* event on the stream.
|
|
90
|
+
*/
|
|
91
|
+
export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
|
|
92
|
+
const origin = deps.corsOrigin ?? "*";
|
|
93
|
+
const cors = corsHeaders(origin);
|
|
94
|
+
let body: { text?: unknown; conversationId?: unknown };
|
|
95
|
+
try {
|
|
96
|
+
body = (await req.json()) as typeof body;
|
|
97
|
+
} catch {
|
|
98
|
+
return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
|
|
99
|
+
}
|
|
100
|
+
if (typeof body.text !== "string" || body.text.trim().length === 0) {
|
|
101
|
+
return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
|
|
102
|
+
}
|
|
103
|
+
const text = body.text;
|
|
104
|
+
const sessionKey =
|
|
105
|
+
typeof body.conversationId === "string" && body.conversationId.trim().length > 0
|
|
106
|
+
? body.conversationId.trim()
|
|
107
|
+
: (deps.newSessionKey ?? (() => crypto.randomUUID()))();
|
|
108
|
+
|
|
109
|
+
// Aborted when the client disconnects (see the stream's `cancel` below) so
|
|
110
|
+
// the in-flight turn stops instead of running to completion — the "stop"
|
|
111
|
+
// affordance for a diverging generation.
|
|
112
|
+
const abort = new AbortController();
|
|
113
|
+
|
|
114
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
115
|
+
async start(controller) {
|
|
116
|
+
const encoder = new TextEncoder();
|
|
117
|
+
// Once the client is gone the controller is closed; enqueuing then throws.
|
|
118
|
+
// Swallow it — there's no consumer left to receive the event anyway.
|
|
119
|
+
const send = (event: string, data: unknown) => {
|
|
120
|
+
try {
|
|
121
|
+
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
122
|
+
} catch {
|
|
123
|
+
/* stream already closed/canceled */
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const close = () => {
|
|
127
|
+
try {
|
|
128
|
+
controller.close();
|
|
129
|
+
} catch {
|
|
130
|
+
/* already closed */
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Same deterministic interception as every other channel — a
|
|
135
|
+
// previously-approved mutation must never depend on the model.
|
|
136
|
+
const confirmReply = await deps.confirm(text, sessionKey, sessionKey);
|
|
137
|
+
if (confirmReply !== null) {
|
|
138
|
+
send("final", { text: confirmReply });
|
|
139
|
+
close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sink: TurnSink = {
|
|
144
|
+
onToolStart: (label, detail, toolCallId) => send("tool", { label, detail, toolCallId }),
|
|
145
|
+
onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
|
|
146
|
+
onTextChunk: (chunk) => {
|
|
147
|
+
if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
|
|
148
|
+
},
|
|
149
|
+
onReasoningChunk: (chunk, id) => send("reasoning", { chunk, id }),
|
|
150
|
+
onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
|
|
151
|
+
onStep: (step) => {
|
|
152
|
+
const pending = detectPendingConfirmation(step);
|
|
153
|
+
if (pending) send("pending", { command: pending.summary, token: pending.token });
|
|
154
|
+
},
|
|
155
|
+
onUsage: () => {},
|
|
156
|
+
finalize: async (finalText) => send("final", { text: finalText }),
|
|
157
|
+
dispose: () => {},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await deps.handleTurn(
|
|
162
|
+
{
|
|
163
|
+
channel: "http",
|
|
164
|
+
multiUser: false,
|
|
165
|
+
text,
|
|
166
|
+
sessionKey,
|
|
167
|
+
wikiUserId: sessionKey,
|
|
168
|
+
logPrefix: `[http:${sessionKey}] `,
|
|
169
|
+
abortSignal: abort.signal,
|
|
170
|
+
},
|
|
171
|
+
sink,
|
|
172
|
+
);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// A client-initiated cancellation is a clean stop, not a failure —
|
|
175
|
+
// don't surface it as an `error` event (and there's no client left).
|
|
176
|
+
if (!abort.signal.aborted) {
|
|
177
|
+
send("error", { message: err instanceof Error ? err.message : String(err) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
close();
|
|
181
|
+
},
|
|
182
|
+
// Fires when the client disconnects (closes the EventSource / aborts the
|
|
183
|
+
// fetch): abort the in-flight turn so generation stops promptly.
|
|
184
|
+
cancel() {
|
|
185
|
+
abort.abort();
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return new Response(stream, { headers: { ...SSE_HEADERS, ...cors } });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type ConfirmRequestDeps = {
|
|
193
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
194
|
+
corsOrigin?: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `POST /confirm { token, conversationId }` — the explicit confirmation
|
|
199
|
+
* endpoint. A nicer contract for a UI than re-POSTing the bare token as `text`
|
|
200
|
+
* to `/turn`, but the exact same mechanism underneath: it resolves the token
|
|
201
|
+
* through the injected `resolveConfirmation`, keyed on
|
|
202
|
+
* `conversationId` as the session. `resolved: true` means the token matched a
|
|
203
|
+
* pending confirmation and its staged action was consumed and run; the `text`
|
|
204
|
+
* then reports whether that execution succeeded. `resolved: false` means the
|
|
205
|
+
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
206
|
+
* even token-shaped). Never touches the model.
|
|
207
|
+
*/
|
|
208
|
+
export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
|
|
209
|
+
const cors = corsHeaders(deps.corsOrigin ?? "*");
|
|
210
|
+
let body: { token?: unknown; conversationId?: unknown };
|
|
211
|
+
try {
|
|
212
|
+
body = (await req.json()) as typeof body;
|
|
213
|
+
} catch {
|
|
214
|
+
return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
|
|
215
|
+
}
|
|
216
|
+
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
217
|
+
const sessionKey = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
|
|
218
|
+
if (token.length === 0 || sessionKey.length === 0) {
|
|
219
|
+
return Response.json({ ok: false, error: "missing token or conversationId" }, { status: 400, headers: cors });
|
|
220
|
+
}
|
|
221
|
+
const outcome = await deps.resolveConfirmation(token, sessionKey, sessionKey);
|
|
222
|
+
switch (outcome.status) {
|
|
223
|
+
case "not-a-token":
|
|
224
|
+
case "not-found":
|
|
225
|
+
return Response.json({ ok: true, resolved: false }, { headers: cors });
|
|
226
|
+
case "ok":
|
|
227
|
+
return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
|
|
228
|
+
case "failed":
|
|
229
|
+
return Response.json({ ok: true, resolved: true, text: `Confermato, ma l'esecuzione è fallita: ${outcome.error}` }, { headers: cors });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
234
|
+
type ReadRoute = {
|
|
235
|
+
GET: (req: Request) => Response | Promise<Response>;
|
|
236
|
+
OPTIONS: () => Response;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Builds the read-only routes, each carrying CORS headers on its `GET` and a
|
|
241
|
+
* shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
|
|
242
|
+
* reach them. `jsonRoute` wraps a getter into a `GET` that always JSON-encodes
|
|
243
|
+
* with the CORS headers merged in; `badRequest` does the same for a 400.
|
|
244
|
+
*/
|
|
245
|
+
export function readRoutes(reads: ChannelHostReads, corsOrigin = "*"): Record<string, ReadRoute> {
|
|
246
|
+
const cors = corsHeaders(corsOrigin);
|
|
247
|
+
const json = (payload: object, status = 200): Response =>
|
|
248
|
+
Response.json(payload, { status, headers: cors });
|
|
249
|
+
const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
|
|
250
|
+
const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
|
|
251
|
+
const options = () => preflight(corsOrigin);
|
|
252
|
+
const route = (GET: ReadRoute["GET"]): ReadRoute => ({ GET, OPTIONS: options });
|
|
253
|
+
return {
|
|
254
|
+
"/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
|
|
255
|
+
"/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
|
|
256
|
+
"/conversation": route(async (req) => {
|
|
257
|
+
const url = new URL(req.url);
|
|
258
|
+
const id = url.searchParams.get("id");
|
|
259
|
+
if (!id) return badRequest("missing ?id");
|
|
260
|
+
const limit = Number(url.searchParams.get("limit") ?? "200");
|
|
261
|
+
const offset = url.searchParams.get("offset") ?? undefined;
|
|
262
|
+
return json({ ok: true, ...(await reads.conversation(id, limit, offset) as object) });
|
|
263
|
+
}),
|
|
264
|
+
"/conversations": route(async (req) => {
|
|
265
|
+
const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
|
|
266
|
+
return json({ ok: true, ...(await reads.conversations(limit) as object) });
|
|
267
|
+
}),
|
|
268
|
+
"/tool-log": route(() => json({ ok: true, entries: reads.toolLog() })),
|
|
269
|
+
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
270
|
+
"/wiki/list": route(async () => json({ ok: true, files: await reads.wikiList() })),
|
|
271
|
+
"/wiki/read": route(async (req) => {
|
|
272
|
+
const path = requireParam(req, "path");
|
|
273
|
+
if (!path) return badRequest("missing ?path");
|
|
274
|
+
return json({ ok: true, content: await reads.wikiRead(path) });
|
|
275
|
+
}),
|
|
276
|
+
"/wiki/grep": route(async (req) => {
|
|
277
|
+
const pattern = requireParam(req, "pattern");
|
|
278
|
+
if (!pattern) return badRequest("missing ?pattern");
|
|
279
|
+
return json({ ok: true, matches: await reads.wikiGrep(pattern) });
|
|
280
|
+
}),
|
|
281
|
+
"/memory/scroll": route(async (req) => {
|
|
282
|
+
const url = new URL(req.url);
|
|
283
|
+
const collection = url.searchParams.get("collection");
|
|
284
|
+
if (!collection) return badRequest("missing ?collection");
|
|
285
|
+
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
286
|
+
const offset = url.searchParams.get("offset") ?? undefined;
|
|
287
|
+
return json({ ok: true, ...(await reads.memoryScroll(collection, limit, offset) as object) });
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type HttpServerDeps = TurnRequestDeps & {
|
|
293
|
+
port: number;
|
|
294
|
+
reads?: ChannelHostReads;
|
|
295
|
+
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
296
|
+
resolveConfirmation: ResolveConfirmationFn;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
300
|
+
* when `reads` is supplied. */
|
|
301
|
+
export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.serve> {
|
|
302
|
+
const origin = deps.corsOrigin ?? "*";
|
|
303
|
+
return Bun.serve({
|
|
304
|
+
port: deps.port,
|
|
305
|
+
// Bind all interfaces inside the container so a published port can reach it
|
|
306
|
+
// (Bun defaults to loopback-only, unreachable through Docker's port proxy).
|
|
307
|
+
// This is not "externally reachable": the container network still isolates
|
|
308
|
+
// it — production simply does not publish the port (see docker-compose).
|
|
309
|
+
hostname: "0.0.0.0",
|
|
310
|
+
// Disable the idle timeout (Bun defaults to 10s): an SSE turn can go quiet
|
|
311
|
+
// for longer than that between events — the model loading, a slow CLI
|
|
312
|
+
// subprocess — and a timeout there would reset the stream mid-turn.
|
|
313
|
+
idleTimeout: 0,
|
|
314
|
+
routes: {
|
|
315
|
+
"/turn": { POST: (req) => handleTurnRequest(req, deps), OPTIONS: () => preflight(origin) },
|
|
316
|
+
"/confirm": {
|
|
317
|
+
POST: (req) => handleConfirmRequest(req, { resolveConfirmation: deps.resolveConfirmation, corsOrigin: origin }),
|
|
318
|
+
OPTIONS: () => preflight(origin),
|
|
319
|
+
},
|
|
320
|
+
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
321
|
+
...(deps.reads ? readRoutes(deps.reads, origin) : {}),
|
|
322
|
+
},
|
|
323
|
+
error: (err) =>
|
|
324
|
+
Response.json(
|
|
325
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
326
|
+
{ status: 500, headers: corsHeaders(origin) },
|
|
327
|
+
),
|
|
328
|
+
});
|
|
329
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
|
|
5
|
+
* does not import the app. Enabled by listing it in `mercury.config.ts` — there
|
|
6
|
+
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
|
+
* declared, it starts.
|
|
8
|
+
*
|
|
9
|
+
* `build` reads its port and CORS origin from `env` and takes the confirm
|
|
10
|
+
* capabilities and in-process reads from the injected context (dependency
|
|
11
|
+
* inversion). Those two are optional on the contract but required here, so a
|
|
12
|
+
* context missing them throws — the loader isolates it fail-soft.
|
|
13
|
+
*/
|
|
14
|
+
import { CHANNEL_API_VERSION, type ChannelPlugin } from "@mercury-fw/channel-types";
|
|
15
|
+
import { createHttpProvider } from "./http-provider.ts";
|
|
16
|
+
|
|
17
|
+
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
18
|
+
|
|
19
|
+
export const httpChannel: ChannelPlugin = {
|
|
20
|
+
apiVersion: CHANNEL_API_VERSION,
|
|
21
|
+
name: "http",
|
|
22
|
+
build: (ctx) => {
|
|
23
|
+
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
24
|
+
throw new Error("http channel requires the resolveConfirmation and reads capabilities on the runtime context");
|
|
25
|
+
}
|
|
26
|
+
return createHttpProvider({
|
|
27
|
+
port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
|
|
28
|
+
corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
|
|
29
|
+
confirm: ctx.confirm,
|
|
30
|
+
resolveConfirmation: ctx.resolveConfirmation,
|
|
31
|
+
reads: ctx.reads,
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
package/openapi.yaml
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
openapi: 3.1.0
|
|
2
|
+
info:
|
|
3
|
+
title: Mercury HTTP surface
|
|
4
|
+
version: 0.17.1
|
|
5
|
+
description: >
|
|
6
|
+
The HTTP surface of Mercury — the intended primary channel for a
|
|
7
|
+
custom web UI. It is enabled by declaring the `http` channel in
|
|
8
|
+
`mercury.config.ts` and, by design,
|
|
9
|
+
carries **no authentication**: it must not be published outside the trusted
|
|
10
|
+
container network (production simply does not expose the port). CORS is
|
|
11
|
+
enabled on every response (`Access-Control-Allow-Origin`, configurable via
|
|
12
|
+
`HTTP_SURFACE_CORS_ORIGIN`, default `*`) and every route answers an `OPTIONS`
|
|
13
|
+
preflight, so a browser UI on another origin can call it.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Conversations
|
|
17
|
+
|
|
18
|
+
A conversation is identified by an opaque, client-owned `conversationId`,
|
|
19
|
+
which becomes Mercury's session key. Omitting it on `POST /turn` starts a
|
|
20
|
+
fresh ephemeral conversation. The durable transcript of a conversation is
|
|
21
|
+
readable via `GET /conversation`, and the list of known conversations via
|
|
22
|
+
`GET /conversations` (both backed by the verbatim archive; they degrade to
|
|
23
|
+
empty when the vector store is unreachable).
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Streaming and cancellation
|
|
27
|
+
|
|
28
|
+
`POST /turn` streams the answer as Server-Sent Events: reasoning and text
|
|
29
|
+
arrive as **incremental deltas** (never a single finished block), followed by
|
|
30
|
+
an authoritative `final` event. To cancel a turn whose generation is
|
|
31
|
+
diverging, the client simply closes the connection — Mercury aborts the
|
|
32
|
+
in-flight generation.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## Confirmation
|
|
36
|
+
|
|
37
|
+
An irreversible action is never executed on the model's say-so: the turn
|
|
38
|
+
emits a `pending` event carrying a one-time `token`. The client confirms by
|
|
39
|
+
sending that token back — either as the `text` of a follow-up `POST /turn`,
|
|
40
|
+
or via `POST /confirm`.
|
|
41
|
+
servers:
|
|
42
|
+
- url: http://localhost:4100
|
|
43
|
+
description: Local/dev surface (HTTP_SURFACE_PORT, default 4100)
|
|
44
|
+
paths:
|
|
45
|
+
/turn:
|
|
46
|
+
post:
|
|
47
|
+
summary: Run a conversational turn (SSE stream)
|
|
48
|
+
description: >
|
|
49
|
+
Runs one model turn and streams the result as `text/event-stream`. If
|
|
50
|
+
`text` is a pending confirmation token it is resolved deterministically
|
|
51
|
+
without ever consulting the model. Close the connection to cancel an
|
|
52
|
+
in-flight turn.
|
|
53
|
+
requestBody:
|
|
54
|
+
required: true
|
|
55
|
+
content:
|
|
56
|
+
application/json:
|
|
57
|
+
schema:
|
|
58
|
+
$ref: "#/components/schemas/TurnRequest"
|
|
59
|
+
responses:
|
|
60
|
+
"200":
|
|
61
|
+
description: >
|
|
62
|
+
An SSE stream. Each event is one of the types in `TurnEvent`:
|
|
63
|
+
`reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
|
|
64
|
+
`tool_finish` (status of a tool call), `pending` (a staged
|
|
65
|
+
confirm-required action, with its command and token), `final` (the
|
|
66
|
+
authoritative complete answer), and `error`.
|
|
67
|
+
content:
|
|
68
|
+
text/event-stream:
|
|
69
|
+
schema:
|
|
70
|
+
$ref: "#/components/schemas/TurnEvent"
|
|
71
|
+
"400":
|
|
72
|
+
description: Missing/invalid body.
|
|
73
|
+
content:
|
|
74
|
+
application/json:
|
|
75
|
+
schema:
|
|
76
|
+
$ref: "#/components/schemas/Error"
|
|
77
|
+
/confirm:
|
|
78
|
+
post:
|
|
79
|
+
summary: Confirm a staged irreversible action
|
|
80
|
+
description: >
|
|
81
|
+
Runs a one-time confirmation token through the same mechanism the
|
|
82
|
+
bare-token-in-`text` path uses, keyed on `conversationId`. Never touches
|
|
83
|
+
the model.
|
|
84
|
+
requestBody:
|
|
85
|
+
required: true
|
|
86
|
+
content:
|
|
87
|
+
application/json:
|
|
88
|
+
schema:
|
|
89
|
+
$ref: "#/components/schemas/ConfirmRequest"
|
|
90
|
+
responses:
|
|
91
|
+
"200":
|
|
92
|
+
description: >
|
|
93
|
+
`resolved: true` (with the reply `text`) when the token was a pending
|
|
94
|
+
confirmation, `resolved: false` when it was not (unknown, expired, or
|
|
95
|
+
already used).
|
|
96
|
+
content:
|
|
97
|
+
application/json:
|
|
98
|
+
schema:
|
|
99
|
+
$ref: "#/components/schemas/ConfirmResult"
|
|
100
|
+
"400":
|
|
101
|
+
description: Missing token or conversationId.
|
|
102
|
+
content:
|
|
103
|
+
application/json:
|
|
104
|
+
schema:
|
|
105
|
+
$ref: "#/components/schemas/Error"
|
|
106
|
+
/conversation:
|
|
107
|
+
get:
|
|
108
|
+
summary: A conversation's durable transcript
|
|
109
|
+
parameters:
|
|
110
|
+
- name: id
|
|
111
|
+
in: query
|
|
112
|
+
required: true
|
|
113
|
+
description: The conversationId (session key) whose messages to return.
|
|
114
|
+
schema:
|
|
115
|
+
type: string
|
|
116
|
+
- name: limit
|
|
117
|
+
in: query
|
|
118
|
+
required: false
|
|
119
|
+
schema:
|
|
120
|
+
type: integer
|
|
121
|
+
default: 200
|
|
122
|
+
- name: offset
|
|
123
|
+
in: query
|
|
124
|
+
required: false
|
|
125
|
+
description: Opaque pagination cursor from a previous page's `nextOffset`.
|
|
126
|
+
schema:
|
|
127
|
+
type: string
|
|
128
|
+
responses:
|
|
129
|
+
"200":
|
|
130
|
+
description: The conversation's messages in chronological order.
|
|
131
|
+
content:
|
|
132
|
+
application/json:
|
|
133
|
+
schema:
|
|
134
|
+
$ref: "#/components/schemas/ConversationPage"
|
|
135
|
+
"400":
|
|
136
|
+
description: Missing `?id`.
|
|
137
|
+
content:
|
|
138
|
+
application/json:
|
|
139
|
+
schema:
|
|
140
|
+
$ref: "#/components/schemas/Error"
|
|
141
|
+
/conversations:
|
|
142
|
+
get:
|
|
143
|
+
summary: List known conversations (most-recently-active first)
|
|
144
|
+
parameters:
|
|
145
|
+
- name: limit
|
|
146
|
+
in: query
|
|
147
|
+
required: false
|
|
148
|
+
schema:
|
|
149
|
+
type: integer
|
|
150
|
+
default: 50
|
|
151
|
+
responses:
|
|
152
|
+
"200":
|
|
153
|
+
description: The conversation list.
|
|
154
|
+
content:
|
|
155
|
+
application/json:
|
|
156
|
+
schema:
|
|
157
|
+
$ref: "#/components/schemas/ConversationList"
|
|
158
|
+
/health:
|
|
159
|
+
get:
|
|
160
|
+
summary: Liveness/health of Mercury and its dependencies
|
|
161
|
+
responses:
|
|
162
|
+
"200":
|
|
163
|
+
description: Health snapshot.
|
|
164
|
+
content:
|
|
165
|
+
application/json:
|
|
166
|
+
schema:
|
|
167
|
+
type: object
|
|
168
|
+
/manifest:
|
|
169
|
+
get:
|
|
170
|
+
summary: The loaded plugins, tools and skills
|
|
171
|
+
responses:
|
|
172
|
+
"200":
|
|
173
|
+
description: Plugin manifest.
|
|
174
|
+
content:
|
|
175
|
+
application/json:
|
|
176
|
+
schema:
|
|
177
|
+
type: object
|
|
178
|
+
/confirmations:
|
|
179
|
+
get:
|
|
180
|
+
summary: Currently pending confirmations (tokens redacted)
|
|
181
|
+
responses:
|
|
182
|
+
"200":
|
|
183
|
+
description: Pending confirmations.
|
|
184
|
+
content:
|
|
185
|
+
application/json:
|
|
186
|
+
schema:
|
|
187
|
+
type: object
|
|
188
|
+
/tool-log:
|
|
189
|
+
get:
|
|
190
|
+
summary: Recent tool-call log entries
|
|
191
|
+
responses:
|
|
192
|
+
"200":
|
|
193
|
+
description: Tool log.
|
|
194
|
+
content:
|
|
195
|
+
application/json:
|
|
196
|
+
schema:
|
|
197
|
+
type: object
|
|
198
|
+
/wiki/list:
|
|
199
|
+
get:
|
|
200
|
+
summary: List the wiki vault's files
|
|
201
|
+
responses:
|
|
202
|
+
"200":
|
|
203
|
+
description: File list.
|
|
204
|
+
content:
|
|
205
|
+
application/json:
|
|
206
|
+
schema:
|
|
207
|
+
type: object
|
|
208
|
+
/wiki/read:
|
|
209
|
+
get:
|
|
210
|
+
summary: Read one wiki vault file
|
|
211
|
+
parameters:
|
|
212
|
+
- name: path
|
|
213
|
+
in: query
|
|
214
|
+
required: true
|
|
215
|
+
schema:
|
|
216
|
+
type: string
|
|
217
|
+
responses:
|
|
218
|
+
"200":
|
|
219
|
+
description: File content.
|
|
220
|
+
content:
|
|
221
|
+
application/json:
|
|
222
|
+
schema:
|
|
223
|
+
type: object
|
|
224
|
+
"400":
|
|
225
|
+
description: Missing `?path`.
|
|
226
|
+
content:
|
|
227
|
+
application/json:
|
|
228
|
+
schema:
|
|
229
|
+
$ref: "#/components/schemas/Error"
|
|
230
|
+
/wiki/grep:
|
|
231
|
+
get:
|
|
232
|
+
summary: Grep the wiki vault
|
|
233
|
+
parameters:
|
|
234
|
+
- name: pattern
|
|
235
|
+
in: query
|
|
236
|
+
required: true
|
|
237
|
+
schema:
|
|
238
|
+
type: string
|
|
239
|
+
responses:
|
|
240
|
+
"200":
|
|
241
|
+
description: Matches.
|
|
242
|
+
content:
|
|
243
|
+
application/json:
|
|
244
|
+
schema:
|
|
245
|
+
type: object
|
|
246
|
+
"400":
|
|
247
|
+
description: Missing `?pattern`.
|
|
248
|
+
content:
|
|
249
|
+
application/json:
|
|
250
|
+
schema:
|
|
251
|
+
$ref: "#/components/schemas/Error"
|
|
252
|
+
/memory/scroll:
|
|
253
|
+
get:
|
|
254
|
+
summary: Scroll a Qdrant memory collection
|
|
255
|
+
parameters:
|
|
256
|
+
- name: collection
|
|
257
|
+
in: query
|
|
258
|
+
required: true
|
|
259
|
+
schema:
|
|
260
|
+
type: string
|
|
261
|
+
- name: limit
|
|
262
|
+
in: query
|
|
263
|
+
required: false
|
|
264
|
+
schema:
|
|
265
|
+
type: integer
|
|
266
|
+
default: 50
|
|
267
|
+
- name: offset
|
|
268
|
+
in: query
|
|
269
|
+
required: false
|
|
270
|
+
schema:
|
|
271
|
+
type: string
|
|
272
|
+
responses:
|
|
273
|
+
"200":
|
|
274
|
+
description: A page of points.
|
|
275
|
+
content:
|
|
276
|
+
application/json:
|
|
277
|
+
schema:
|
|
278
|
+
type: object
|
|
279
|
+
"400":
|
|
280
|
+
description: Missing `?collection`.
|
|
281
|
+
content:
|
|
282
|
+
application/json:
|
|
283
|
+
schema:
|
|
284
|
+
$ref: "#/components/schemas/Error"
|
|
285
|
+
/openapi.yaml:
|
|
286
|
+
get:
|
|
287
|
+
summary: This OpenAPI document
|
|
288
|
+
responses:
|
|
289
|
+
"200":
|
|
290
|
+
description: The raw OpenAPI YAML.
|
|
291
|
+
content:
|
|
292
|
+
text/yaml:
|
|
293
|
+
schema:
|
|
294
|
+
type: string
|
|
295
|
+
components:
|
|
296
|
+
schemas:
|
|
297
|
+
TurnRequest:
|
|
298
|
+
type: object
|
|
299
|
+
required: [text]
|
|
300
|
+
properties:
|
|
301
|
+
text:
|
|
302
|
+
type: string
|
|
303
|
+
description: The user's message, or a bare confirmation token.
|
|
304
|
+
conversationId:
|
|
305
|
+
type: string
|
|
306
|
+
description: Opaque, client-owned. Omit to start a fresh ephemeral conversation.
|
|
307
|
+
TurnEvent:
|
|
308
|
+
type: object
|
|
309
|
+
description: The shape carried by each SSE `data:` line, per event type.
|
|
310
|
+
properties:
|
|
311
|
+
chunk:
|
|
312
|
+
type: string
|
|
313
|
+
description: On `text`/`reasoning` — an incremental delta.
|
|
314
|
+
id:
|
|
315
|
+
type: string
|
|
316
|
+
description: On `reasoning`/`reasoning_end` — the reasoning block id.
|
|
317
|
+
failed:
|
|
318
|
+
type: boolean
|
|
319
|
+
description: On `reasoning_end`.
|
|
320
|
+
label:
|
|
321
|
+
type: string
|
|
322
|
+
description: On `tool` — a human status label.
|
|
323
|
+
detail:
|
|
324
|
+
type: string
|
|
325
|
+
description: On `tool` — optional detail.
|
|
326
|
+
toolCallId:
|
|
327
|
+
type: string
|
|
328
|
+
description: On `tool`/`tool_finish`.
|
|
329
|
+
outcome:
|
|
330
|
+
type: string
|
|
331
|
+
description: On `tool_finish` — success/failed.
|
|
332
|
+
command:
|
|
333
|
+
type: string
|
|
334
|
+
description: On `pending` — the staged action's summary.
|
|
335
|
+
token:
|
|
336
|
+
type: string
|
|
337
|
+
description: On `pending` — the one-time confirmation token.
|
|
338
|
+
text:
|
|
339
|
+
type: string
|
|
340
|
+
description: On `final` — the authoritative complete answer.
|
|
341
|
+
message:
|
|
342
|
+
type: string
|
|
343
|
+
description: On `error` — the failure message.
|
|
344
|
+
ConfirmRequest:
|
|
345
|
+
type: object
|
|
346
|
+
required: [token, conversationId]
|
|
347
|
+
properties:
|
|
348
|
+
token:
|
|
349
|
+
type: string
|
|
350
|
+
conversationId:
|
|
351
|
+
type: string
|
|
352
|
+
ConfirmResult:
|
|
353
|
+
type: object
|
|
354
|
+
required: [ok, resolved]
|
|
355
|
+
properties:
|
|
356
|
+
ok:
|
|
357
|
+
type: boolean
|
|
358
|
+
resolved:
|
|
359
|
+
type: boolean
|
|
360
|
+
text:
|
|
361
|
+
type: string
|
|
362
|
+
description: The confirmation reply, present when `resolved` is true.
|
|
363
|
+
Message:
|
|
364
|
+
type: object
|
|
365
|
+
required: [role, content, timestamp]
|
|
366
|
+
properties:
|
|
367
|
+
role:
|
|
368
|
+
type: string
|
|
369
|
+
enum: [user, assistant]
|
|
370
|
+
content:
|
|
371
|
+
type: string
|
|
372
|
+
timestamp:
|
|
373
|
+
type: string
|
|
374
|
+
description: ISO 8601, millisecond precision — the durable ordering key.
|
|
375
|
+
ConversationPage:
|
|
376
|
+
type: object
|
|
377
|
+
required: [ok, messages]
|
|
378
|
+
properties:
|
|
379
|
+
ok:
|
|
380
|
+
type: boolean
|
|
381
|
+
messages:
|
|
382
|
+
type: array
|
|
383
|
+
items:
|
|
384
|
+
$ref: "#/components/schemas/Message"
|
|
385
|
+
nextOffset:
|
|
386
|
+
description: Opaque cursor for the next page, or null when exhausted.
|
|
387
|
+
oneOf:
|
|
388
|
+
- type: string
|
|
389
|
+
- type: "null"
|
|
390
|
+
ConversationSummary:
|
|
391
|
+
type: object
|
|
392
|
+
required: [sessionKey, lastTimestamp, preview]
|
|
393
|
+
properties:
|
|
394
|
+
sessionKey:
|
|
395
|
+
type: string
|
|
396
|
+
lastTimestamp:
|
|
397
|
+
type: string
|
|
398
|
+
preview:
|
|
399
|
+
type: string
|
|
400
|
+
ConversationList:
|
|
401
|
+
type: object
|
|
402
|
+
required: [ok, conversations]
|
|
403
|
+
properties:
|
|
404
|
+
ok:
|
|
405
|
+
type: boolean
|
|
406
|
+
conversations:
|
|
407
|
+
type: array
|
|
408
|
+
items:
|
|
409
|
+
$ref: "#/components/schemas/ConversationSummary"
|
|
410
|
+
Error:
|
|
411
|
+
type: object
|
|
412
|
+
required: [ok, error]
|
|
413
|
+
properties:
|
|
414
|
+
ok:
|
|
415
|
+
type: boolean
|
|
416
|
+
enum: [false]
|
|
417
|
+
error:
|
|
418
|
+
type: string
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mercury-fw/channel-http",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lucabro81/mercury-fw.git",
|
|
8
|
+
"directory": "packages/channels/channel-http"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"*.ts",
|
|
12
|
+
"dist",
|
|
13
|
+
"openapi.yaml",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"!**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.24.0 <1.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@mercury-fw/channel-types": "0.25.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.25.0",
|
|
26
|
+
"@mercury-fw/typescript-config": "*",
|
|
27
|
+
"@types/bun": "^1.4.0",
|
|
28
|
+
"typescript": "^6.0.3"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"mercury-fw-source": "./index.ts",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./index.ts"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|