@mercury-fw/channel-http 0.3.0 → 0.5.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 +34 -0
- package/README.md +17 -9
- package/dist/http-provider.d.ts +8 -1
- package/dist/http-server.d.ts +26 -6
- package/dist/index.d.ts +4 -2
- package/http-provider.ts +6 -1
- package/http-server.ts +91 -22
- package/index.ts +10 -3
- package/openapi.yaml +69 -18
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
|
|
8
|
+
|
|
9
|
+
- Plugin contract version 4: a plugin declares `actsAs: "person" | "mercury"`, and one acting as the person contributes a `login`; its tools get the turn's `person` and `requireLogin` in their context.
|
|
10
|
+
- A person is offered only the plugins acting as the person: their prompt fragments, skills and tools. A plugin acting as Mercury, or declaring nothing, is offered on the terminal only until people can be allowed to make Mercury act as itself; the startup log names it.
|
|
11
|
+
- `createCliTool` adds `--user` with the person's id to every command, staged confirmations included, and when the CLI exits with code 3 (not logged in) returns their login instead. `createCliPersonLogin` builds a plugin's `login` on a CLI's two-step remote login. A failed `runCli` carries `exitCode`.
|
|
12
|
+
- Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
|
|
13
|
+
- The HTTP channel, with `HTTP_SURFACE_PUBLIC_URL` set, streams a `login` event with the link to open and takes the person back at `GET /login/callback`, public and protected by the single-use state.
|
|
14
|
+
- `mercury.cliCredentials` can declare `userSetup`, run by `mfw credentials setup <plugin> --user-app`: the app people log in through, set up without logging anyone in.
|
|
15
|
+
- plugin-jira and plugin-bitbucket act as the person, on CLIs 2.3.0: set up their people's app with `--user-app` and register `<HTTP_SURFACE_PUBLIC_URL>/login/callback` on it. Jira's skill now says `currentUser()` is the person.
|
|
16
|
+
- plugin-atlassian-admin acts as Mercury, on CLI 0.2.0, whose `init` asks for the key without echoing it: until permissions exist it's available on the terminal only.
|
|
17
|
+
- A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
|
|
18
|
+
|
|
19
|
+
## 0.4.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 521db42: Isolation between the people an agent talks to.
|
|
24
|
+
|
|
25
|
+
- The core keeps one identity per person, `<provider>:<id>`, so two providers issuing the same id are two people. Memory, the conversation archive, the tool log and pending confirmations are keyed on it.
|
|
26
|
+
- The wiki has a common area (`curated/`) everyone reads and an area per person (`users/<key>/`), which the model sees as `personal/`. Listing, reading and grepping reach the common area and the caller's own area, nothing else.
|
|
27
|
+
- Breaking: the model's `write_file` writes only under `personal/notes/`. The new `promote_note` tool copies one of the person's notes into `curated/`, and only once they confirm it with the token.
|
|
28
|
+
- A confirmation can only be confirmed by whoever staged it, in the same session. Its note is written once, in the person's area: a staged note no longer stays "pending" after it's resolved, for ids such as Google Chat's `users/<n>`.
|
|
29
|
+
- Breaking, `@mercury-fw/channel-types`: channel contract 3. `confirm` and `resolveConfirmation` take the caller's `Principal`, and so does every read getter except `manifest` and `health`. Channels written for contract 2 are refused.
|
|
30
|
+
- `@mercury-fw/confirm-engine`: the store's `stage`, `take` and `pending` take the owner's key.
|
|
31
|
+
- Breaking, `@mercury-fw/channel-http`: every read route returns only the caller's data. `/conversation?id=` takes one of the caller's `conversationId`s and `/conversations` lists them by `conversationId`. `/wiki/read` answers `404` outside what the caller can see, and `/memory/scroll` answers `400` for a collection that isn't kept per person.
|
|
32
|
+
- `@mercury-fw/channel-google-chat`: on channel contract 3.
|
|
33
|
+
- At startup, an instance's existing data moves to the new layout. Google Chat and terminal notes go into their areas, and Qdrant's ids go onto the new keys. Ids that don't say which provider they came from are left in place and logged.
|
|
34
|
+
- `mfw vault read` refuses a path outside the vault.
|
|
35
|
+
- The admin panel's wiki box edits the common area with the operator's tools (list, read, grep, `write_curated`), the same the nightly review uses.
|
|
36
|
+
|
|
3
37
|
## 0.3.0
|
|
4
38
|
|
|
5
39
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -20,12 +20,13 @@ It needs an auth provider next to it: [`@mercury-fw/auth-oidc`](../../auth/auth-
|
|
|
20
20
|
|---|---|
|
|
21
21
|
| `HTTP_SURFACE_PORT` | The port (default `4100`). |
|
|
22
22
|
| `HTTP_SURFACE_CORS_ORIGIN` | The origin a browser UI calls from (default `*`). |
|
|
23
|
+
| `HTTP_SURFACE_PUBLIC_URL` | The address people's browsers reach the surface at (e.g. `https://mercury.example.com`). With it, people log in here to the services plugins act on as them (see [`GET /login/callback`](#get-logincallback)); without it, nobody can. |
|
|
23
24
|
|
|
24
25
|
## API
|
|
25
26
|
|
|
26
27
|
It's active whenever it's declared in `mercury.config.ts`'s `channels` with an auth provider; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). Base URL `http://<host>:<port>`.
|
|
27
28
|
|
|
28
|
-
**Authentication.** Every route except `GET /openapi.yaml` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs.
|
|
29
|
+
**Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth provider decides who the token belongs to. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs.
|
|
29
30
|
|
|
30
31
|
**Conversations belong to whoever opened them.** The session key is `<caller id>:<conversationId>`, so the same `conversationId` sent by someone else is a conversation of their own, and a confirmation token staged in your conversation can't be confirmed from theirs. Each authenticated caller also gets their own episodic memory and wiki area, as on Google Chat.
|
|
31
32
|
|
|
@@ -60,6 +61,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
|
|
|
60
61
|
| `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
|
|
61
62
|
| `text` | `{ chunk }` | an answer-text delta |
|
|
62
63
|
| `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
|
|
64
|
+
| `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
|
|
63
65
|
| `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
|
|
64
66
|
| `error` | `{ message }` | the turn failed mid-stream |
|
|
65
67
|
|
|
@@ -70,26 +72,32 @@ curl -N -X POST http://localhost:4100/turn \
|
|
|
70
72
|
-d '{"text":"Quante issue nel progetto KAN?","conversationId":"c1"}'
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
## `GET /login/callback`
|
|
76
|
+
|
|
77
|
+
Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went.
|
|
78
|
+
|
|
79
|
+
Whoever opens the link and accepts decides which account gets linked: a link belongs to the person it was shown to, and someone else accepting it would link their own account to that person's Mercury identity. A UI should say so next to the link. Checking the linked account against the person is [#179](https://github.com/lucabro81/mercury-fw/issues/179).
|
|
80
|
+
|
|
73
81
|
## `POST /confirm`
|
|
74
82
|
|
|
75
83
|
Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
|
|
76
84
|
|
|
77
85
|
## Read-only introspection
|
|
78
86
|
|
|
79
|
-
All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller,
|
|
87
|
+
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; only `/manifest` and `/health` are the same for everyone.
|
|
80
88
|
|
|
81
89
|
| endpoint | `data` on success |
|
|
82
90
|
|---|---|
|
|
83
|
-
| `GET /conversation?id=<
|
|
84
|
-
| `GET /conversations?limit=<n>` | `{ conversations: [{
|
|
91
|
+
| `GET /conversation?id=<conversationId>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, timestamp }], nextOffset }` — one of your conversations, its durable transcript in order (empty for an id that isn't yours); `400` if `id` is missing or has other characters than `/turn` accepts |
|
|
92
|
+
| `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
|
|
85
93
|
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
86
|
-
| `GET /confirmations` | `{ pending: [{ sessionKey,
|
|
87
|
-
| `GET /tool-log` | `{ entries: [...] }` |
|
|
94
|
+
| `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
|
|
95
|
+
| `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
|
|
88
96
|
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
89
|
-
| `GET /wiki/list` | `{ files: [...] }` |
|
|
90
|
-
| `GET /wiki/read?path=<
|
|
97
|
+
| `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
|
|
98
|
+
| `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 |
|
|
91
99
|
| `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
|
|
92
|
-
| `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of
|
|
100
|
+
| `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); `400` for any other collection |
|
|
93
101
|
| `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
|
|
94
102
|
|
|
95
103
|
Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
|
-
import { type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
13
|
+
import { type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
14
|
import type { Authenticate, Provider, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
15
|
export type HttpProviderDeps = {
|
|
16
16
|
port: number;
|
|
@@ -23,6 +23,13 @@ export type HttpProviderDeps = {
|
|
|
23
23
|
authenticate: Authenticate;
|
|
24
24
|
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
25
25
|
corsOrigin?: string;
|
|
26
|
+
/** People's logins, when the surface has a public URL to be sent back to:
|
|
27
|
+
* the callback is offered (`accept`) once the server listens. */
|
|
28
|
+
logins?: {
|
|
29
|
+
callbackUrl: string;
|
|
30
|
+
accept: (callbackUrl: string) => void;
|
|
31
|
+
complete: CompleteLoginFn;
|
|
32
|
+
};
|
|
26
33
|
};
|
|
27
34
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
28
35
|
stop(): Promise<void>;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -12,18 +12,21 @@
|
|
|
12
12
|
* is unit-tested without standing up a socket. The read-only routes (4b) mount
|
|
13
13
|
* alongside `/turn` here, driven by the injected `reads` getters.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
|
+
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
|
+
*
|
|
18
|
+
* Every route but the OpenAPI document, the login callback and the CORS
|
|
19
|
+
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
17
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
18
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
19
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
20
23
|
* package never imports the app.
|
|
21
24
|
*/
|
|
22
|
-
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome } from "@mercury-fw/channel-types";
|
|
25
|
+
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type LoginOutcome, type Principal } from "@mercury-fw/channel-types";
|
|
23
26
|
/** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
24
|
-
export type ConfirmFn = (token: string, sessionKey: string,
|
|
27
|
+
export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
|
|
25
28
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
26
|
-
export type ResolveConfirmationFn = (token: string, sessionKey: string,
|
|
29
|
+
export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
|
|
27
30
|
export type TurnRequestDeps = {
|
|
28
31
|
handleTurn: HandleTurn;
|
|
29
32
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
@@ -72,6 +75,18 @@ export type ConfirmRequestDeps = {
|
|
|
72
75
|
* even token-shaped). Never touches the model.
|
|
73
76
|
*/
|
|
74
77
|
export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
|
|
78
|
+
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
79
|
+
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
80
|
+
/**
|
|
81
|
+
* `GET /login/callback`: where the provider sends a person back after they log
|
|
82
|
+
* in, with `code` and `state` (or `error`, when they refused). Public: the
|
|
83
|
+
* person's browser carries no token here, and the single-use `state` the core
|
|
84
|
+
* issued for them is what ties the request to their login. Answers with a
|
|
85
|
+
* plain page telling them how it went.
|
|
86
|
+
*/
|
|
87
|
+
export declare function handleLoginCallback(req: Request, deps: {
|
|
88
|
+
complete: CompleteLoginFn;
|
|
89
|
+
}): Promise<Response>;
|
|
75
90
|
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
76
91
|
type ReadRoute = {
|
|
77
92
|
GET: (req: Request) => Response | Promise<Response>;
|
|
@@ -81,7 +96,10 @@ type ReadRoute = {
|
|
|
81
96
|
* Builds the read-only routes, each carrying CORS headers on its `GET` and a
|
|
82
97
|
* shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
|
|
83
98
|
* reach them. Every `GET` asks `authenticate` first and answers 401 without
|
|
84
|
-
* calling its getter when nobody is calling; the preflight doesn't ask.
|
|
99
|
+
* calling its getter when nobody is calling; the preflight doesn't ask. The
|
|
100
|
+
* caller's principal goes to every per-person getter, so the core answers with
|
|
101
|
+
* their data only; a conversation is named by its conversationId, as on
|
|
102
|
+
* `/turn`, and its session key is built from the caller.
|
|
85
103
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
86
104
|
* same for a 400.
|
|
87
105
|
*/
|
|
@@ -91,6 +109,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
91
109
|
reads?: ChannelHostReads;
|
|
92
110
|
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
93
111
|
resolveConfirmation: ResolveConfirmationFn;
|
|
112
|
+
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
113
|
+
completeLogin?: CompleteLoginFn;
|
|
94
114
|
};
|
|
95
115
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
96
116
|
* when `reads` is supplied. */
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
7
|
* declared, it starts.
|
|
8
8
|
*
|
|
9
|
-
* `build` reads its port
|
|
9
|
+
* `build` reads its port, CORS origin and public URL from `env` and takes the confirm
|
|
10
10
|
* capabilities, the in-process reads and the app's auth provider from the
|
|
11
11
|
* injected context (dependency inversion). Those are optional on the contract
|
|
12
12
|
* but required here, so a context missing them throws — the loader isolates it
|
|
13
|
-
* fail-soft, and without an auth provider the surface never opens.
|
|
13
|
+
* fail-soft, and without an auth provider the surface never opens. With a
|
|
14
|
+
* public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
|
|
15
|
+
* mounts `<url>/login/callback` and, once it's listening, offers it to the core.
|
|
14
16
|
*/
|
|
15
17
|
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
16
18
|
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
package/http-provider.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (that stays with Google Chat). `stop` closes the socket on shutdown. Confirm is
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
|
-
import { startHttpServer, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
13
|
+
import { startHttpServer, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
14
|
import type { Authenticate, Provider, HandleTurn, ChannelHostReads } from "@mercury-fw/channel-types";
|
|
15
15
|
|
|
16
16
|
export type HttpProviderDeps = {
|
|
@@ -24,6 +24,9 @@ export type HttpProviderDeps = {
|
|
|
24
24
|
authenticate: Authenticate;
|
|
25
25
|
/** Allowed CORS origin echoed to a browser UI; defaults to `*` in the server. */
|
|
26
26
|
corsOrigin?: string;
|
|
27
|
+
/** People's logins, when the surface has a public URL to be sent back to:
|
|
28
|
+
* the callback is offered (`accept`) once the server listens. */
|
|
29
|
+
logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -38,7 +41,9 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
38
41
|
reads: deps.reads,
|
|
39
42
|
authenticate: deps.authenticate,
|
|
40
43
|
corsOrigin: deps.corsOrigin,
|
|
44
|
+
completeLogin: deps.logins?.complete,
|
|
41
45
|
});
|
|
46
|
+
deps.logins?.accept(deps.logins.callbackUrl);
|
|
42
47
|
},
|
|
43
48
|
async notify(): Promise<{ sessionKey: string }> {
|
|
44
49
|
// No proactive push channel over HTTP (request/response only).
|
package/http-server.ts
CHANGED
|
@@ -12,14 +12,18 @@
|
|
|
12
12
|
* is unit-tested without standing up a socket. The read-only routes (4b) mount
|
|
13
13
|
* alongside `/turn` here, driven by the injected `reads` getters.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
|
+
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
|
+
*
|
|
18
|
+
* Every route but the OpenAPI document, the login callback and the CORS
|
|
19
|
+
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
17
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
18
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
19
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
20
23
|
* package never imports the app.
|
|
21
24
|
*/
|
|
22
25
|
import {
|
|
26
|
+
detectLoginRequired,
|
|
23
27
|
detectPendingConfirmation,
|
|
24
28
|
PENDING_CONFIRMATION_NOTE,
|
|
25
29
|
type HandleTurn,
|
|
@@ -27,6 +31,8 @@ import {
|
|
|
27
31
|
type Authenticate,
|
|
28
32
|
type ChannelHostReads,
|
|
29
33
|
type ConfirmOutcome,
|
|
34
|
+
type LoginOutcome,
|
|
35
|
+
type Principal,
|
|
30
36
|
} from "@mercury-fw/channel-types";
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -43,9 +49,9 @@ function forLog(text: string): string {
|
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** Resolves a bare confirmation token to a reply string, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
46
|
-
export type ConfirmFn = (token: string, sessionKey: string,
|
|
52
|
+
export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
|
|
47
53
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
48
|
-
export type ResolveConfirmationFn = (token: string, sessionKey: string,
|
|
54
|
+
export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
|
|
49
55
|
|
|
50
56
|
export type TurnRequestDeps = {
|
|
51
57
|
handleTurn: HandleTurn;
|
|
@@ -169,7 +175,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
169
175
|
|
|
170
176
|
// Same deterministic interception as every other channel — a
|
|
171
177
|
// previously-approved mutation must never depend on the model.
|
|
172
|
-
const confirmReply = await deps.confirm(text, sessionKey, principal
|
|
178
|
+
const confirmReply = await deps.confirm(text, sessionKey, principal);
|
|
173
179
|
if (confirmReply !== null) {
|
|
174
180
|
send("final", { text: confirmReply });
|
|
175
181
|
close();
|
|
@@ -187,6 +193,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
187
193
|
onStep: (step) => {
|
|
188
194
|
const pending = detectPendingConfirmation(step);
|
|
189
195
|
if (pending) send("pending", { command: pending.summary, token: pending.token });
|
|
196
|
+
const login = detectLoginRequired(step);
|
|
197
|
+
if (login) send("login", { service: login.service, url: login.url });
|
|
190
198
|
},
|
|
191
199
|
onUsage: () => {},
|
|
192
200
|
finalize: async (finalText) => send("final", { text: finalText }),
|
|
@@ -263,7 +271,7 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
263
271
|
if (!CONVERSATION_ID.test(conversationId)) {
|
|
264
272
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
265
273
|
}
|
|
266
|
-
const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal
|
|
274
|
+
const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
|
|
267
275
|
switch (outcome.status) {
|
|
268
276
|
case "not-a-token":
|
|
269
277
|
case "not-found":
|
|
@@ -275,6 +283,46 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
275
283
|
}
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/** Finishes a person's login, injected by the core (`ChannelLogins.complete`). */
|
|
287
|
+
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
288
|
+
|
|
289
|
+
/** A plain-text page for the person's browser, at the end of their login. */
|
|
290
|
+
function loginPage(text: string, status: number): Response {
|
|
291
|
+
// The URL carried the code and the state: neither kept nor passed on.
|
|
292
|
+
return new Response(text, {
|
|
293
|
+
status,
|
|
294
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `GET /login/callback`: where the provider sends a person back after they log
|
|
300
|
+
* in, with `code` and `state` (or `error`, when they refused). Public: the
|
|
301
|
+
* person's browser carries no token here, and the single-use `state` the core
|
|
302
|
+
* issued for them is what ties the request to their login. Answers with a
|
|
303
|
+
* plain page telling them how it went.
|
|
304
|
+
*/
|
|
305
|
+
export async function handleLoginCallback(req: Request, deps: { complete: CompleteLoginFn }): Promise<Response> {
|
|
306
|
+
const params = new URL(req.url).searchParams;
|
|
307
|
+
const error = params.get("error");
|
|
308
|
+
if (error !== null) {
|
|
309
|
+
const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
|
|
310
|
+
return loginPage(`The login didn't happen (${reason}). Ask Mercury again for a new link.`, 400);
|
|
311
|
+
}
|
|
312
|
+
const code = params.get("code");
|
|
313
|
+
const state = params.get("state");
|
|
314
|
+
if (!code || !state) return loginPage("The login link came back without a code or a state. Ask Mercury again for a new link.", 400);
|
|
315
|
+
const outcome = await deps.complete(state, code);
|
|
316
|
+
return outcome.ok
|
|
317
|
+
? loginPage(`You're logged in to ${outcome.service}. Go back to the conversation and ask again.`, 200)
|
|
318
|
+
: loginPage(outcome.error, 400);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The login callback route, mounted when the server can complete logins. */
|
|
322
|
+
function loginRoutes(complete: CompleteLoginFn): Record<string, { GET: (req: Request) => Promise<Response> }> {
|
|
323
|
+
return { "/login/callback": { GET: (req) => handleLoginCallback(req, { complete }) } };
|
|
324
|
+
}
|
|
325
|
+
|
|
278
326
|
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
279
327
|
type ReadRoute = {
|
|
280
328
|
GET: (req: Request) => Response | Promise<Response>;
|
|
@@ -285,7 +333,10 @@ type ReadRoute = {
|
|
|
285
333
|
* Builds the read-only routes, each carrying CORS headers on its `GET` and a
|
|
286
334
|
* shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
|
|
287
335
|
* reach them. Every `GET` asks `authenticate` first and answers 401 without
|
|
288
|
-
* calling its getter when nobody is calling; the preflight doesn't ask.
|
|
336
|
+
* calling its getter when nobody is calling; the preflight doesn't ask. The
|
|
337
|
+
* caller's principal goes to every per-person getter, so the core answers with
|
|
338
|
+
* their data only; a conversation is named by its conversationId, as on
|
|
339
|
+
* `/turn`, and its session key is built from the caller.
|
|
289
340
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
290
341
|
* same for a 400.
|
|
291
342
|
*/
|
|
@@ -296,45 +347,60 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
|
|
|
296
347
|
const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
|
|
297
348
|
const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
|
|
298
349
|
const options = () => preflight(corsOrigin);
|
|
299
|
-
const route = (GET:
|
|
300
|
-
GET: async (req) =>
|
|
350
|
+
const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
|
|
351
|
+
GET: async (req) => {
|
|
352
|
+
const principal = await authenticate(req);
|
|
353
|
+
return principal === null ? unauthorized(corsOrigin) : GET(req, principal);
|
|
354
|
+
},
|
|
301
355
|
OPTIONS: options,
|
|
302
356
|
});
|
|
303
357
|
return {
|
|
304
358
|
"/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
|
|
305
|
-
"/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
|
|
306
|
-
"/conversation": route(async (req) => {
|
|
359
|
+
"/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
|
|
360
|
+
"/conversation": route(async (req, principal) => {
|
|
307
361
|
const url = new URL(req.url);
|
|
308
362
|
const id = url.searchParams.get("id");
|
|
309
363
|
if (!id) return badRequest("missing ?id");
|
|
364
|
+
if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
|
|
310
365
|
const limit = Number(url.searchParams.get("limit") ?? "200");
|
|
311
366
|
const offset = url.searchParams.get("offset") ?? undefined;
|
|
312
|
-
return json({ ok: true, ...(await reads.conversation(
|
|
367
|
+
return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
|
|
313
368
|
}),
|
|
314
|
-
"/conversations": route(async (req) => {
|
|
369
|
+
"/conversations": route(async (req, principal) => {
|
|
315
370
|
const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
|
|
316
|
-
|
|
371
|
+
const { conversations } = (await reads.conversations(principal, limit)) as {
|
|
372
|
+
conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string }>;
|
|
373
|
+
};
|
|
374
|
+
const prefix = `${principal.id}:`;
|
|
375
|
+
return json({
|
|
376
|
+
ok: true,
|
|
377
|
+
conversations: conversations
|
|
378
|
+
.filter((c) => c.sessionKey.startsWith(prefix))
|
|
379
|
+
.map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
|
|
380
|
+
});
|
|
317
381
|
}),
|
|
318
|
-
"/tool-log": route(() => json({ ok: true, entries: reads.toolLog() })),
|
|
382
|
+
"/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
|
|
319
383
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
320
|
-
"/wiki/list": route(async () => json({ ok: true, files: await reads.wikiList() })),
|
|
321
|
-
"/wiki/read": route(async (req) => {
|
|
384
|
+
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
385
|
+
"/wiki/read": route(async (req, principal) => {
|
|
322
386
|
const path = requireParam(req, "path");
|
|
323
387
|
if (!path) return badRequest("missing ?path");
|
|
324
|
-
|
|
388
|
+
const content = await reads.wikiRead(principal, path);
|
|
389
|
+
return content === null ? json({ ok: false, error: "not found" }, 404) : json({ ok: true, content });
|
|
325
390
|
}),
|
|
326
|
-
"/wiki/grep": route(async (req) => {
|
|
391
|
+
"/wiki/grep": route(async (req, principal) => {
|
|
327
392
|
const pattern = requireParam(req, "pattern");
|
|
328
393
|
if (!pattern) return badRequest("missing ?pattern");
|
|
329
|
-
return json({ ok: true, matches: await reads.wikiGrep(pattern) });
|
|
394
|
+
return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
|
|
330
395
|
}),
|
|
331
|
-
"/memory/scroll": route(async (req) => {
|
|
396
|
+
"/memory/scroll": route(async (req, principal) => {
|
|
332
397
|
const url = new URL(req.url);
|
|
333
398
|
const collection = url.searchParams.get("collection");
|
|
334
399
|
if (!collection) return badRequest("missing ?collection");
|
|
335
400
|
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
336
401
|
const offset = url.searchParams.get("offset") ?? undefined;
|
|
337
|
-
|
|
402
|
+
const page = await reads.memoryScroll(principal, collection, limit, offset);
|
|
403
|
+
return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
|
|
338
404
|
}),
|
|
339
405
|
};
|
|
340
406
|
}
|
|
@@ -344,6 +410,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
344
410
|
reads?: ChannelHostReads;
|
|
345
411
|
/** The `/confirm` endpoint's structured resolver, injected by the core. */
|
|
346
412
|
resolveConfirmation: ResolveConfirmationFn;
|
|
413
|
+
/** Finishes people's logins; `/login/callback` is mounted only with it. */
|
|
414
|
+
completeLogin?: CompleteLoginFn;
|
|
347
415
|
};
|
|
348
416
|
|
|
349
417
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
@@ -370,6 +438,7 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
370
438
|
},
|
|
371
439
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
372
440
|
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin) : {}),
|
|
441
|
+
...(deps.completeLogin ? loginRoutes(deps.completeLogin) : {}),
|
|
373
442
|
},
|
|
374
443
|
error: (err) =>
|
|
375
444
|
Response.json(
|
package/index.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
7
7
|
* declared, it starts.
|
|
8
8
|
*
|
|
9
|
-
* `build` reads its port
|
|
9
|
+
* `build` reads its port, CORS origin and public URL from `env` and takes the confirm
|
|
10
10
|
* capabilities, the in-process reads and the app's auth provider from the
|
|
11
11
|
* injected context (dependency inversion). Those are optional on the contract
|
|
12
12
|
* but required here, so a context missing them throws — the loader isolates it
|
|
13
|
-
* fail-soft, and without an auth provider the surface never opens.
|
|
13
|
+
* fail-soft, and without an auth provider the surface never opens. With a
|
|
14
|
+
* public URL (`HTTP_SURFACE_PUBLIC_URL`) it also takes people's logins: it
|
|
15
|
+
* mounts `<url>/login/callback` and, once it's listening, offers it to the core.
|
|
14
16
|
*/
|
|
15
17
|
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
16
18
|
import { createHttpProvider } from "./http-provider.ts";
|
|
@@ -20,7 +22,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
|
20
22
|
export const httpChannel: ChannelPlugin = {
|
|
21
23
|
// The contract this channel is written for, as a literal: importing
|
|
22
24
|
// CHANNEL_API_VERSION would report whichever contract is installed.
|
|
23
|
-
apiVersion:
|
|
25
|
+
apiVersion: 4,
|
|
24
26
|
name: "http",
|
|
25
27
|
build: (ctx) => {
|
|
26
28
|
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
@@ -29,6 +31,10 @@ export const httpChannel: ChannelPlugin = {
|
|
|
29
31
|
if (!ctx.authenticate) {
|
|
30
32
|
throw new Error("http channel requires an auth provider (auth in mercury.config.ts)");
|
|
31
33
|
}
|
|
34
|
+
// The provider sends a person back here after they log in, so it needs
|
|
35
|
+
// the address their browser reaches the surface at.
|
|
36
|
+
const publicUrl = ctx.env.HTTP_SURFACE_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
|
37
|
+
const logins = publicUrl ? ctx.logins : undefined;
|
|
32
38
|
return createHttpProvider({
|
|
33
39
|
port: Number(ctx.env.HTTP_SURFACE_PORT ?? "4100"),
|
|
34
40
|
corsOrigin: ctx.env.HTTP_SURFACE_CORS_ORIGIN ?? "*",
|
|
@@ -36,6 +42,7 @@ export const httpChannel: ChannelPlugin = {
|
|
|
36
42
|
resolveConfirmation: ctx.resolveConfirmation,
|
|
37
43
|
reads: ctx.reads,
|
|
38
44
|
authenticate: ctx.authenticate,
|
|
45
|
+
...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
|
|
39
46
|
});
|
|
40
47
|
},
|
|
41
48
|
};
|
package/openapi.yaml
CHANGED
|
@@ -21,6 +21,15 @@ info:
|
|
|
21
21
|
anything runs.
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
## What a caller sees
|
|
25
|
+
|
|
26
|
+
Every read is scoped to the caller: their own conversations, pending
|
|
27
|
+
confirmations, tool calls and memory, and the wiki as they see it, the
|
|
28
|
+
team's common area (`curated/`) plus their own notes (`personal/`).
|
|
29
|
+
Someone else's data doesn't exist as far as a caller can tell. Only
|
|
30
|
+
`/manifest` and `/health` are the same for everyone.
|
|
31
|
+
|
|
32
|
+
|
|
24
33
|
## Conversations
|
|
25
34
|
|
|
26
35
|
A conversation is identified by an opaque, client-owned `conversationId`
|
|
@@ -28,10 +37,11 @@ info:
|
|
|
28
37
|
`<caller id>:<conversationId>`, so the same id sent by someone else is a
|
|
29
38
|
conversation of their own, and a confirmation token staged in one person's
|
|
30
39
|
conversation is never found from another's. Omitting `conversationId` on
|
|
31
|
-
`POST /turn` starts a fresh ephemeral conversation. The durable transcript
|
|
32
|
-
readable via `GET /conversation`,
|
|
33
|
-
`GET /conversations` (both backed
|
|
34
|
-
empty when the vector store is
|
|
40
|
+
`POST /turn` starts a fresh ephemeral conversation. The durable transcript
|
|
41
|
+
of one of the caller's conversations is readable via `GET /conversation`,
|
|
42
|
+
and the list of their conversations via `GET /conversations` (both backed
|
|
43
|
+
by the verbatim archive; they degrade to empty when the vector store is
|
|
44
|
+
unreachable).
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
## Streaming and cancellation
|
|
@@ -77,7 +87,9 @@ paths:
|
|
|
77
87
|
An SSE stream. Each event is one of the types in `TurnEvent`:
|
|
78
88
|
`reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
|
|
79
89
|
`tool_finish` (status of a tool call), `pending` (a staged
|
|
80
|
-
confirm-required action, with its command and token), `
|
|
90
|
+
confirm-required action, with its command and token), `login` (a
|
|
91
|
+
service the caller has to log in to first, with the link to open),
|
|
92
|
+
`final` (the
|
|
81
93
|
authoritative complete answer), and `error`.
|
|
82
94
|
content:
|
|
83
95
|
text/event-stream:
|
|
@@ -127,9 +139,10 @@ paths:
|
|
|
127
139
|
- name: id
|
|
128
140
|
in: query
|
|
129
141
|
required: true
|
|
130
|
-
description: The
|
|
142
|
+
description: The caller's `conversationId`, as `/conversations` lists it.
|
|
131
143
|
schema:
|
|
132
144
|
type: string
|
|
145
|
+
pattern: "^[A-Za-z0-9_-]{1,128}$"
|
|
133
146
|
- name: limit
|
|
134
147
|
in: query
|
|
135
148
|
required: false
|
|
@@ -146,20 +159,20 @@ paths:
|
|
|
146
159
|
"401":
|
|
147
160
|
$ref: "#/components/responses/Unauthorized"
|
|
148
161
|
"200":
|
|
149
|
-
description: The conversation's messages in chronological order.
|
|
162
|
+
description: The conversation's messages in chronological order; none when it isn't one of the caller's.
|
|
150
163
|
content:
|
|
151
164
|
application/json:
|
|
152
165
|
schema:
|
|
153
166
|
$ref: "#/components/schemas/ConversationPage"
|
|
154
167
|
"400":
|
|
155
|
-
description: Missing `?id`.
|
|
168
|
+
description: Missing or malformed `?id`.
|
|
156
169
|
content:
|
|
157
170
|
application/json:
|
|
158
171
|
schema:
|
|
159
172
|
$ref: "#/components/schemas/Error"
|
|
160
173
|
/conversations:
|
|
161
174
|
get:
|
|
162
|
-
summary: List
|
|
175
|
+
summary: List the caller's conversations (most-recently-active first)
|
|
163
176
|
parameters:
|
|
164
177
|
- name: limit
|
|
165
178
|
in: query
|
|
@@ -202,7 +215,7 @@ paths:
|
|
|
202
215
|
type: object
|
|
203
216
|
/confirmations:
|
|
204
217
|
get:
|
|
205
|
-
summary:
|
|
218
|
+
summary: The caller's pending confirmations (tokens redacted)
|
|
206
219
|
responses:
|
|
207
220
|
"401":
|
|
208
221
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -214,7 +227,7 @@ paths:
|
|
|
214
227
|
type: object
|
|
215
228
|
/tool-log:
|
|
216
229
|
get:
|
|
217
|
-
summary:
|
|
230
|
+
summary: The tool calls made in the caller's recent turns
|
|
218
231
|
responses:
|
|
219
232
|
"401":
|
|
220
233
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -226,7 +239,7 @@ paths:
|
|
|
226
239
|
type: object
|
|
227
240
|
/wiki/list:
|
|
228
241
|
get:
|
|
229
|
-
summary: List the wiki
|
|
242
|
+
summary: List the wiki files the caller can see (`curated/` and `personal/`)
|
|
230
243
|
responses:
|
|
231
244
|
"401":
|
|
232
245
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -238,11 +251,12 @@ paths:
|
|
|
238
251
|
type: object
|
|
239
252
|
/wiki/read:
|
|
240
253
|
get:
|
|
241
|
-
summary: Read one wiki
|
|
254
|
+
summary: Read one wiki file the caller can see
|
|
242
255
|
parameters:
|
|
243
256
|
- name: path
|
|
244
257
|
in: query
|
|
245
258
|
required: true
|
|
259
|
+
description: A path as `/wiki/list` gives it, e.g. `curated/team.md` or `personal/notes/plan.md`.
|
|
246
260
|
schema:
|
|
247
261
|
type: string
|
|
248
262
|
responses:
|
|
@@ -260,9 +274,15 @@ paths:
|
|
|
260
274
|
application/json:
|
|
261
275
|
schema:
|
|
262
276
|
$ref: "#/components/schemas/Error"
|
|
277
|
+
"404":
|
|
278
|
+
description: No such file among those the caller can see.
|
|
279
|
+
content:
|
|
280
|
+
application/json:
|
|
281
|
+
schema:
|
|
282
|
+
$ref: "#/components/schemas/Error"
|
|
263
283
|
/wiki/grep:
|
|
264
284
|
get:
|
|
265
|
-
summary: Grep the wiki
|
|
285
|
+
summary: Grep the wiki files the caller can see
|
|
266
286
|
parameters:
|
|
267
287
|
- name: pattern
|
|
268
288
|
in: query
|
|
@@ -287,11 +307,12 @@ paths:
|
|
|
287
307
|
$ref: "#/components/schemas/Error"
|
|
288
308
|
/memory/scroll:
|
|
289
309
|
get:
|
|
290
|
-
summary: Scroll a
|
|
310
|
+
summary: Scroll the caller's points in a memory collection
|
|
291
311
|
parameters:
|
|
292
312
|
- name: collection
|
|
293
313
|
in: query
|
|
294
314
|
required: true
|
|
315
|
+
description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
|
|
295
316
|
schema:
|
|
296
317
|
type: string
|
|
297
318
|
- name: limit
|
|
@@ -315,11 +336,35 @@ paths:
|
|
|
315
336
|
schema:
|
|
316
337
|
type: object
|
|
317
338
|
"400":
|
|
318
|
-
description: Missing `?collection
|
|
339
|
+
description: Missing `?collection`, or one that isn't kept per person.
|
|
319
340
|
content:
|
|
320
341
|
application/json:
|
|
321
342
|
schema:
|
|
322
343
|
$ref: "#/components/schemas/Error"
|
|
344
|
+
/login/callback:
|
|
345
|
+
get:
|
|
346
|
+
summary: Where a person comes back after logging in to a service
|
|
347
|
+
description: >
|
|
348
|
+
Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
|
|
349
|
+
person's browser here with `code` and `state` (or `error`); the
|
|
350
|
+
single-use state is the credential, so no bearer token is needed.
|
|
351
|
+
Answers with a plain-text page.
|
|
352
|
+
security: []
|
|
353
|
+
parameters:
|
|
354
|
+
- { name: code, in: query, schema: { type: string } }
|
|
355
|
+
- { name: state, in: query, schema: { type: string } }
|
|
356
|
+
- { name: error, in: query, schema: { type: string } }
|
|
357
|
+
responses:
|
|
358
|
+
"200":
|
|
359
|
+
description: Logged in.
|
|
360
|
+
content:
|
|
361
|
+
text/plain:
|
|
362
|
+
schema: { type: string }
|
|
363
|
+
"400":
|
|
364
|
+
description: Refused, expired, already used, or missing code/state.
|
|
365
|
+
content:
|
|
366
|
+
text/plain:
|
|
367
|
+
schema: { type: string }
|
|
323
368
|
/openapi.yaml:
|
|
324
369
|
get:
|
|
325
370
|
summary: This OpenAPI document
|
|
@@ -397,6 +442,12 @@ components:
|
|
|
397
442
|
token:
|
|
398
443
|
type: string
|
|
399
444
|
description: On `pending` — the one-time confirmation token.
|
|
445
|
+
service:
|
|
446
|
+
type: string
|
|
447
|
+
description: On `login` — the service the caller has to log in to.
|
|
448
|
+
url:
|
|
449
|
+
type: string
|
|
450
|
+
description: On `login` — the link the caller opens to log in; it comes back to `/login/callback`.
|
|
400
451
|
text:
|
|
401
452
|
type: string
|
|
402
453
|
description: On `final` — the authoritative complete answer.
|
|
@@ -452,9 +503,9 @@ components:
|
|
|
452
503
|
- type: "null"
|
|
453
504
|
ConversationSummary:
|
|
454
505
|
type: object
|
|
455
|
-
required: [
|
|
506
|
+
required: [conversationId, lastTimestamp, preview]
|
|
456
507
|
properties:
|
|
457
|
-
|
|
508
|
+
conversationId:
|
|
458
509
|
type: string
|
|
459
510
|
lastTimestamp:
|
|
460
511
|
type: string
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.36.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.40.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.40.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|