@mercury-fw/channel-http 0.3.0 → 0.4.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,23 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 521db42: Isolation between the people an agent talks to.
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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>`.
13
+ - 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.
14
+ - `@mercury-fw/confirm-engine`: the store's `stage`, `take` and `pending` take the owner's key.
15
+ - 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.
16
+ - `@mercury-fw/channel-google-chat`: on channel contract 3.
17
+ - 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.
18
+ - `mfw vault read` refuses a path outside the vault.
19
+ - 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.
20
+
3
21
  ## 0.3.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -76,20 +76,20 @@ Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, con
76
76
 
77
77
  ## Read-only introspection
78
78
 
79
- All `GET`, all JSON, all reporting state already held in-process. They need an authenticated caller, but each one still shows every person's data: scoping them per person is the next step.
79
+ 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
80
 
81
81
  | endpoint | `data` on success |
82
82
  |---|---|
83
- | `GET /conversation?id=<sessionKey>&limit=<n>&offset=<cursor>` | `{ messages: [{ role, content, timestamp }], nextOffset }` — a conversation's durable transcript in order; `400` if `id` is missing. Any session key `/conversations` lists opens here, whatever channel it came from |
84
- | `GET /conversations?limit=<n>` | `{ conversations: [{ sessionKey, lastTimestamp, preview }] }` — known conversations, most-recently-active first |
83
+ | `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 |
84
+ | `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
85
85
  | `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
86
- | `GET /confirmations` | `{ pending: [{ sessionKey, binary, args, expiresAt }] }` — tokens are deliberately never included |
87
- | `GET /tool-log` | `{ entries: [...] }` |
86
+ | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
87
+ | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
88
88
  | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
89
- | `GET /wiki/list` | `{ files: [...] }` |
90
- | `GET /wiki/read?path=<vault-path>` | `{ content }` — `400` if `path` is missing |
89
+ | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
90
+ | `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
91
  | `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
92
- | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of the named episodic/semantic collection |
92
+ | `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
93
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
94
94
 
95
95
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
@@ -19,11 +19,11 @@
19
19
  * Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
20
20
  * package never imports the app.
21
21
  */
22
- import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome } from "@mercury-fw/channel-types";
22
+ import { type HandleTurn, type Authenticate, type ChannelHostReads, type ConfirmOutcome, type Principal } from "@mercury-fw/channel-types";
23
23
  /** 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, userId: string) => Promise<string | null>;
24
+ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
25
25
  /** 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, userId: string) => Promise<ConfirmOutcome>;
26
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
27
27
  export type TurnRequestDeps = {
28
28
  handleTurn: HandleTurn;
29
29
  /** Who is calling, injected from the app's auth provider; `null` = refused. */
@@ -81,7 +81,10 @@ type ReadRoute = {
81
81
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
82
82
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
83
83
  * reach them. Every `GET` asks `authenticate` first and answers 401 without
84
- * calling its getter when nobody is calling; the preflight doesn't ask.
84
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
85
+ * caller's principal goes to every per-person getter, so the core answers with
86
+ * their data only; a conversation is named by its conversationId, as on
87
+ * `/turn`, and its session key is built from the caller.
85
88
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
86
89
  * same for a 400.
87
90
  */
package/http-server.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type Authenticate,
28
28
  type ChannelHostReads,
29
29
  type ConfirmOutcome,
30
+ type Principal,
30
31
  } from "@mercury-fw/channel-types";
31
32
 
32
33
  /**
@@ -43,9 +44,9 @@ function forLog(text: string): string {
43
44
  }
44
45
 
45
46
  /** 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, userId: string) => Promise<string | null>;
47
+ export type ConfirmFn = (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
47
48
  /** 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, userId: string) => Promise<ConfirmOutcome>;
49
+ export type ResolveConfirmationFn = (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
49
50
 
50
51
  export type TurnRequestDeps = {
51
52
  handleTurn: HandleTurn;
@@ -169,7 +170,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
169
170
 
170
171
  // Same deterministic interception as every other channel — a
171
172
  // previously-approved mutation must never depend on the model.
172
- const confirmReply = await deps.confirm(text, sessionKey, principal.id);
173
+ const confirmReply = await deps.confirm(text, sessionKey, principal);
173
174
  if (confirmReply !== null) {
174
175
  send("final", { text: confirmReply });
175
176
  close();
@@ -263,7 +264,7 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
263
264
  if (!CONVERSATION_ID.test(conversationId)) {
264
265
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
265
266
  }
266
- const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal.id);
267
+ const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal);
267
268
  switch (outcome.status) {
268
269
  case "not-a-token":
269
270
  case "not-found":
@@ -285,7 +286,10 @@ type ReadRoute = {
285
286
  * Builds the read-only routes, each carrying CORS headers on its `GET` and a
286
287
  * shared `OPTIONS` preflight so a browser UI on `corsOrigin` (default `*`) can
287
288
  * reach them. Every `GET` asks `authenticate` first and answers 401 without
288
- * calling its getter when nobody is calling; the preflight doesn't ask.
289
+ * calling its getter when nobody is calling; the preflight doesn't ask. The
290
+ * caller's principal goes to every per-person getter, so the core answers with
291
+ * their data only; a conversation is named by its conversationId, as on
292
+ * `/turn`, and its session key is built from the caller.
289
293
  * `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
