@mercury-fw/channel-http 0.8.3 → 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 CHANGED
@@ -1,5 +1,25 @@
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
+
12
+ ## 0.8.4
13
+
14
+ ### Patch Changes
15
+
16
+ - 62bb3a3: - `persona.name` is the assistant's name, Mercury when left out. A name that's blank, longer than 40 characters or on more than one line stops the app at startup.
17
+ - Every text the model reads refers to the name as `{{assistant}}`, and the core puts the persona's name there: the persona's identity and tone, the system prompt, plugin fragments, skills and tool descriptions. The model no longer takes "Mercury" for its own name when the app calls its assistant something else.
18
+ - Where the one acting is the system around the model, the texts say "the system" or "this instance" instead of Mercury: the wiki tools, the cli-engine's errors, the jira skill and its issue lists.
19
+ - Plugins get `assistantName` in their `build()` context, and channels in their runtime context, for texts they build while running. The login messages, the linking reply and the HTTP login page name the assistant that way.
20
+ - The tools a person runs as the assistant itself are `<tool>AsSelf`, previously `<tool>AsMercury`. The `mercury.act-as-self` roles are unchanged.
21
+ - `mfw create` writes the assistant's name as `persona.name`, with `persona/identity.md` and `persona/tone.md` referring to it. An existing app keeps working as it is: to rename its assistant everywhere, add `name` to its persona and write `{{assistant}}` in its persona files.
22
+
3
23
  ## 0.8.3
4
24
 
5
25
  ### 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 |
@@ -36,6 +36,8 @@ export type HttpProviderDeps = {
36
36
  linking?: ChannelLinking;
37
37
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
38
38
  resumeMaxChars?: number;
39
+ /** What the pages people read call the assistant. */
40
+ assistantName?: string;
39
41
  };
40
42
  export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
41
43
  stop(): Promise<void>;
@@ -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 = {
@@ -102,10 +107,12 @@ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutc
102
107
  * in, with `code` and `state` (or `error`, when they refused). Public: the
103
108
  * person's browser carries no token here, and the single-use `state` the core
104
109
  * issued for them is what ties the request to their login. Answers with a
105
- * plain page telling them how it went.
110
+ * plain page telling them how it went, naming the assistant as the app does
111
+ * (`Mercury` from an older core that doesn't say).
106
112
  */
107
113
  export declare function handleLoginCallback(req: Request, deps: {
108
114
  complete: CompleteLoginFn;
115
+ assistantName?: string;
109
116
  }): Promise<Response>;
110
117
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
111
118
  type ReadRoute = {
@@ -133,6 +140,8 @@ export type HttpServerDeps = TurnRequestDeps & {
133
140
  completeLogin?: CompleteLoginFn;
134
141
  /** Linking another account to the caller; `/link` is mounted only with it. */
135
142
  linking?: ChannelLinking;
143
+ /** What the pages people read call the assistant. */
144
+ assistantName?: string;
136
145
  };
137
146
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
138
147
  * when `reads` is supplied. */
package/http-provider.ts CHANGED
@@ -33,6 +33,8 @@ export type HttpProviderDeps = {
33
33
  linking?: ChannelLinking;
34
34
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
35
35
  resumeMaxChars?: number;
36
+ /** What the pages people read call the assistant. */
37
+ assistantName?: string;
36
38
  };
37
39
 
38
40
  export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
@@ -51,6 +53,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
51
53
  completeLogin: deps.logins?.complete,
52
54
  ...(deps.linking === undefined ? {} : { linking: deps.linking }),
53
55
  ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
56
+ ...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
54
57
  });
55
58
  deps.logins?.accept(deps.logins.callbackUrl);
56
59
  },
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
- if (typeof body.text !== "string" || body.text.trim().length === 0) {
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
  );
@@ -352,18 +362,23 @@ function loginPage(text: string, status: number): Response {
352
362
  * in, with `code` and `state` (or `error`, when they refused). Public: the
353
363
  * person's browser carries no token here, and the single-use `state` the core
354
364
  * issued for them is what ties the request to their login. Answers with a
355
- * plain page telling them how it went.
365
+ * plain page telling them how it went, naming the assistant as the app does
366
+ * (`Mercury` from an older core that doesn't say).
356
367
  */
357
- export async function handleLoginCallback(req: Request, deps: { complete: CompleteLoginFn }): Promise<Response> {
368
+ export async function handleLoginCallback(
369
+ req: Request,
370
+ deps: { complete: CompleteLoginFn; assistantName?: string },
371
+ ): Promise<Response> {
372
+ const assistant = deps.assistantName ?? "Mercury";
358
373
  const params = new URL(req.url).searchParams;
359
374
  const error = params.get("error");
360
375
  if (error !== null) {
361
376
  const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
362
- return loginPage(`The login didn't happen (${reason}). Ask Mercury again for a new link.`, 400);
377
+ return loginPage(`The login didn't happen (${reason}). Ask ${assistant} again for a new link.`, 400);
363
378
  }
364
379
  const code = params.get("code");
365
380
  const state = params.get("state");
366
- if (!code || !state) return loginPage("The login link came back without a code or a state. Ask Mercury again for a new link.", 400);
381
+ if (!code || !state) return loginPage(`The login link came back without a code or a state. Ask ${assistant} again for a new link.`, 400);
367
382
  const outcome = await deps.complete(state, code);
368
383
  return outcome.ok
369
384
  ? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
@@ -371,8 +386,15 @@ export async function handleLoginCallback(req: Request, deps: { complete: Comple
371
386
  }
372
387
 
373
388
  /** The login callback route, mounted when the server can complete logins. */
374
- function loginRoutes(complete: CompleteLoginFn): Record<string, { GET: (req: Request) => Promise<Response> }> {
375
- return { "/login/callback": { GET: (req) => handleLoginCallback(req, { complete }) } };
389
+ function loginRoutes(
390
+ complete: CompleteLoginFn,
391
+ assistantName: string | undefined,
392
+ ): Record<string, { GET: (req: Request) => Promise<Response> }> {
393
+ return {
394
+ "/login/callback": {
395
+ GET: (req) => handleLoginCallback(req, { complete, ...(assistantName === undefined ? {} : { assistantName }) }),
396
+ },
397
+ };
376
398
  }
377
399
 
378
400
  /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
@@ -437,6 +459,16 @@ export function readRoutes(
437
459
  });
438
460
  }),
439
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
+ }),
440
472
  "/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
441
473
  "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
442
474
  "/wiki/read": route(async (req, principal) => {
@@ -471,6 +503,8 @@ export type HttpServerDeps = TurnRequestDeps & {
471
503
  completeLogin?: CompleteLoginFn;
472
504
  /** Linking another account to the caller; `/link` is mounted only with it. */
473
505
  linking?: ChannelLinking;
506
+ /** What the pages people read call the assistant. */
507
+ assistantName?: string;
474
508
  };
475
509
 
476
510
  /** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
@@ -502,7 +536,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
502
536
  },
503
537
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
504
538
  ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
505
- ...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
539
+ ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName) : {}),
506
540
  ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
507
541
  },
508
542
  error: (err) =>
package/index.ts CHANGED
@@ -46,6 +46,7 @@ export const httpChannel: ChannelPlugin = {
46
46
  admit: ctx.admit,
47
47
  ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
48
48
  ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
49
+ ...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
49
50
  ...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
50
51
  });
51
52
  },
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.8.3",
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.45.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.49.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.47.0",
25
- "@mercury-fw/plugin-types": "0.47.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"