@mercury-fw/channel-http 0.15.1 → 0.17.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,44 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fb1f30a: - conversation-store contract: `ConversationStorePlugin`, `ConversationStore`, `StoredMessage`, `StoredTitle`, `CONVERSATION_STORE_API_VERSION`
8
+ - `conversationStore` in `mercury.config.ts`: where every conversation's messages and title are kept
9
+ - **Breaking:** without `conversationStore` nothing is archived: no reopened or listed conversations, no titles, no `recall_verbatim`
10
+ - `qdrantConversations()` in `@mercury-fw/store-qdrant`: messages in `conversation_messages` (`QDRANT_MESSAGES_COLLECTION`), titles in `conversation_titles` (`QDRANT_TITLES_COLLECTION`); on start, copies the messages of `verbatim_archive` into `conversation_messages` once, marking the copy done only when all are in
11
+ - `@mercury-fw/core` no longer depends on `@qdrant/js-client-rest` and no longer reads `QDRANT_URL`
12
+ - **Breaking:** `GET /health` (HTTP): `conversationStoreReachable` and `vectorIndexReachable` (`null` when not declared) instead of `qdrantReachable`
13
+ - **Breaking:** channel contract 7: `ChannelHostReads.conversation`, `conversations` and `memoryScroll` optional, given only with a conversation store / a vector index; channels written for contract 6 are refused
14
+ - `channel-http`, `channel-google-chat`: channel contract 7, framework 0.73 or later
15
+ - `channel-http`: `/conversation`, `/conversations` (`GET`, `PATCH`) only with a conversation store, `/memory/scroll` only with a vector index; `404` otherwise
16
+ - `system.start` trace event: `conversationStore` (`declared`, `active`)
17
+ - `mfw create`: new apps declare `conversationStore: qdrantConversations()`
18
+ - **Migration:** in `mercury.config.ts`, import `qdrantConversations` from `@mercury-fw/store-qdrant` and add `conversationStore: qdrantConversations(),`; upgrade `@mercury-fw/channel-http` and `@mercury-fw/channel-google-chat` with the framework; a UI reading `qdrantReachable` from `/health` reads the two new fields
19
+
20
+ ### Patch Changes
21
+
22
+ - b634bd1: - vector-index contract: `VectorIndexPlugin`, `VectorIndex`, `VectorPoint`, `VectorSpaceSpec`, `VectorWhere`, `VECTOR_INDEX_API_VERSION`
23
+ - `vectorIndex` in `mercury.config.ts`: where episodes, semantic facts, procedural corrections and `recall_verbatim`'s search are kept
24
+ - **Breaking:** without `vectorIndex` there is no long-term memory: no conversation summaries, no corrections, no `recall_verbatim` (tool and prompt), idle conversations closed without a summary
25
+ - new package `@mercury-fw/store-qdrant`: `qdrantIndex()`, Qdrant at `QDRANT_URL`, each space the collection of the same name or the one its `QDRANT_*_COLLECTION` names
26
+ - the memory's vector length comes from the embedding model at startup; `QDRANT_*_VECTOR_SIZE` no longer read
27
+ - `GET /memory/scroll` (HTTP) takes the index's space name (`episodic_memory`, `semantic_facts`, `verbatim_archive`), not the `QDRANT_*_COLLECTION` name
28
+ - `system.start` trace event: `vectorIndex` (`declared`, `active`)
29
+ - `mfw memory` runs the memory CLI of `@mercury-fw/store-qdrant`
30
+ - `mfw create`: new apps declare `vectorIndex: qdrantIndex()` and depend on `@mercury-fw/store-qdrant`
31
+ - **Migration:** add `@mercury-fw/store-qdrant` to the app's dependencies, at the framework's version; in `mercury.config.ts`, `import { qdrantIndex } from "@mercury-fw/store-qdrant";` and `vectorIndex: qdrantIndex(),`; `QDRANT_*_VECTOR_SIZE` can go from `.env`
32
+
33
+ ## 0.16.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 07d1ef6: - conversations get a title, generated from the first question and its answer after the first turn the person wrote
38
+ - channel contract: optional `ctx.conversations` with `rename`, the title the person chose, never replaced by a generated one
39
+ - `GET /conversations` and `GET /conversation` carry `title` once a conversation has one
40
+ - `PATCH /conversation?id=<conversationId>` with `{ title }` renames one of the caller's conversations
41
+
3
42
  ## 0.15.1