290
294
  * same for a 400.
291
295
  */
@@ -296,45 +300,60 @@ export function readRoutes(reads: ChannelHostReads, authenticate: Authenticate,
296
300
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
297
301
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
298
302
  const options = () => preflight(corsOrigin);
299
- const route = (GET: ReadRoute["GET"]): ReadRoute => ({
300
- GET: async (req) => ((await authenticate(req)) === null ? unauthorized(corsOrigin) : GET(req)),
303
+ const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
304
+ GET: async (req) => {
305
+ const principal = await authenticate(req);
306
+ return principal === null ? unauthorized(corsOrigin) : GET(req, principal);
307
+ },
301
308
  OPTIONS: options,
302
309
  });
303
310
  return {
304
311
  "/manifest": route(() => json({ ok: true, manifest: reads.manifest() })),
305
- "/confirmations": route(() => json({ ok: true, pending: reads.pendingConfirmations() })),
306
- "/conversation": route(async (req) => {
312
+ "/confirmations": route((_req, principal) => json({ ok: true, pending: reads.pendingConfirmations(principal) })),
313
+ "/conversation": route(async (req, principal) => {
307
314
  const url = new URL(req.url);
308
315
  const id = url.searchParams.get("id");
309
316
  if (!id) return badRequest("missing ?id");
317
+ if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
310
318
  const limit = Number(url.searchParams.get("limit") ?? "200");
311
319
  const offset = url.searchParams.get("offset") ?? undefined;
312
- return json({ ok: true, ...(await reads.conversation(id, limit, offset) as object) });
320
+ return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
313
321
  }),
314
- "/conversations": route(async (req) => {
322
+ "/conversations": route(async (req, principal) => {
315
323
  const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
316
- return json({ ok: true, ...(await reads.conversations(limit) as object) });
324
+ const { conversations } = (await reads.conversations(principal, limit)) as {
325
+ conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string }>;
326
+ };
327
+ const prefix = `${principal.id}:`;
328
+ return json({
329
+ ok: true,
330
+ conversations: conversations
331
+ .filter((c) => c.sessionKey.startsWith(prefix))
332
+ .map((c) => ({ conversationId: c.sessionKey.slice(prefix.length), lastTimestamp: c.lastTimestamp, preview: c.preview })),
333
+ });
317
334
  }),
318
- "/tool-log": route(() => json({ ok: true, entries: reads.toolLog() })),
335
+ "/tool-log": route((_req, principal) => json({ ok: true, entries: reads.toolLog(principal) })),
319
336
  "/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) => {
337
+ "/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
338
+ "/wiki/read": route(async (req, principal) => {
322
339
  const path = requireParam(req, "path");
323
340
  if (!path) return badRequest("missing ?path");
324
- return json({ ok: true, content: await reads.wikiRead(path) });
341
+ const content = await reads.wikiRead(principal, path);
342
+ return content === null ? json({ ok: false, error: "not found" }, 404) : json({ ok: true, content });
325
343
  }),
326
- "/wiki/grep": route(async (req) => {
344
+ "/wiki/grep": route(async (req, principal) => {
327
345
  const pattern = requireParam(req, "pattern");
328
346
  if (!pattern) return badRequest("missing ?pattern");
329
- return json({ ok: true, matches: await reads.wikiGrep(pattern) });
347
+ return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
330
348
  }),
331
- "/memory/scroll": route(async (req) => {
349
+ "/memory/scroll": route(async (req, principal) => {
332
350
  const url = new URL(req.url);
333
351
  const collection = url.searchParams.get("collection");
334
352
  if (!collection) return badRequest("missing ?collection");
335
353
  const limit = Number(url.searchParams.get("limit") ?? "50");
336
354
  const offset = url.searchParams.get("offset") ?? undefined;
337
- return json({ ok: true, ...(await reads.memoryScroll(collection, limit, offset) as object) });
355
+ const page = await reads.memoryScroll(principal, collection, limit, offset);
356
+ return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
338
357
  }),
339
358
  };
340
359
  }
package/index.ts CHANGED
@@ -20,7 +20,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
20
20
  export const httpChannel: ChannelPlugin = {
21
21
  // The contract this channel is written for, as a literal: importing
22
22
  // CHANNEL_API_VERSION would report whichever contract is installed.
23
- apiVersion: 2,
23
+ apiVersion: 3,
24
24
  name: "http",
25
25
  build: (ctx) => {
26
26
  if (!ctx.resolveConfirmation || !ctx.reads) {
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 of a conversation is
32
- readable via `GET /conversation`, and the list of known conversations via
33
- `GET /conversations` (both backed by the verbatim archive; they degrade to
34
- empty when the vector store is unreachable).
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
@@ -127,9 +137,10 @@ paths:
127
137
  - name: id
128
138
  in: query
129
139
  required: true
130
- description: The session key whose messages to return, as `/conversations` lists it (any channel's; an HTTP one is `<caller id>:<conversationId>`).
140
+ description: The caller's `conversationId`, as `/conversations` lists it.
131
141
  schema:
132
142
  type: string
143
+ pattern: "^[A-Za-z0-9_-]{1,128}$"
133
144
  - name: limit
134
145
  in: query
135
146
  required: false
@@ -146,20 +157,20 @@ paths:
146
157
  "401":
147
158
  $ref: "#/components/responses/Unauthorized"
148
159
  "200":
149
- description: The conversation's messages in chronological order.
160
+ description: The conversation's messages in chronological order; none when it isn't one of the caller's.
150
161
  content:
151
162
  application/json:
152
163
  schema:
153
164
  $ref: "#/components/schemas/ConversationPage"
154
165
  "400":
155
- description: Missing `?id`.
166
+ description: Missing or malformed `?id`.
156
167
  content:
157
168
  application/json:
158
169
  schema:
159
170
  $ref: "#/components/schemas/Error"
160
171
  /conversations:
161
172
  get:
162
- summary: List known conversations (most-recently-active first)
173
+ summary: List the caller's conversations (most-recently-active first)
163
174
  parameters:
164
175
  - name: limit
165
176
  in: query
@@ -202,7 +213,7 @@ paths:
202
213
  type: object
203
214
  /confirmations:
204
215
  get:
205
- summary: Currently pending confirmations (tokens redacted)
216
+ summary: The caller's pending confirmations (tokens redacted)
206
217
  responses:
207
218
  "401":
208
219
  $ref: "#/components/responses/Unauthorized"
@@ -214,7 +225,7 @@ paths:
214
225
  type: object
215
226
  /tool-log:
216
227
  get:
217
- summary: Recent tool-call log entries
228
+ summary: The tool calls made in the caller's recent turns
218
229
  responses:
219
230
  "401":
220
231
  $ref: "#/components/responses/Unauthorized"
@@ -226,7 +237,7 @@ paths:
226
237
  type: object
227
238
  /wiki/list:
228
239
  get:
229
- summary: List the wiki vault's files
240
+ summary: List the wiki files the caller can see (`curated/` and `personal/`)
230
241
  responses:
231
242
  "401":
232
243
  $ref: "#/components/responses/Unauthorized"
@@ -238,11 +249,12 @@ paths:
238
249
  type: object
239
250
  /wiki/read:
240
251
  get:
241
- summary: Read one wiki vault file
252
+ summary: Read one wiki file the caller can see
242
253
  parameters:
243
254
  - name: path
244
255
  in: query
245
256
  required: true
257
+ description: A path as `/wiki/list` gives it, e.g. `curated/team.md` or `personal/notes/plan.md`.
246
258
  schema:
247
259
  type: string
248
260
  responses:
@@ -260,9 +272,15 @@ paths:
260
272
  application/json:
261
273
  schema:
262
274
  $ref: "#/components/schemas/Error"
275
+ "404":
276
+ description: No such file among those the caller can see.
277
+ content:
278
+ application/json:
279
+ schema:
280
+ $ref: "#/components/schemas/Error"
263
281
  /wiki/grep:
264
282
  get:
265
- summary: Grep the wiki vault
283
+ summary: Grep the wiki files the caller can see
266
284
  parameters:
267
285
  - name: pattern
268
286
  in: query
@@ -287,11 +305,12 @@ paths:
287
305
  $ref: "#/components/schemas/Error"
288
306
  /memory/scroll:
289
307
  get:
290
- summary: Scroll a Qdrant memory collection
308
+ summary: Scroll the caller's points in a memory collection
291
309
  parameters:
292
310
  - name: collection
293
311
  in: query
294
312
  required: true
313
+ description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
295
314
  schema:
296
315
  type: string
297
316
  - name: limit
@@ -315,7 +334,7 @@ paths:
315
334
  schema:
316
335
  type: object
317
336
  "400":
318
- description: Missing `?collection`.
337
+ description: Missing `?collection`, or one that isn't kept per person.
319
338
  content:
320
339
  application/json:
321
340
  schema:
@@ -452,9 +471,9 @@ components:
452
471
  - type: "null"
453
472
  ConversationSummary:
454
473
  type: object
455
- required: [sessionKey, lastTimestamp, preview]
474
+ required: [conversationId, lastTimestamp, preview]
456
475
  properties:
457
- sessionKey:
476
+ conversationId:
458
477
  type: string
459
478
  lastTimestamp:
460
479
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.35.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.36.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.35.0",
25
- "@mercury-fw/plugin-types": "0.35.0",
24
+ "@mercury-fw/channel-types": "0.36.0",
25
+ "@mercury-fw/plugin-types": "0.36.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"