@mercury-fw/channel-http 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
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
+
3
33
  ## 0.16.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -4,7 +4,7 @@ Gives a [Mercury](https://github.com/lucabro81/mercury-fw) agent an HTTP surface
4
4
 
5
5
  On Mercury's documentation site: [its page](https://lucabro81.github.io/mercury-fw/plugins/channel-http/).
6
6
 
7
- This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.65 or later, the first whose core gives channels the app's version (`GET /version`); since 0.61 the core says when a resumed turn's answer is no longer available (`resume` below). A core with an older contract skips it, and the log says why.
7
+ This version is written for version 7 of the channel contract, the one whose conversation and memory reads are there only when the agent keeps them, and needs the framework at 0.73 or later; since 0.61 the core says when a resumed turn's answer is no longer available (`resume` below). A core with an older contract skips it, and the log says why.
8
8
 
9
9
  ```bash
10
10
  bun add @mercury-fw/channel-http
@@ -99,7 +99,7 @@ Saves the caller's time zone, for a UI's settings: body `{ timeZone }`, an IANA
99
99
 
100
100
  ## `PATCH /conversation`
101
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 a core that can't rename conversations.
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
103
 
104
104
  ## `POST /confirm`
105
105
 
@@ -107,7 +107,7 @@ Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, con
107
107
 
108
108
  ## Read-only introspection
109
109
 
110
- 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.
111
111
 
112
112
  | endpoint | `data` on success |
113
113
  |---|---|
@@ -117,11 +117,11 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
117
117
  | `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
118
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 |
119
119
  | `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
120
- | `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
120
+ | `GET /health` | `{ uptimeSeconds, memory, conversationStoreReachable, vectorIndexReachable, ollamaReachable }` — whether each answers, `null` for one the agent doesn't declare |
121
121
  | `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
122
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 |
123
123
  | `GET /wiki/grep?pattern=<regex>` | `{ matches: [...] }` — `400` if `pattern` is missing |
124
- | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of your points in a collection kept per person (verbatim archive, episodic memory, semantic facts), each with its whole payload (a verbatim answer's `displays` and `calls` included, and an interrupted answer's `interrupted: true` with the note the model gets in its `content`); `400` for any other collection |
124
+ | `GET /memory/scroll?collection=<name>&limit=<n>&offset=<cursor>` | one page of your points in a space of the vector index kept per person (`verbatim_archive`, `episodic_memory`, `semantic_facts`), each with its whole payload (a verbatim answer's `displays` and `calls` included, and an interrupted answer's `interrupted: true` with the note the model gets in its `content`); `400` for any other |
125
125
  | `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
126
126
 
127
127
  Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