4
43
 
5
44
  ### Patch Changes
package/README.md CHANGED
@@ -4,7 +4,7 @@ Gives a [Mercury](https://github.com/lucabro81/mercury-fw) agent an HTTP surface
4
4
 
5
5
  On Mercury's documentation site: [its page](https://lucabro81.github.io/mercury-fw/plugins/channel-http/).
6
6
 
7
- This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.65 or later, the first whose core gives channels the app's version (`GET /version`); since 0.61 the core says when a resumed turn's answer is no longer available (`resume` below). A core with an older contract skips it, and the log says why.
7
+ This version is written for version 7 of the channel contract, the one whose conversation and memory reads are there only when the agent keeps them, and needs the framework at 0.73 or later; since 0.61 the core says when a resumed turn's answer is no longer available (`resume` below). A core with an older contract skips it, and the log says why.
8
8
 
9
9
  ```bash
10
10
  bun add @mercury-fw/channel-http
@@ -97,31 +97,37 @@ A code for linking another of the caller's accounts to them, for the UI to show:
97
97
 
98
98
  Saves the caller's time zone, for a UI's settings: body `{ timeZone }`, an IANA name such as `Europe/Rome`; returns `{ ok: true, timeZone }` as saved (`europe/rome` is saved as `Europe/Rome`), or `400` with the reason for what isn't a zone's name (a fixed offset such as `+02:00` isn't one) and for a body without it. From then on the caller's turns are stamped with the time there, whatever `X-Time-Zone` says. Not there on a core without settings.
99
99
 
100
+ ## `PATCH /conversation`
101
+
102
+ Renames one of the caller's conversations: `?id=<conversationId>`, body `{ title }`; returns `{ ok: true, title }` with the title as saved (trimmed). A title has at most 100 characters, on one line: an empty one, a longer one or one with a line break is `400` with the reason, as are a missing or malformed `id` and a body without `title`. `404` when no conversation of the caller's has that id: only a conversation with at least one turn in the archive can be renamed. Mercury never replaces a title the person chose. Not there on an agent with no conversation store (`404`).
103
+
100
104
  ## `POST /confirm`
101
105
 
102
106
  Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation (`text`: the core's reply in the caller's language, "Confirmed and done." or the failure, never the action's raw result), `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
103
107
 
104
108
  ## Read-only introspection
105
109
 
106
- All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller and show only what belongs to them: their conversations, confirmations, tool calls and memory, and the wiki as they see it (the team's `curated/` plus their own notes as `personal/`). Someone else's data doesn't exist as far as a caller can tell. `/manifest` lists what the caller is offered (a plugin acting as Mercury only for someone allowed to use it), and only `/health` is the same for everyone.
110
+ All `GET`, all JSON, all reporting state already held in-process. `/conversation` and `/conversations` exist only on an agent with a conversation store, `/memory/scroll` only on one with a vector index: on any other they answer `404`, as an unknown route does. They need an authenticated caller and show only what belongs to them: their conversations, confirmations, tool calls and memory, and the wiki as they see it (the team's `curated/` plus their own notes as `personal/`). Someone else's data doesn't exist as far as a caller can tell. `/manifest` lists what the caller is offered (a plugin acting as Mercury only for someone allowed to use it), and only `/health` is the same for everyone.
107
111
 
108
112
  | endpoint | `data` on success |
109
113
  |---|---|
110
- | `GET /conversation?id=<conversationId>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, interrupted?, trace?, pendingNote?, confirmation?, timestamp }], nextOffset }` — one of your conversations, its durable transcript in order, each answer as you saw it with the lists it showed (empty for an id that isn't yours). An answer whose turn was cancelled has `interrupted: true` and an empty `content`: the question before it was left unanswered. An answer's `trace` is what its turn showed besides the text, in order, as the events above showed it: each reasoning block in full, each status line with its tool and outcome, each confirmation staged (its command and its choices' labels, named by an `id`, never a token), each login asked for (its service, never the link); up to the stop for a cancelled turn. `pendingNote` is the turn's `final` one. A message with `confirmation: { id, status, choice? }` says how the staged action with that `id` (its trace entry) was resolved (`ok`, `failed`, or `resume`, whose answer is the next message): its `content` is the reply you got, empty for `resume`. Answers archived before these fields have none of them. `400` if `id` is missing or has other characters than `/turn` accepts |
111
- | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first; the preview is the newest message that shows something, so the question when the last answer was interrupted |
114
+ | `GET /conversation?id=<conversationId>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, interrupted?, trace?, pendingNote?, confirmation?, timestamp }], nextOffset, title? }` — one of your conversations, its durable transcript in order, each answer as you saw it with the lists it showed (empty for an id that isn't yours). An answer whose turn was cancelled has `interrupted: true` and an empty `content`: the question before it was left unanswered. An answer's `trace` is what its turn showed besides the text, in order, as the events above showed it: each reasoning block in full, each status line with its tool and outcome, each confirmation staged (its command and its choices' labels, named by an `id`, never a token), each login asked for (its service, never the link); up to the stop for a cancelled turn. `pendingNote` is the turn's `final` one. A message with `confirmation: { id, status, choice? }` says how the staged action with that `id` (its trace entry) was resolved (`ok`, `failed`, or `resume`, whose answer is the next message): its `content` is the reply you got, empty for `resume`. Answers archived before these fields have none of them. `400` if `id` is missing or has other characters than `/turn` accepts |
115
+ | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview, title? }] }` — your conversations, most-recently-active first; the preview is the newest message that shows something, so the question when the last answer was interrupted |
112
116
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
113
117
  | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
114
118
  | `GET /me` | `{ name, language, timeZone }` — how you asked the assistant to call you, the language you asked it to use and the time zone you saved, `null` when you never said; `language` and `timeZone` are missing on a core that doesn't know them, and the route isn't there on a core older than this channel |
115
119
  | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
116
- | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
120
+ | `GET /health` | `{ uptimeSeconds, memory, conversationStoreReachable, vectorIndexReachable, ollamaReachable }` — whether each answers, `null` for one the agent doesn't declare |
117
121
  | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
118
122
  | `GET /wiki/read?path=<path>` | `{ content }` — a path as `/wiki/list` gives it; `400` if `path` is missing, `404` if it isn't among yours |
119
123
  | `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
120
- | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of your points in a collection kept per person (verbatim archive, episodic memory, semantic facts), each with its whole payload (a verbatim answer's `displays` and `calls` included, and an interrupted answer's `interrupted: true` with the note the model gets in its `content`); `400` for any other collection |
124
+ | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of your points in a space of the vector index kept per person (`verbatim_archive`, `episodic_memory`, `semantic_facts`), each with its whole payload (a verbatim answer's `displays` and `calls` included, and an interrupted answer's `interrupted: true` with the note the model gets in its `content`); `400` for any other |
121
125
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
122
126
 
123
127
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
124
128
 
129
+ **Titles.** A few seconds after the first turn the person wrote in a conversation, Mercury names it from that first question and its answer (after the next turn if it couldn't, for instance with the model unreachable: until then the conversation has no title), and the title stays the same from then on; the person can rename it (`PATCH /conversation`), and Mercury never replaces a title they chose. `title` is there on both `GET /conversation` and `GET /conversations` once the conversation has one; until then, `preview` is what there is to show. A UI that wants the title of a new conversation reads the list again shortly after the turn's stream closes.
130
+
125
131
  ## `GET /version`
126
132
 
127
133
  Which Mercury this is, for a UI to show: `{ ok: true, version }`, the framework's version as `major.minor.patch` (`"0.59.0"`), the one of `@mercury-fw/core`, not this channel's own. When the app's config declares its own version (`version` in `mercury.config.ts`, its `package.json`'s, as a scaffolded app does), it comes too, as it is: `{ ok: true, version: "0.65.0", app: { version: "1.2.0" } }`. A new one shows once the app is rebuilt. It needs no token, so anyone who reaches the port sees both.
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings, ChannelConversations } from "@mercury-fw/channel-types";
15
15
  export type HttpProviderDeps = {
16
16
  port: number;
17
17
  /** Bare-token interception before the model. */
@@ -36,6 +36,8 @@ export type HttpProviderDeps = {
36
36
  linking?: ChannelLinking;
37
37
  /** The person's own settings (`PATCH /me`). */
38
38
  settings?: ChannelSettings;
39
+ /** Renaming the person's conversations (`PATCH /conversation`). */
40
+ conversations?: ChannelConversations;
39
41
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
40
42
  resumeMaxChars?: number;
41
43
  /** What the pages people read call the assistant. */
@@ -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 ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking, type ChannelSettings } from "@mercury-fw/channel-types";
25
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking, type ChannelSettings, type ChannelConversations } from "@mercury-fw/channel-types";
26
26
  /** Resolves a bare confirmation token to what to tell the person, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
27
27
  export type ConfirmFn = ChannelRuntimeContext["confirm"];
28
28
  /** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
@@ -124,10 +124,10 @@ export declare function handleLoginCallback(req: Request, deps: {
124
124
  assistantName?: string;
125
125
  i18n?: ChannelI18n;
126
126
  }): Promise<Response>;
127
- /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
127
+ /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` and `/conversation` the `PATCH` changing what it reads. */
128
128
  type ReadRoute = {
129
129
  GET: (req: Request) => Response | Promise<Response>;
130
- /** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
130
+ /** On `/me` when the core offers the person's settings (`PATCH /me`), on `/conversation` when it renames conversations (`PATCH /conversation`). */
131
131
  PATCH?: (req: Request) => Promise<Response>;
132
132
  OPTIONS: () => Response;
133
133
  };
@@ -141,9 +141,11 @@ type ReadRoute = {
141
141
  * `/turn`, and its session key is built from the caller.
142
142
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
143
143
  * same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
144
- * caller's time zone (a UI's settings area), asking `authenticate` first too.
144
+ * caller's time zone (a UI's settings area), asking `authenticate` first too;
145
+ * with `conversations`, `/conversation` takes a `PATCH` renaming one of the
146
+ * caller's conversations, the same way.
145
147
  */
146
- export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn, settings?: ChannelSettings): Record<string, ReadRoute>;
148
+ export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn, settings?: ChannelSettings, conversations?: ChannelConversations): Record<string, ReadRoute>;
147
149
  export type HttpServerDeps = TurnRequestDeps & {
148
150
  port: number;
149
151
  reads?: ChannelHostReads;
@@ -155,6 +157,8 @@ export type HttpServerDeps = TurnRequestDeps & {
155
157
  linking?: ChannelLinking;
156
158
  /** The person's own settings; `PATCH /me` is mounted only with it. */
157
159
  settings?: ChannelSettings;
160
+ /** Renaming the person's conversations; `PATCH /conversation` is mounted only with it. */
161
+ conversations?: ChannelConversations;
158
162
  /** What the pages people read call the assistant. */
159
163
  assistantName?: string;
160
164
  /** The framework's version; `/version` is mounted only with it. */
package/http-provider.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
12
12
  */
13
13
  import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
14
- import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings } from "@mercury-fw/channel-types";
14
+ import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings, ChannelConversations } from "@mercury-fw/channel-types";
15
15
 
