@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 +39 -0
- package/README.md +12 -6
- package/dist/http-provider.d.ts +3 -1
- package/dist/http-server.d.ts +9 -5
- package/http-provider.ts +4 -1
- package/http-server.ts +76 -35
- package/index.ts +2 -1
- package/openapi.yaml +91 -1
- package/package.json +4 -4
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
|
|
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,
|
|
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
|
|
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.
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -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. */
|
package/dist/http-server.d.ts
CHANGED
|
@@ -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`
|
|
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
|
-
/**
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.73.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.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"
|