@mercury-fw/channel-http 0.8.4 → 0.9.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 +9 -0
- package/README.md +4 -2
- package/dist/http-server.d.ts +5 -0
- package/http-server.ts +26 -6
- package/openapi.yaml +37 -4
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b7438a6: - The assistant remembers how each person wants to be called: they say it in conversation and the model saves it with the new `set_preferred_name` tool. It's a setting kept in `.mercury/profiles.json` on the vault's volume, not a wiki note, and every one-to-one turn tells the model the name.
|
|
8
|
+
- A turn can be an opening (`opening: true`, with no text): the assistant speaks first, but only to someone whose name it doesn't know, introducing itself and asking how to call them. For anyone else, and in a shared space, it says nothing and keeps nothing.
|
|
9
|
+
- `ChannelHostReads.me` says how the caller asked to be called, or `null`.
|
|
10
|
+
- HTTP: `POST /turn` takes `{ opening: true, conversationId }`, and `GET /me` answers `{ name }`. A UI asks for an opening right after a sign-in, when the name is `null`. The channel now needs channel-types 0.49.0 or later.
|
|
11
|
+
|
|
3
12
|
## 0.8.4
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -45,10 +45,11 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
|
|
|
45
45
|
|
|
46
46
|
| field | type | required | description |
|
|
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
|
+
| `text` | string | yes, unless `opening` | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model. |
|
|
49
|
+
| `opening` | `true` | no | The assistant speaks first, before the person writes anything: what a UI asks right after someone signs in, when `GET /me` says the assistant doesn't know their name. It introduces itself and asks how to call them, and saves the answer once they give it. For someone it already knows, the stream is a single `final` with an empty text. Needs `conversationId`; `text` is ignored. |
|
|
49
50
|
| `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. |
|
|
50
51
|
|
|
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.
|
|
52
|
+
**Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, an opening has no `conversationId`, the body isn't JSON, or `conversationId` has other characters than the ones above.
|
|
52
53
|
|
|
53
54
|
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.
|
|
54
55
|
|
|
@@ -97,6 +98,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
|
|
|
97
98
|
| `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
|
|
98
99
|
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
99
100
|
| `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
|
|
101
|
+
| `GET /me` | `{ name }` — how you asked the assistant to call you, `null` when you never said; not there on a core older than this channel |
|
|
100
102
|
| `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
|
|
101
103
|
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
102
104
|
| `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
|
package/dist/http-server.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export declare function openApiResponse(corsOrigin?: string): Response;
|
|
|
60
60
|
* one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
|
|
61
61
|
* session. Never throws: a refused caller is a 401, a bad body a 400, a
|
|
62
62
|
* mid-turn failure an `error` event on the stream.
|
|
63
|
+
*
|
|
64
|
+
* `{ opening: true, conversationId }`, with no `text`, asks the assistant to
|
|
65
|
+
* speak first in that conversation (right after someone signs in): the core
|
|
66
|
+
* decides whether it has anything to say, and a `final` with an empty text
|
|
67
|
+
* means it hasn't.
|
|
63
68
|
*/
|
|
64
69
|
export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
|
|
65
70
|
export type ConfirmRequestDeps = {
|
package/http-server.ts
CHANGED
|
@@ -147,24 +147,33 @@ export function openApiResponse(corsOrigin = "*"): Response {
|
|
|
147
147
|
* one-on-one (`multiUser: false`). No `conversationId` means a fresh ephemeral
|
|
148
148
|
* session. Never throws: a refused caller is a 401, a bad body a 400, a
|
|
149
149
|
* mid-turn failure an `error` event on the stream.
|
|
150
|
+
*
|
|
151
|
+
* `{ opening: true, conversationId }`, with no `text`, asks the assistant to
|
|
152
|
+
* speak first in that conversation (right after someone signs in): the core
|
|
153
|
+
* decides whether it has anything to say, and a `final` with an empty text
|
|
154
|
+
* means it hasn't.
|
|
150
155
|
*/
|
|
151
156
|
export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
|
|
152
157
|
const origin = deps.corsOrigin ?? "*";
|
|
153
158
|
const cors = corsHeaders(origin);
|
|
154
159
|
const principal = await caller(req, deps, origin);
|
|
155
160
|
if (principal instanceof Response) return principal;
|
|
156
|
-
let body: { text?: unknown; conversationId?: unknown };
|
|
161
|
+
let body: { text?: unknown; conversationId?: unknown; opening?: unknown };
|
|
157
162
|
try {
|
|
158
163
|
body = (await req.json()) as typeof body;
|
|
159
164
|
} catch {
|
|
160
165
|
return Response.json({ ok: false, error: "body must be JSON" }, { status: 400, headers: cors });
|
|
161
166
|
}
|
|
162
|
-
|
|
163
|
-
return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
|
|
164
|
-
}
|
|
165
|
-
const text = body.text;
|
|
167
|
+
const opening = body.opening === true;
|
|
166
168
|
// A conversation the client names is one it reopens; an ephemeral one never comes back.
|
|
167
169
|
const named = typeof body.conversationId === "string" && body.conversationId.trim().length > 0;
|
|
170
|
+
if (opening && !named) {
|
|
171
|
+
return Response.json({ ok: false, error: "an opening needs a conversationId" }, { status: 400, headers: cors });
|
|
172
|
+
}
|
|
173
|
+
if (!opening && (typeof body.text !== "string" || body.text.trim().length === 0)) {
|
|
174
|
+
return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
|
|
175
|
+
}
|
|
176
|
+
const text = opening ? "" : (body.text as string);
|
|
168
177
|
const conversationId = named ? (body.conversationId as string).trim() : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
|
|
169
178
|
if (!CONVERSATION_ID.test(conversationId)) {
|
|
170
179
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
@@ -198,7 +207,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
198
207
|
|
|
199
208
|
// Same deterministic interception as every other channel — a
|
|
200
209
|
// previously-approved mutation must never depend on the model.
|
|
201
|
-
const confirmReply = await deps.confirm(text, sessionKey, principal);
|
|
210
|
+
const confirmReply = opening ? null : await deps.confirm(text, sessionKey, principal);
|
|
202
211
|
if (confirmReply !== null) {
|
|
203
212
|
send("final", { text: confirmReply });
|
|
204
213
|
close();
|
|
@@ -236,6 +245,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
236
245
|
logPrefix: `[http:${forLog(sessionKey)}] `,
|
|
237
246
|
abortSignal: abort.signal,
|
|
238
247
|
...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
|
|
248
|
+
...(opening ? { opening: true as const } : {}),
|
|
239
249
|
},
|
|
240
250
|
sink,
|
|
241
251
|
);
|
|
@@ -449,6 +459,16 @@ export function readRoutes(
|
|
|
449
459
|
});
|
|
450
460
|
}),
|
|
451
461
|
"/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
|
|
462
|
+
// Who the caller is to the core, on a core that can say.
|
|
463
|
+
...(reads.me === undefined
|
|
464
|
+
? {}
|
|
465
|
+
: {
|
|
466
|
+
"/me": route(async (_req, principal) => {
|
|
467
|
+
// Null when the core no longer admits the caller: still a name, null.
|
|
468
|
+
const me = (await reads.me!(principal)) as { name: string | null } | null;
|
|
469
|
+
return json({ ok: true, name: me?.name ?? null });
|
|
470
|
+
}),
|
|
471
|
+
}),
|
|
452
472
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
453
473
|
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
454
474
|
"/wiki/read": route(async (req, principal) => {
|
package/openapi.yaml
CHANGED
|
@@ -85,7 +85,11 @@ paths:
|
|
|
85
85
|
Runs one model turn and streams the result as `text/event-stream`. If
|
|
86
86
|
`text` is a pending confirmation token it is resolved deterministically
|
|
87
87
|
without ever consulting the model. Close the connection to cancel an
|
|
88
|
-
in-flight turn.
|
|
88
|
+
in-flight turn. With `opening: true` and no `text`, the assistant
|
|
89
|
+
speaks first in the conversation named (right after someone signs
|
|
90
|
+
in): it does only for someone it doesn't know the name of, asking how
|
|
91
|
+
to call them, and otherwise the stream carries a single `final` with
|
|
92
|
+
an empty text.
|
|
89
93
|
requestBody:
|
|
90
94
|
required: true
|
|
91
95
|
content:
|
|
@@ -113,7 +117,7 @@ paths:
|
|
|
113
117
|
schema:
|
|
114
118
|
$ref: "#/components/schemas/TurnEvent"
|
|
115
119
|
"400":
|
|
116
|
-
description: Missing/invalid body, or a conversationId with characters outside the allowed ones.
|
|
120
|
+
description: Missing/invalid body, an opening without a conversationId, or a conversationId with characters outside the allowed ones.
|
|
117
121
|
content:
|
|
118
122
|
application/json:
|
|
119
123
|
schema:
|
|
@@ -305,6 +309,32 @@ paths:
|
|
|
305
309
|
application/json:
|
|
306
310
|
schema:
|
|
307
311
|
type: object
|
|
312
|
+
/me:
|
|
313
|
+
get:
|
|
314
|
+
summary: Who the caller is to the assistant
|
|
315
|
+
description: >
|
|
316
|
+
How the caller asked to be called, `null` when they never said: a UI
|
|
317
|
+
asks for an opening turn after a sign-in only then. Not there on an
|
|
318
|
+
older core, which can't say.
|
|
319
|
+
responses:
|
|
320
|
+
"401":
|
|
321
|
+
$ref: "#/components/responses/Unauthorized"
|
|
322
|
+
"403":
|
|
323
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
324
|
+
"503":
|
|
325
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
326
|
+
"200":
|
|
327
|
+
description: The caller's name, or null.
|
|
328
|
+
content:
|
|
329
|
+
application/json:
|
|
330
|
+
schema:
|
|
331
|
+
type: object
|
|
332
|
+
required: [ok, name]
|
|
333
|
+
properties:
|
|
334
|
+
ok:
|
|
335
|
+
type: boolean
|
|
336
|
+
name:
|
|
337
|
+
type: [string, "null"]
|
|
308
338
|
/tool-log:
|
|
309
339
|
get:
|
|
310
340
|
summary: The tool calls made in the caller's recent turns
|
|
@@ -519,11 +549,14 @@ components:
|
|
|
519
549
|
schemas:
|
|
520
550
|
TurnRequest:
|
|
521
551
|
type: object
|
|
522
|
-
required: [text]
|
|
523
552
|
properties:
|
|
524
553
|
text:
|
|
525
554
|
type: string
|
|
526
|
-
description: The user's message, or a bare confirmation token.
|
|
555
|
+
description: The user's message, or a bare confirmation token. Required, unless `opening` is true.
|
|
556
|
+
opening:
|
|
557
|
+
type: boolean
|
|
558
|
+
enum: [true]
|
|
559
|
+
description: The assistant speaks first in `conversationId`, which is then required; `text` is ignored.
|
|
527
560
|
conversationId:
|
|
528
561
|
type: string
|
|
529
562
|
pattern: "^[A-Za-z0-9_-]{1,128}$"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.49.0 <1.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@mercury-fw/channel-types": "0.
|
|
25
|
-
"@mercury-fw/plugin-types": "0.
|
|
24
|
+
"@mercury-fw/channel-types": "0.49.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.49.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|