16
16
  export type HttpProviderDeps = {
17
17
  port: number;
@@ -33,6 +33,8 @@ export type HttpProviderDeps = {
33
33
  linking?: ChannelLinking;
34
34
  /** The person's own settings (`PATCH /me`). */
35
35
  settings?: ChannelSettings;
36
+ /** Renaming the person's conversations (`PATCH /conversation`). */
37
+ conversations?: ChannelConversations;
36
38
  /** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
37
39
  resumeMaxChars?: number;
38
40
  /** What the pages people read call the assistant. */
@@ -61,6 +63,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
61
63
  completeLogin: deps.logins?.complete,
62
64
  ...(deps.linking === undefined ? {} : { linking: deps.linking }),
63
65
  ...(deps.settings === undefined ? {} : { settings: deps.settings }),
66
+ ...(deps.conversations === undefined ? {} : { conversations: deps.conversations }),
64
67
  ...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
65
68
  ...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
66
69
  ...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
package/http-server.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  type Admission,
37
37
  type ChannelLinking,
38
38
  type ChannelSettings,
39
+ type ChannelConversations,
39
40
  } from "@mercury-fw/channel-types";
40
41
  import { englishHttp } from "./messages.ts";
41
42
 
@@ -457,10 +458,10 @@ function loginRoutes(
457
458
  };
458
459
  }