package/http-server.ts CHANGED
@@ -494,6 +494,8 @@ export function readRoutes(
494
494
  const badRequest = (message: string): Response => json({ ok: false, error: message }, 400);
495
495
  const requireParam = (req: Request, name: string): string | null => new URL(req.url).searchParams.get(name);
496
496
  const options = () => preflight(corsOrigin);
497
+ // Read once, so each stays narrowed inside its routes.
498
+ const { conversation, conversations: listConversations, memoryScroll } = reads;
497
499
  const route = (GET: (req: Request, principal: Principal) => Response | Promise<Response>): ReadRoute => ({
498
500
  GET: async (req) => {
499
501
  const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
@@ -504,51 +506,56 @@ export function readRoutes(
504
506
  return {
505
507
  "/manifest": route(async (_req, principal) => json({ ok: true, manifest: await reads.manifest(principal) })),
506
508
  "/confirmations": route(async (_req, principal) => json({ ok: true, pending: await reads.pendingConfirmations(principal) })),
507
- "/conversation": {
508
- ...route(async (req, principal) => {
509
- const url = new URL(req.url);
510
- const id = url.searchParams.get("id");
511
- if (!id) return badRequest("missing ?id");
512
- if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
513
- const limit = Number(url.searchParams.get("limit") ?? "200");
514
- const offset = url.searchParams.get("offset") ?? undefined;
515
- return json({ ok: true, ...(await reads.conversation(principal, `${principal.id}:${id}`, limit, offset) as object) });
516
- }),
517
- ...(conversations === undefined
518
- ? {}
519
- : {
520
- PATCH: async (req: Request) => {
521
- const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
522
- if (principal instanceof Response) return principal;
523
- const id = new URL(req.url).searchParams.get("id");
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");
524
517
  if (!id) return badRequest("missing ?id");
525
518
  if (!CONVERSATION_ID.test(id)) return badRequest("invalid conversationId");
526
- const body = (await req.json().catch(() => null)) as { title?: unknown } | null;
527
- if (typeof body?.title !== "string") return badRequest('the body must be {"title": "<text>"}');
528
- const renamed = await conversations.rename(principal, `${principal.id}:${id}`, body.title);
529
- if (renamed.ok) return json({ ok: true, title: renamed.title });
530
- return renamed.notFound ? json({ ok: false, error: renamed.error }, 404) : badRequest(renamed.error);
531
- },
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
+ });
532
557
  }),
533
- },
534
- "/conversations": route(async (req, principal) => {
535
- const limit = Number(new URL(req.url).searchParams.get("limit") ?? "50");
536
- const { conversations } = (await reads.conversations(principal, limit)) as {
537
- conversations: Array<{ sessionKey: string; lastTimestamp: string; preview: string; title?: string }>;
538
- };
539
- const prefix = `${principal.id}:`;
540
- return json({
541
- ok: true,
542
- conversations: conversations
543
- .filter((c) => c.sessionKey.startsWith(prefix))
544
- .map((c) => ({
545
- conversationId: c.sessionKey.slice(prefix.length),
546
- lastTimestamp: c.lastTimestamp,
547
- preview: c.preview,
548
- ...(c.title === undefined ? {} : { title: c.title }),
549
- })),
550
- });
551
- }),
558
+ }),
552
559
  "/tool-log": route(async (_req, principal) => json({ ok: true, entries: await reads.toolLog(principal) })),
553
560
  // Who the caller is to the core, on a core that can say.
554
561
  ...(reads.me === undefined
@@ -593,15 +600,20 @@ export function readRoutes(
593
600
  if (!pattern) return badRequest("missing ?pattern");
594
601
  return json({ ok: true, matches: await reads.wikiGrep(principal, pattern) });
595
602
  }),
596
- "/memory/scroll": route(async (req, principal) => {
597
- const url = new URL(req.url);
598
- const collection = url.searchParams.get("collection");
599
- if (!collection) return badRequest("missing ?collection");
600
- const limit = Number(url.searchParams.get("limit") ?? "50");
601
- const offset = url.searchParams.get("offset") ?? undefined;
602
- const page = await reads.memoryScroll(principal, collection, limit, offset);
603
- return page === null ? badRequest(`unknown collection: ${collection}`) : json({ ok: true, ...(page as object) });
604
- }),
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
+ }),
605
617
  };
606
618
  }
607
619
 
package/index.ts CHANGED
@@ -23,7 +23,7 @@ export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
23
23
  export const httpChannel: ChannelPlugin = {
24
24
  // The contract this channel is written for, as a literal: importing
25
25
  // CHANNEL_API_VERSION would report whichever contract is installed.
26
- apiVersion: 6,
26
+ apiVersion: 7,
27
27
  name: "http",
28
28
  messages: HTTP_MESSAGES,
29
29
  build: (ctx) => {
package/openapi.yaml CHANGED
@@ -242,6 +242,7 @@ paths:
242
242
  /conversation:
243
243
  get:
244
244
  summary: A conversation's durable transcript
245
+ description: Only on an agent with a conversation store; `404` on any other.
245
246
  parameters:
246
247
  - name: id
247
248
  in: query
@@ -342,6 +343,7 @@ paths:
342
343
  /conversations:
343
344
  get:
344
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.
345
347
  parameters:
346
348
  - name: limit
347
349
  in: query
@@ -378,6 +380,21 @@ paths:
378
380
  application/json:
379
381
  schema:
380
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
381
398
  /manifest:
382
399
  get:
383
400
  summary: The plugins, tools and skills the caller is offered
@@ -586,11 +603,12 @@ paths:
586
603
  /memory/scroll:
587
604
  get:
588
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.
589
607
  parameters:
590
608
  - name: collection
591
609
  in: query
592
610
  required: true
593
- description: One of the collections kept per person (the verbatim archive, episodic memory, semantic facts), by its configured name.
611
+ description: One of the vector index's spaces kept per person, by name (`verbatim_archive`, `episodic_memory`, `semantic_facts`).
594
612
  schema:
595
613
  type: string
596
614
  - name: limit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.16.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.70.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.73.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.70.0",
25
- "@mercury-fw/plugin-types": "0.70.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"