459
460
 
460
- /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
461
+ /** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` and `/conversation` the `PATCH` changing what it reads. */
461
462
  type ReadRoute = {
462
463
  GET: (req: Request) => Response | Promise<Response>;
463
- /** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
464
+ /** On `/me` when the core offers the person's settings (`PATCH /me`), on `/conversation` when it renames conversations (`PATCH /conversation`). */
464
465
  PATCH?: (req: Request) => Promise<Response>;
465
466
  OPTIONS: () => Response;
466
467
  };
@@ -475,7 +476,9 @@ type ReadRoute = {
475
476
  * `/turn`, and its session key is built from the caller.
476
477
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
477
478
  * same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
478
- * caller's time zone (a UI's settings area), asking `authenticate` first too.
479
+ * caller's time zone (a UI's settings area), asking `authenticate` first too;
480
+ * with `conversations`, `/conversation` takes a `PATCH` renaming one of the
481
+ * caller's conversations, the same way.
479
482
  */
480
483
  export function readRoutes(
481
484
  reads: ChannelHostReads,
@@ -483,6 +486,7 @@ export function readRoutes(
483
486
  corsOrigin = "*",
484
487
  admit?: AdmitFn,
485
488
  settings?: ChannelSettings,
489
+ conversations?: ChannelConversations,
486
490
  ): Record<string, ReadRoute> {
487
491
  const cors = corsHeaders(corsOrigin);
488
492
  const json = (payload: object, status = 200): Response =>
@@ -490,6 +494,8 @@ export function readRoutes(
490
494
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
491
495
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
492
496
  const options = () => preflight(corsOrigin);
497
+ // Read once, so each stays narrowed inside its routes.
498
+ const { conversation, conversations: listConversations, memoryScroll } = reads;
493
499
  const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
494
500
  GET: async (req) => {
495
501
  const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
@@ -500,28 +506,56 @@ export function readRoutes(
500
506
  return {
501
507
  "/manifest": route(async (_req, principal) => json({ ok: true, manifest: await reads.manifest(principal) })),
502
508
  "/confirmations": route(async (_req, principal) => json({ ok: true, pending: await reads.pendingConfirmations(principal) })),
503
- "/conversation": route(async (req, principal) => {
504
- const url = new URL(req.url);
505
- const id = url.searchParams.get("id");
506
- if (!id) return badRequest("missing ?id");
507
- if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
508
- const limit = Number(url.searchParams.get("limit") ?? "200");
509
- const offset = url.searchParams.get("offset") ?? undefined;
510
- return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
511
- }),
512
- "/conversations": route(async (req, principal) => {
513
- const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
514
- const { conversations } = (await reads.conversations(principal, limit)) as {
515
- conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string }>;
516
- };
517
- const prefix = `${principal.id}:`;
518
- return json({
519
- ok: true,
520
- conversations: conversations
521
- .filter((c) => c.sessionKey.startsWith(prefix))
522
- .map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
523
- });
524
- }),
509
+ // The conversations, on an instance that keeps them.
510
+ ...(conversation === undefined || listConversations === undefined
511
+ ? {}
512
+ : {
513
+ "/conversation": {
514
+ ...route(async (req, principal) => {
515
+ const url = new URL(req.url);
516
+ const id = url.searchParams.get("id");
517
+ if (!id) return badRequest("missing ?id");
518
+ if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
519
+ const limit = Number(url.searchParams.get("limit") ?? "200");
520
+ const offset = url.searchParams.get("offset") ?? undefined;
521
+ return json({ ok: true, ...(await conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
522
+ }),
523
+ ...(conversations === undefined
524
+ ? {}
525
+ : {
526
+ PATCH: async (req: Request) => {
527
+ const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
528
+ if (principal instanceof Response) return principal;
529
+ const id = new URL(req.url).searchParams.get("id");
530
+ if (!id) return badRequest("missing ?id");
531
+ if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
532
+ const body = (await req.json().catch(() => null)) as { title?: unknown } | null;
533
+ if (typeof body?.title !== "string") return badRequest('the body must be {"title": "<text>"}');
534
+ const renamed = await conversations.rename(principal, `${principal.id}:${id}`, body.title);
535
+ if (renamed.ok) return json({ ok: true, title: renamed.title });
536
+ return renamed.notFound ? json({ ok: false, error: renamed.error }, 404) : badRequest(renamed.error);
537
+ },
538
+ }),
539
+ },
540
+ "/conversations": route(async (req, principal) => {
541
+ const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
542
+ const { conversations } = (await listConversations(principal, limit)) as {
543
+ conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string; title?: string }>;
544
+ };
545
+ const prefix = `${principal.id}:`;
546
+ return json({
547
+ ok: true,
548
+ conversations: conversations
549
+ .filter((c) => c.sessionKey.startsWith(prefix))
550
+ .map((c) => ({
551
+ conversationId: c.sessionKey.slice(prefix.length),
552
+ lastTimestamp: c.lastTimestamp,
553
+ preview: c.preview,
554
+ ...(c.title === undefined ? {} : { title: c.title }),
555
+ })),
556
+ });
557
+ }),
558
+ }),
525
559
  "/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
526
560
  // Who the caller is to the core, on a core that can say.
527
561
  ...(reads.me === undefined
@@ -566,15 +600,20 @@ export function readRoutes(
566
600
  if (!pattern) return badRequest("missing ?pattern");
567
601
  return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
568
602
  }),
569
- "/memory/scroll": route(async (req, principal) => {
570
- const url = new URL(req.url);
571
- const collection = url.searchParams.get("collection");
572
- if (!collection) return badRequest("missing ?collection");
573
- const limit = Number(url.searchParams.get("limit") ?? "50");
574
- const offset = url.searchParams.get("offset") ?? undefined;
575
- const page = await reads.memoryScroll(principal, collection, limit, offset);
576
- return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
577
- }),
603
+ // The memory, on an instance with a vector index.
604
+ ...(memoryScroll === undefined
605
+ ? {}
606
+ : {
607
+ "/memory/scroll": route(async (req, principal) => {
608
+ const url = new URL(req.url);
609
+ const collection = url.searchParams.get("collection");
610
+ if (!collection) return badRequest("missing ?collection");
611
+ const limit = Number(url.searchParams.get("limit") ?? "50");
612
+ const offset = url.searchParams.get("offset") ?? undefined;
613
+ const page = await memoryScroll(principal, collection, limit, offset);
614
+ return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
615
+ }),
616
+ }),
578
617
  };
579
618
  }
580
619
 
@@ -604,6 +643,8 @@ export type HttpServerDeps = TurnRequestDeps & {
604
643
  linking?: ChannelLinking;
605
644
  /** The person's own settings; `PATCH /me` is mounted only with it. */
606
645
  settings?: ChannelSettings;
646
+ /** Renaming the person's conversations; `PATCH /conversation` is mounted only with it. */
647
+ conversations?: ChannelConversations;
607
648
  /** What the pages people read call the assistant. */
608
649
  assistantName?: string;
609
650
  /** The framework's version; `/version` is mounted only with it. */
@@ -642,7 +683,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
642
683
  },
643
684
  "/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
644
685
  ...(deps.version === undefined ? {} : versionRoutes(deps.version, deps.appVersion, origin)),
645
- ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings) : {}),
686
+ ...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings, deps.conversations) : {}),
646
687
  ...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
647
688
  ...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
648
689
  },
package/index.ts CHANGED
@@ -23,7 +23,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
23
23
  export const httpChannel: ChannelPlugin = {
24
24
  // The contract this channel is written for, as a literal: importing
25
25
  // CHANNEL_API_VERSION would report whichever contract is installed.
26
- apiVersion: 6,
26
+ apiVersion: 7,
27
27
  name: "http",
28
28
  messages: HTTP_MESSAGES,
29
29
  build: (ctx) => {
@@ -49,6 +49,7 @@ export const httpChannel: ChannelPlugin = {
49
49
  i18n: ctx.i18n,
50
50
  ...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
51
51
  ...(ctx.settings === undefined ? {} : { settings: ctx.settings }),
52
+ ...(ctx.conversations === undefined ? {} : { conversations: ctx.conversations }),
52
53
  ...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
53
54
  ...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
54
55
  ...(ctx.frameworkVersion === undefined ? {} : { version: ctx.frameworkVersion }),
package/openapi.yaml CHANGED
@@ -49,6 +49,14 @@ info:
49
49
  by the verbatim archive; they degrade to empty when the vector store is
50
50
  unreachable).
51
51
 
52
+ A conversation has a title once Mercury has named it, a few seconds after
53
+ the first turn the person wrote in it, from that first question and its
54
+ answer (after the next turn if it couldn't, for instance with the model
55
+ unreachable); it then stays the same. The caller renames one of their
56
+ conversations with `PATCH /conversation`, and Mercury never replaces a
57
+ title the person chose. Until there is a title, `title` is absent from
58
+ both reads and `preview` is what there is to show.
59
+
52
60
  A conversation never ends: sent again with the same `conversationId`, a
53
61
  turn continues it however long after. Mercury frees a conversation's
54
62
  in-memory history once it has been idle a while; the next turn rebuilds
@@ -234,6 +242,7 @@ paths:
234
242
  /conversation:
235
243
  get:
236
244
  summary: A conversation's durable transcript
245
+ description: Only on an agent with a conversation store; `404` on any other.
237
246
  parameters:
238
247
  - name: id
239
248
  in: query
@@ -273,9 +282,68 @@ paths:
273
282
  application/json:
274
283
  schema:
275
284
  $ref: "#/components/schemas/Error"
285
+ patch:
286
+ summary: Renames one of the caller's conversations
287
+ description: >
288
+ Gives the conversation the title the caller chose, trimmed, in place of
289
+ the one it had; Mercury never replaces it with one it generates. Only a
290
+ conversation already in the archive (one with a turn) can be renamed.
291
+ Not there on a core that can't rename conversations.
292
+ parameters:
293
+ - name: id
294
+ in: query
295
+ required: true
296
+ description: The caller's `conversationId`, as `/conversations` lists it.
297
+ schema:
298
+ type: string
299
+ pattern: "^[A-Za-z0-9_-]{1,128}$"
300
+ requestBody:
301
+ required: true
302
+ content:
303
+ application/json:
304
+ schema:
305
+ type: object
306
+ required: [title]
307
+ properties:
308
+ title:
309
+ type: string
310
+ description: At most 100 characters once trimmed, on one line.
311
+ responses:
312
+ "400":
313
+ description: Missing or malformed `?id`, a body with no `title`, or a title that is empty, longer than 100 characters or holds a line break.
314
+ content:
315
+ application/json:
316
+ schema:
317
+ $ref: "#/components/schemas/Error"
318
+ "401":
319
+ $ref: "#/components/responses/Unauthorized"
320
+ "403":
321
+ $ref: "#/components/responses/NotAdmitted"
322
+ "404":
323
+ description: No conversation of the caller's has this id.
324
+ content:
325
+ application/json:
326
+ schema:
327
+ $ref: "#/components/schemas/Error"
328
+ "503":
329
+ $ref: "#/components/responses/NotAdmitted"
330
+ "200":
331
+ description: The title as saved.
332
+ content:
333
+ application/json:
334
+ schema:
335
+ type: object
336
+ required: [ok, title]
337
+ properties:
338
+ ok:
339
+ type: boolean
340
+ enum: [true]
341
+ title:
342
+ type: string
276
343
  /conversations:
277
344
  get:
278
345
  summary: List the caller's conversations (most-recently-active first)
346
+ description: Only on an agent with a conversation store; `404` on any other.
279
347
  parameters:
280
348
  - name: limit
281
349
  in: query
@@ -312,6 +380,21 @@ paths:
312
380
  application/json:
313
381
  schema:
314
382
  type: object
383
+ properties:
384
+ ok:
385
+ type: boolean
386
+ uptimeSeconds:
387
+ type: number
388
+ memory:
389
+ type: object
390
+ conversationStoreReachable:
391
+ type: [boolean, "null"]
392
+ description: Whether the declared conversation store answers; `null` when the agent declares none.
393
+ vectorIndexReachable:
394
+ type: [boolean, "null"]
395
+ description: Whether the declared vector index answers; `null` when the agent declares none.
396
+ ollamaReachable:
397
+ type: boolean
315
398
  /manifest:
316
399
  get:
317
400
  summary: The plugins, tools and skills the caller is offered
@@ -520,11 +603,12 @@ paths:
520
603
  /memory/scroll:
521
604
  get:
522
605
  summary: Scroll the caller's points in a memory collection
606
+ description: Only on an agent with a vector index; `404` on any other.
523
607
  parameters:
524
608
  - name: collection
525
609
  in: query
526
610
  required: true
527
- description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
611
+ description: One of the vector index's spaces kept per person, by name (`verbatim_archive`, `episodic_memory`, `semantic_facts`).
528
612
  schema:
529
613
  type: string
530
614
  - name: limit
@@ -932,6 +1016,9 @@ components:
932
1016
  oneOf:
933
1017
  - type: string
934
1018
  - type: "null"
1019
+ title:
1020
+ type: string
1021
+ description: The conversation's title, Mercury's or the caller's; absent until it has one.
935
1022
  ConversationSummary:
936
1023
  type: object
937
1024
  required: [conversationId, lastTimestamp, preview]
@@ -943,6 +1030,9 @@ components:
943
1030
  preview:
944
1031
  type: string
945
1032
  description: The newest message that shows something, as the person saw it, so the question when the last answer was interrupted.
1033
+ title:
1034
+ type: string
1035
+ description: The conversation's title, Mercury's or the caller's; absent until it has one, when `preview` is what there is to show.
946
1036
  ConversationList:
947
1037
  type: object
948
1038
  required: [ok, conversations]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.15.1",
3
+ "version": "0.17.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.68.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.73.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.69.0",
25
- "@mercury-fw/plugin-types": "0.69.0",
24
+ "@mercury-fw/channel-types": "0.73.0",
25
+ "@mercury-fw/plugin-types": "0.73.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"