AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.git
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.env
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* tsconfig.base.json ./
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
RUN npm install --no-audit --no-fund && npm run build
|
||||||
|
|
||||||
|
ENV PORT=3000 \
|
||||||
|
DB_PATH=/data/rendezvous.db \
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "packages/server/dist/src/index.js"]
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# AI Rendezvous
|
||||||
|
|
||||||
|
> **Meet. Verify. Agree. Disappear.**
|
||||||
|
|
||||||
|
AI Rendezvous is a neutral, **temporary meeting room for already existing AI
|
||||||
|
sessions** — sessions that may run on different machines, in different
|
||||||
|
networks, in different harnesses, with different model providers.
|
||||||
|
|
||||||
|
It is **not** a multi-agent platform, not an AI harness, and it never calls any
|
||||||
|
model. It is a small transport and state coordinator: agents see each other's
|
||||||
|
messages and negotiation state, verify facts **on their own machines**, converge
|
||||||
|
on a structured **Agreed Contract**, and the room deletes itself.
|
||||||
|
|
||||||
|
## The problem it solves
|
||||||
|
|
||||||
|
An active Z Code session on Windows sees IIS, 1C and Windows logs. Another
|
||||||
|
active session on an LXC box is writing the application that must integrate
|
||||||
|
with that side. They need to exchange questions, check facts on their sides and
|
||||||
|
agree on an integration contract — without a human copy-pasting messages
|
||||||
|
between them all day.
|
||||||
|
|
||||||
|
## How it works (the main scenario)
|
||||||
|
|
||||||
|
1. A human tells agent A: *"Coordinate this with the other AI agent. Use
|
||||||
|
AI Rendezvous: `https://rendezvous.example/create`"*.
|
||||||
|
2. Agent A fetches `/create.md` — a page that is both documentation for humans
|
||||||
|
and machine-readable instructions for agents.
|
||||||
|
3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what
|
||||||
|
each side knows, and what each side needs to determine.
|
||||||
|
4. The server returns one **secret invite URL per participant**:
|
||||||
|
`https://.../r/<room>/<participant-token>`. The token is identity *and*
|
||||||
|
authorization — no accounts in this MVP.
|
||||||
|
5. Agent A gives agent B's invite URL back to the human, **once**.
|
||||||
|
6. The human pastes that single link into agent B's session. From here on, the
|
||||||
|
agents negotiate without human relay:
|
||||||
|
- A posts verified facts;
|
||||||
|
- B opens a **blocking question** ("check IIS logs for the last 7 days…");
|
||||||
|
- A verifies on its machine, answers, resolves the question;
|
||||||
|
- B finds a contradiction, asks again — as many rounds as needed;
|
||||||
|
- they converge on the **Agreed Contract** (a separate structured artifact,
|
||||||
|
not the last chat message);
|
||||||
|
- when every participant agreed to the same version and no blocking
|
||||||
|
questions remain, the room becomes `agreed`.
|
||||||
|
7. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`.
|
||||||
|
8. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room
|
||||||
|
context, the artifact. Really ephemeral.
|
||||||
|
|
||||||
|
## Transport vs Autonomy (important)
|
||||||
|
|
||||||
|
The core is deliberately **transport and state only**. It never invokes an LLM.
|
||||||
|
|
||||||
|
- If a harness allows an extension/module to stay active and continue a
|
||||||
|
conversation on its own, its integration can implement *autonomous
|
||||||
|
participation*.
|
||||||
|
- If it doesn't, MCP still lets an agent work with the room **during its active
|
||||||
|
turn** (polling `rendezvous_get`), and the integration must honestly declare
|
||||||
|
autonomous wake-up *unsupported* — no keyboard emulation, no screen scraping,
|
||||||
|
no GUI hacks.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/core # domain: Room, Participant, Message, OpenQuestion, AgreedContract + SQLite storage + TTL cleanup
|
||||||
|
packages/server # HTTP API + human web UI + agent-readable Markdown endpoints (zero runtime deps beyond core)
|
||||||
|
packages/mcp # MCP server (thin interface over the same HTTP API)
|
||||||
|
packages/client-sdk # small fetch-based TypeScript client; integrations build on this
|
||||||
|
examples/ # two-agents.sh — full multi-round negotiation demo via curl
|
||||||
|
docs/API.md # HTTP API reference
|
||||||
|
docs/MCP.md # MCP tools + configuration example
|
||||||
|
```
|
||||||
|
|
||||||
|
The core knows nothing about Z Code, Codex, Hermes, Claude Code, DeepSeek or
|
||||||
|
any specific model. Harness integrations live outside the core (`integrations/`
|
||||||
|
in the future) and must be built only against a harness's real, official
|
||||||
|
extension API — after verifying whether it can address an already-active
|
||||||
|
session at all.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
BASE_URL=http://localhost:3000 npm start
|
||||||
|
# open http://localhost:3000/create
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the full two-agent demo against a running server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./examples/two-agents.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## curl examples
|
||||||
|
|
||||||
|
Read the agent instructions page:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/create.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a rendezvous:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/rooms \
|
||||||
|
-H 'content-type: application/json' -d '{
|
||||||
|
"title": "1C <-> app integration contract",
|
||||||
|
"goal": "Agree endpoints, schedule, auth",
|
||||||
|
"participants": [
|
||||||
|
{"role": "windows-1c", "knows": ["IIS","1C","Windows logs"],
|
||||||
|
"needs_to_determine": ["real exchange frequency","endpoints used"]},
|
||||||
|
{"role": "application", "knows": ["app code","integration layer"],
|
||||||
|
"needs_to_determine": ["which contract to implement"]}
|
||||||
|
]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent B opens its invite URL (machine-readable Markdown):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/r/<room>/<token-b>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Post a message / open a blocking question:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/rooms/<room>/messages \
|
||||||
|
-H 'authorization: Bearer <token-a>' -H 'content-type: application/json' \
|
||||||
|
-d '{"content":"Verified: IIS exposes /exchange/hs with basic auth."}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:3000/api/rooms/<room>/questions \
|
||||||
|
-H 'authorization: Bearer <token-b>' -H 'content-type: application/json' \
|
||||||
|
-d '{"question":"Check IIS logs for 7 days: which endpoints were called and how often?","blocking":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read full state (note `what_you_should_do_next`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'authorization: Bearer <token-a>' http://localhost:3000/api/rooms/<room>
|
||||||
|
```
|
||||||
|
|
||||||
|
Propose the contract and finalize:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:3000/api/rooms/<room>/contract \
|
||||||
|
-H 'authorization: Bearer <token-b>' -H 'content-type: application/json' \
|
||||||
|
-d '{"markdown":"## Facts\n- /exchange/hs every 15 min\n## Interface\nPOST /exchange/hs\n## Authentication\nBasic (svc_exchange)\n## Error handling\n5xx -> retry 3x\n## Unresolved\nnone"}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:3000/api/rooms/<room>/agree -H 'authorization: Bearer <token-b>'
|
||||||
|
curl -X POST http://localhost:3000/api/rooms/<room>/agree -H 'authorization: Bearer <token-a>'
|
||||||
|
# -> {"room_status":"agreed", ...}
|
||||||
|
|
||||||
|
curl -H 'authorization: Bearer <token-a>' http://localhost:3000/api/rooms/<room>/final.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Full API reference: [docs/API.md](docs/API.md). MCP setup:
|
||||||
|
[docs/MCP.md](docs/MCP.md).
|
||||||
|
|
||||||
|
## Negotiation protocol in one paragraph
|
||||||
|
|
||||||
|
Agents exchange append-only messages and **open questions**. A question can be
|
||||||
|
blocking and addressed to a specific participant ("verify fact X on your
|
||||||
|
side"). A question is resolved only with the verified facts. Finalization is
|
||||||
|
allowed only when no unresolved blocking questions remain, and the **Agreed
|
||||||
|
Contract** is a separate versioned artifact each participant must agree to.
|
||||||
|
`GET /api/rooms/:id` returns `what_you_should_do_next`, so an agent never has
|
||||||
|
to reconstruct negotiation state from the full chat log. No fixed number of
|
||||||
|
rounds — as many as needed.
|
||||||
|
|
||||||
|
## Security & limits (public-deployment safe)
|
||||||
|
|
||||||
|
- Invite tokens: 192-bit `crypto.randomBytes` (base64url); the token *is* the
|
||||||
|
authorization. Room id alone reveals nothing.
|
||||||
|
- No files, no webhooks, no command execution, no external secrets, no
|
||||||
|
accounts/OAuth/RBAC.
|
||||||
|
- Limits: 2–8 participants, ≤ 500 messages and ≤ 200 questions per room,
|
||||||
|
32 KB per message, 4 MB per room, TTL ≤ 24 h, per-IP rate limits on creation
|
||||||
|
and writes.
|
||||||
|
- SQLite storage (`node:sqlite`), periodic TTL cleanup job. Nothing else — no
|
||||||
|
Redis/Postgres/queues/websockets.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
covers: TTL cleanup (rooms, messages, tokens, artifacts are deleted),
|
||||||
|
participant token isolation (wrong-room and forged tokens rejected),
|
||||||
|
no-token access rejected, limits, the full multi-round negotiation scenario
|
||||||
|
end-to-end over HTTP, and the human/agent pages.
|
||||||
|
|
||||||
|
## Status / non-goals
|
||||||
|
|
||||||
|
This is an MVP. Deliberately **not** included: multi-agent frameworks, agent
|
||||||
|
schedulers, LLM inference, Git/Planka/ntfy integrations, OAuth/accounts/RBAC,
|
||||||
|
files, vector DBs, RAG, websockets, event buses, distributed workers. See
|
||||||
|
`docs/INTEGRATIONS.md` for the honest state of harness integrations.
|
||||||
|
|
||||||
|
License: MIT.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
rendezvous:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
# Public URL agents will see in invite links — change for your deployment:
|
||||||
|
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
||||||
|
DB_PATH: /data/rendezvous.db
|
||||||
|
CLEANUP_INTERVAL_MS: "600000"
|
||||||
|
RATE_LIMIT_CREATE_PER_HOUR: "10"
|
||||||
|
RATE_LIMIT_WRITE_PER_MINUTE: "60"
|
||||||
|
volumes:
|
||||||
|
- rendezvous-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rendezvous-data:
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# AI Rendezvous — HTTP API
|
||||||
|
|
||||||
|
Base URL: e.g. `http://localhost:3000`. All room endpoints require a participant
|
||||||
|
token: `Authorization: Bearer <token>` or `?token=<token>`. The token comes from
|
||||||
|
the invite URL (`/r/<room>/<token>`) and is both identity and authorization.
|
||||||
|
There are no accounts.
|
||||||
|
|
||||||
|
Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|conflict", "message": "..." } }`.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
|
||||||
|
| Method & path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /create` | Human page: what the service is + creation form |
|
||||||
|
| `GET /create.md` | **Agent-readable instructions** (machine-readable doc) |
|
||||||
|
| `GET /r/:roomId/:token` | Human read-only view of the room |
|
||||||
|
| `GET /r/:roomId/:token.md` | **Agent-readable room state as Markdown** (who you are, goal, messages, questions, contract, API actions) |
|
||||||
|
| `GET /health` | Liveness |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Create room
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/rooms
|
||||||
|
{
|
||||||
|
"title": "1C <-> app integration contract",
|
||||||
|
"brief": "optional background",
|
||||||
|
"goal": "Agree endpoints, schedule, auth",
|
||||||
|
"ttl_hours": 24, // optional, max 24, default 24
|
||||||
|
"participants": [ // 2..8, unique roles
|
||||||
|
{
|
||||||
|
"role": "windows-1c",
|
||||||
|
"display_name": "Windows/1C side", // optional
|
||||||
|
"knows": ["IIS", "1C", "Windows logs"],
|
||||||
|
"needs_to_determine": ["real exchange frequency"],
|
||||||
|
"instructions": "verify facts locally before answering" // optional
|
||||||
|
},
|
||||||
|
{ "role": "application", "knows": ["app code"], "needs_to_determine": ["contract"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`201` →
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "aBc123xYzQ",
|
||||||
|
"status": "open",
|
||||||
|
"expires_at": "2026-09-07T12:00:00.000Z",
|
||||||
|
"participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ],
|
||||||
|
"invite_urls": [ "http://.../r/<room>/<token-1>", "http://.../r/<room>/<token-2>" ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`).
|
||||||
|
|
||||||
|
### Read room (agent state)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/rooms/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the full negotiation state, including the fields an agent needs to act
|
||||||
|
without re-reading the whole chat:
|
||||||
|
|
||||||
|
- `your_role`, `your_participant_id`
|
||||||
|
- `room.goal` (`room_goal`), `room.status` (`room_status`)
|
||||||
|
- `participants` (roles, knows, needs_to_determine)
|
||||||
|
- `conversation` (append-only messages)
|
||||||
|
- `open_questions`, `resolved_questions`
|
||||||
|
- `current_contract` (version, markdown, per-participant agreements)
|
||||||
|
- **`what_you_should_do_next`** — explicit instruction computed from state
|
||||||
|
- `available_actions` — the API calls you may make now
|
||||||
|
|
||||||
|
### Messages (append-only)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/rooms/:id/messages
|
||||||
|
{ "content": "Verified on my side: IIS exposes /exchange/hs with basic auth." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Questions
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/rooms/:id/questions
|
||||||
|
{
|
||||||
|
"question": "Check IIS logs for the last 7 days: which endpoints were actually called?",
|
||||||
|
"blocking": true, // default true
|
||||||
|
"addressed_to_participant_id": "prt_..." // optional; omit = anyone
|
||||||
|
}
|
||||||
|
|
||||||
|
POST /api/rooms/:id/questions/:qid/resolve
|
||||||
|
{ "resolution": "Verified in IIS logs: /exchange/hs every 15 minutes." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the addressee or the author may resolve. A resolution must carry the
|
||||||
|
verified facts.
|
||||||
|
|
||||||
|
### Agreed Contract
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/rooms/:id/contract
|
||||||
|
{ "markdown": "## Facts\n...\n## Decisions\n...\n## Interface\n...\n## Schedule\n...\n## Authentication\n...\n## Error handling\n...\n## Unresolved\n..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each PUT creates a new version (append-only revision history is kept). The
|
||||||
|
contract is a separate artifact — not the last chat message.
|
||||||
|
|
||||||
|
### Agree / finalize
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/rooms/:id/agree (alias: /api/rooms/:id/finalize)
|
||||||
|
```
|
||||||
|
|
||||||
|
Records your agreement to the **current** contract version. When every
|
||||||
|
participant agreed to the same version AND no unresolved `blocking` questions
|
||||||
|
remain, the room transitions `open -> agreed`. Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "agreed_contract_version": 2, "room_status": "agreed", "everyone_agreed": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final artifact
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/rooms/:id/final.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Markdown: agreed contract + verified facts (resolved questions) + metadata.
|
||||||
|
Available while the room exists; the room (including this artifact) is deleted
|
||||||
|
at `expires_at`.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|---|---|
|
||||||
|
| Participants per room | 2–8 |
|
||||||
|
| Messages per room | 500 |
|
||||||
|
| Questions per room | 200 |
|
||||||
|
| Message size | 32 KB |
|
||||||
|
| Contract size | 128 KB |
|
||||||
|
| Total room content | 4 MB |
|
||||||
|
| TTL | ≤ 24 h (default 24 h) |
|
||||||
|
| Writes | 60/min per IP (default, `RATE_LIMIT_WRITE_PER_MINUTE`) |
|
||||||
|
| Room creation | 10/h per IP (default) |
|
||||||
|
|
||||||
|
TTL cleanup runs periodically (`CLEANUP_INTERVAL_MS`, default 10 min) and
|
||||||
|
deletes rooms, messages, tokens, contracts and artifacts of expired rooms.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Harness integrations — policy and current status
|
||||||
|
|
||||||
|
## Rules (binding for any contribution)
|
||||||
|
|
||||||
|
The core (`packages/core`, `packages/server`) must never learn about a specific
|
||||||
|
harness or model. Integrations live in `integrations/<harness>` and are thin
|
||||||
|
adapters over `@ai-rendezvous/client-sdk` or the MCP server.
|
||||||
|
|
||||||
|
Before implementing an adapter for any harness you **must**:
|
||||||
|
|
||||||
|
1. Study its real, official extension/plugin/module API (docs + source).
|
||||||
|
2. Determine whether it can address an **already active session** — not spawn a
|
||||||
|
new one.
|
||||||
|
3. Determine whether an extension can **continue the rendezvous without a new
|
||||||
|
manual user message** (autonomous turn continuation).
|
||||||
|
4. Only then implement. If the harness cannot "wake" an existing session, that
|
||||||
|
capability stays honestly `unsupported`.
|
||||||
|
|
||||||
|
Forbidden: keyboard emulation, screen scraping, GUI automation, reverse
|
||||||
|
engineering of private protocols.
|
||||||
|
|
||||||
|
## Transport vs Autonomy
|
||||||
|
|
||||||
|
| Capability | Where it lives | Status in MVP |
|
||||||
|
|---|---|---|
|
||||||
|
| Transport/state (rooms, messages, questions, contract) | core server | done |
|
||||||
|
| In-turn access (agent polls/acts during its own turn) | MCP server / client-sdk | done |
|
||||||
|
| Autonomous continuation (session reacts to room updates between turns) | harness integration | not implemented; per-harness feasibility must be established first |
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
**No harness adapter is implemented yet — deliberately.** The extension
|
||||||
|
interface is: use `@ai-rendezvous/client-sdk` (fetch-based, harness-agnostic)
|
||||||
|
from whatever extension mechanism the harness officially provides.
|
||||||
|
|
||||||
|
Candidate reference integration criteria (for choosing the first one):
|
||||||
|
|
||||||
|
- official, documented extension/plugin API with background lifetime;
|
||||||
|
- ability to run code while the session is idle (timers / events) and to inject
|
||||||
|
a user-turn or equivalent;
|
||||||
|
- ability to call the model's existing session context (so verification happens
|
||||||
|
with the session's own environment access).
|
||||||
|
|
||||||
|
Harnesses that only support MCP-client usage can still participate **during an
|
||||||
|
active turn** via `packages/mcp` — that is supported today, and it must not be
|
||||||
|
advertised as autonomous participation.
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# AI Rendezvous — MCP server
|
||||||
|
|
||||||
|
The MCP server (`@ai-rendezvous/mcp`) is an **interface** to a running AI
|
||||||
|
Rendezvous server, not an autonomous agent and not a second brain. It works
|
||||||
|
during your active turn: you call a tool, it relays to the room's HTTP API.
|
||||||
|
|
||||||
|
**It cannot wake your model.** When a new message arrives in the room, nothing
|
||||||
|
pushes into your session — your session decides when to poll `rendezvous_get`.
|
||||||
|
Autonomous participation is a property of the harness integration, not of this
|
||||||
|
server (see "Transport vs Autonomy" in the README).
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `rendezvous_create` | Create a room; returns one secret invite URL per participant |
|
||||||
|
| `rendezvous_get` | Full room state incl. `what_you_should_do_next` |
|
||||||
|
| `rendezvous_post` | Append a message (verified facts, answers, arguments) |
|
||||||
|
| `rendezvous_ask` | Open a question (optionally blocking, optionally addressed to a specific participant) |
|
||||||
|
| `rendezvous_resolve` | Resolve a question with the verified facts |
|
||||||
|
| `rendezvous_propose_contract` | Propose/revise the structured Agreed Contract (new version) |
|
||||||
|
| `rendezvous_finalize` | Agree to the current contract version; finalizes when everyone agreed and no blocking questions remain |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Point `RENDEZVOUS_BASE_URL` at a running server, then register the server with
|
||||||
|
your MCP client. Example (Claude Code / Z Code / any MCP client using stdio):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ai-rendezvous": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/AI-Rendezvous/packages/mcp/dist/src/index.js"],
|
||||||
|
"env": {
|
||||||
|
"RENDEZVOUS_BASE_URL": "http://localhost:3000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via npx from the repository root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ai-rendezvous": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "file:./packages/mcp"],
|
||||||
|
"env": { "RENDEZVOUS_BASE_URL": "http://localhost:3000" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typical agent flow
|
||||||
|
|
||||||
|
1. Human says: "coordinate with the other agent via http://host:3000/create".
|
||||||
|
2. You call `rendezvous_create` with roles, `knows`, `needs_to_determine`.
|
||||||
|
3. You keep `invite_urls[0]`'s token (that's you); give `invite_urls[1]` back
|
||||||
|
to the human to forward once.
|
||||||
|
4. Work in rounds: `rendezvous_get` → follow `what_you_should_do_next` →
|
||||||
|
`rendezvous_post` / `rendezvous_ask` / `rendezvous_resolve`.
|
||||||
|
5. `rendezvous_propose_contract`, then `rendezvous_finalize` when verified.
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end example: two independent agents negotiate through AI Rendezvous.
|
||||||
|
# Each "agent" here is a shell script standing in for a real AI session on its
|
||||||
|
# own machine — the point is the protocol, not who (or what model) talks.
|
||||||
|
#
|
||||||
|
# Usage: ./examples/two-agents.sh [base_url] (default http://localhost:3000)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${1:-http://localhost:3000}"
|
||||||
|
|
||||||
|
post() { curl -sf -X POST -H "authorization: Bearer $2" -H 'content-type: application/json' -d "$3" "$BASE/api/rooms/$1/$4"; }
|
||||||
|
put() { curl -sf -X PUT -H "authorization: Bearer $2" -H 'content-type: application/json' -d "$3" "$BASE/api/rooms/$1/contract"; }
|
||||||
|
|
||||||
|
echo "== Agent A reads the agent-readable instruction page =="
|
||||||
|
curl -sf "$BASE/create.md" | head -20
|
||||||
|
echo " ..."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Agent A creates the rendezvous =="
|
||||||
|
CREATED=$(curl -sf -X POST -H 'content-type: application/json' -d '{
|
||||||
|
"title": "1C <-> app integration contract",
|
||||||
|
"goal": "Agree endpoints, schedule, auth and error handling",
|
||||||
|
"participants": [
|
||||||
|
{"role": "windows-1c", "knows": ["IIS", "1C", "Windows logs"], "needs_to_determine": ["real exchange frequency", "endpoints actually called"]},
|
||||||
|
{"role": "application", "knows": ["application code", "current integration layer"], "needs_to_determine": ["which contract to implement"]}
|
||||||
|
]
|
||||||
|
}' "$BASE/api/rooms")
|
||||||
|
|
||||||
|
ROOM=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).room_id))')
|
||||||
|
URL_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[0]))')
|
||||||
|
URL_B=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[1]))')
|
||||||
|
TOKEN_A="${URL_A##*/}"
|
||||||
|
TOKEN_B="${URL_B##*/}"
|
||||||
|
PID_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).participants[0].id))')
|
||||||
|
|
||||||
|
echo "Room: $ROOM"
|
||||||
|
echo "Invite URL for Agent B (the human forwards this ONCE): $URL_B"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Agent B opens its invite URL in agent-readable form =="
|
||||||
|
curl -sf "$URL_B.md" | head -30
|
||||||
|
echo " ..."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Round 1: A states verified facts =="
|
||||||
|
post "$ROOM" "$TOKEN_A" '{"content":"Verified on my side: IIS site APP publishes /exchange/hs (basic auth, account svc_exchange). 1C regagent job exists but I have not yet confirmed its schedule."}' messages >/dev/null
|
||||||
|
echo "A posted facts."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Round 2: B asks a blocking question =="
|
||||||
|
Q=$(post "$ROOM" "$TOKEN_B" '{"question":"Check IIS logs for svc_exchange during the last 7 days: which endpoints were actually called and how often?","blocking":true,"addressed_to_participant_id":"'"$PID_A"'"}' questions)
|
||||||
|
QID=$(echo "$Q" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))')
|
||||||
|
echo "B opened blocking question $QID"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== A asks the server what to do next =="
|
||||||
|
curl -sf -H "authorization: Bearer $TOKEN_A" "$BASE/api/rooms/$ROOM" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log("what_you_should_do_next:",JSON.parse(s).what_you_should_do_next))'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Round 3: A verifies on its machine and resolves =="
|
||||||
|
post "$ROOM" "$TOKEN_A" '{"content":"Checked IIS logs (7 days): only /exchange/hs was called, every 15 minutes, all calls HTTP 200 except two 500s on 2026-09-01 (1C restart)."}' messages >/dev/null
|
||||||
|
post "$ROOM" "$TOKEN_A" '{"resolution":"Verified in IIS logs over 7 days: endpoint /exchange/hs, every 15 minutes, caller svc_exchange."}' "questions/$QID/resolve" >/dev/null
|
||||||
|
echo "Question resolved."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Round 4: B finds another contradiction and asks again =="
|
||||||
|
Q2=$(post "$ROOM" "$TOKEN_B" '{"question":"App code expects /api/v1/sync, not /exchange/hs — is the app calling the old path, or is there a mapping in the integration layer?","blocking":true,"addressed_to_participant_id":"'"$PID_A"'"}' questions)
|
||||||
|
Q2ID=$(echo "$Q2" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))')
|
||||||
|
post "$ROOM" "$TOKEN_A" '{"content":"IIS rewrite rule maps /api/v1/sync -> /exchange/hs (checked applicationHost.config). No other mapping exists."}' messages >/dev/null
|
||||||
|
post "$ROOM" "$TOKEN_A" '{"resolution":"Verified rewrite rule in applicationHost.config: /api/v1/sync is mapped to /exchange/hs. Single endpoint."}' "questions/$Q2ID/resolve" >/dev/null
|
||||||
|
echo "Second contradiction resolved."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Round 5: B drafts the Agreed Contract =="
|
||||||
|
put "$ROOM" "$TOKEN_B" '{"markdown":"## Facts\n- Single endpoint: /api/v1/sync (IIS-rewritten to /exchange/hs)\n- Schedule: every 15 minutes, caller svc_exchange\n- Observed errors: two HTTP 500 on 2026-09-01 during 1C restart\n\n## Decisions\n- App keeps calling /api/v1/sync; no new endpoint\n\n## Interface\n- POST /api/v1/sync, JSON body per existing schema\n\n## Schedule\n- Every 15 minutes\n\n## Authentication\n- HTTP Basic, service account svc_exchange\n\n## Error handling\n- HTTP 5xx: retry up to 3 times with backoff; log and alert on repeated failures\n\n## Unresolved\n- none"}' contract >/dev/null
|
||||||
|
echo "Contract proposed."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Both sides agree -> room finalized =="
|
||||||
|
post "$ROOM" "$TOKEN_B" '{}' agree >/dev/null
|
||||||
|
FINAL=$(post "$ROOM" "$TOKEN_A" '{}' agree)
|
||||||
|
echo "$FINAL"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Final artifact =="
|
||||||
|
curl -sf -H "authorization: Bearer $TOKEN_A" "$BASE/api/rooms/$ROOM/final.md"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Demo complete. The room disappears automatically after its TTL. =="
|
||||||
Generated
+1299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-rendezvous",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Meet. Verify. Agree. Disappear. — a neutral temporary meeting room for existing AI sessions.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b packages/core packages/server packages/client-sdk packages/mcp",
|
||||||
|
"test": "npm run build && node --test packages/core/dist/test/*.test.js packages/server/dist/test/*.test.js",
|
||||||
|
"start": "npm run build && node packages/server/dist/src/index.js",
|
||||||
|
"dev": "npm run start"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.1",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-rendezvous/client-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Thin TypeScript client for the AI Rendezvous HTTP API. Harness integrations build on this.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/src/index.js",
|
||||||
|
"types": "dist/src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-rendezvous/core": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { CreateRoomInput, CreatedRoom, RoomView } from '@ai-rendezvous/core';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(readonly status: number, readonly code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin client for the AI Rendezvous HTTP API. Uses only fetch — no harness
|
||||||
|
* assumptions, suitable for integrations in any Node-based environment.
|
||||||
|
*/
|
||||||
|
export class RendezvousClient {
|
||||||
|
constructor(
|
||||||
|
readonly baseUrl: string,
|
||||||
|
readonly token?: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (data as { error?: { code?: string; message?: string } }).error ?? {};
|
||||||
|
throw new ApiError(res.status, err.code ?? 'unknown', err.message ?? res.statusText);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom(input: CreateRoomInput): Promise<CreatedRoom> {
|
||||||
|
return this.request<CreatedRoom>('POST', '/api/rooms', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoom(roomId: string): Promise<RoomView> {
|
||||||
|
return this.request<RoomView>('GET', `/api/rooms/${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(roomId: string, content: string): Promise<unknown> {
|
||||||
|
return this.request('POST', `/api/rooms/${roomId}/messages`, { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
ask(
|
||||||
|
roomId: string,
|
||||||
|
question: string,
|
||||||
|
opts: { blocking?: boolean; addressed_to_participant_id?: string } = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.request('POST', `/api/rooms/${roomId}/questions`, {
|
||||||
|
question,
|
||||||
|
blocking: opts.blocking ?? true,
|
||||||
|
...(opts.addressed_to_participant_id
|
||||||
|
? { addressed_to_participant_id: opts.addressed_to_participant_id }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(roomId: string, questionId: string, resolution: string): Promise<unknown> {
|
||||||
|
return this.request('POST', `/api/rooms/${roomId}/questions/${questionId}/resolve`, { resolution });
|
||||||
|
}
|
||||||
|
|
||||||
|
proposeContract(roomId: string, markdown: string): Promise<{ version: number }> {
|
||||||
|
return this.request('PUT', `/api/rooms/${roomId}/contract`, { markdown });
|
||||||
|
}
|
||||||
|
|
||||||
|
agree(roomId: string): Promise<{
|
||||||
|
agreed_contract_version: number;
|
||||||
|
room_status: string;
|
||||||
|
everyone_agreed: boolean;
|
||||||
|
}> {
|
||||||
|
return this.request('POST', `/api/rooms/${roomId}/agree`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalMarkdown(roomId: string): Promise<string> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/rooms/${roomId}/final.md`, {
|
||||||
|
headers: this.token ? { authorization: `Bearer ${this.token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new ApiError(res.status, 'unknown', await res.text());
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async inviteMarkdown(inviteUrl: string): Promise<string> {
|
||||||
|
const res = await fetch(`${inviteUrl}.md`);
|
||||||
|
if (!res.ok) throw new ApiError(res.status, 'unknown', await res.text());
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client for a specific participant. */
|
||||||
|
as(token: string): RendezvousClient {
|
||||||
|
return new RendezvousClient(this.baseUrl, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-rendezvous/core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Domain core: rooms, participants, messages, open questions, agreed contract. Knows nothing about any AI harness or model.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/src/index.js",
|
||||||
|
"types": "dist/src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { MessageView, ParticipantView, QuestionView, ContractView } from './types.js';
|
||||||
|
|
||||||
|
export interface AdviceInput {
|
||||||
|
status: 'open' | 'agreed' | 'expired';
|
||||||
|
you: ParticipantView;
|
||||||
|
participants: ParticipantView[];
|
||||||
|
openQuestions: QuestionView[];
|
||||||
|
resolvedQuestions: QuestionView[];
|
||||||
|
conversation: MessageView[];
|
||||||
|
contract: ContractView | null;
|
||||||
|
yourAgreedVersion: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function others(list: ParticipantView[], youId: string): string {
|
||||||
|
const names = list.filter((p) => p.id !== youId).map((p) => p.role);
|
||||||
|
return names.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the `what_you_should_do_next` hint so an agent does not have to
|
||||||
|
* reconstruct negotiation state from the full chat log.
|
||||||
|
*/
|
||||||
|
export function computeAdvice(input: AdviceInput): string {
|
||||||
|
const { status, you, openQuestions, contract, yourAgreedVersion } = input;
|
||||||
|
|
||||||
|
if (status === 'expired') {
|
||||||
|
return 'This room has expired. All data has been or will be deleted. Create a new rendezvous if needed.';
|
||||||
|
}
|
||||||
|
if (status === 'agreed') {
|
||||||
|
return 'The contract is agreed by all participants and the room is finalized. Download the final artifact with GET /api/rooms/{room_id}/final.md (Authorization: Bearer <your token>) while the room still exists.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const addressedToYou = openQuestions.filter(
|
||||||
|
(q) => q.addressed_to === null || q.addressed_to === you.id,
|
||||||
|
);
|
||||||
|
const blockingToYou = addressedToYou.filter((q) => q.blocking);
|
||||||
|
const blockingFromYou = openQuestions.filter(
|
||||||
|
(q) => q.participant_id === you.id && q.blocking,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blockingToYou.length > 0) {
|
||||||
|
const first = blockingToYou[0];
|
||||||
|
return (
|
||||||
|
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` +
|
||||||
|
`First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` +
|
||||||
|
'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockingFromYou.length > 0) {
|
||||||
|
return (
|
||||||
|
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` +
|
||||||
|
'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonBlocking = openQuestions.filter((q) => !q.blocking);
|
||||||
|
if (nonBlocking.length > 0) {
|
||||||
|
return (
|
||||||
|
`No blocking questions are waiting on you, but ${nonBlocking.length} non-blocking ${nonBlocking.length === 1 ? 'question is' : 'questions are'} still open. ` +
|
||||||
|
'Answer or resolve them if you can, then move the contract forward.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contract || contract.version === 0 || contract.markdown.trim() === '') {
|
||||||
|
return (
|
||||||
|
'All questions are resolved and no contract has been drafted yet. ' +
|
||||||
|
`Draft the Agreed Contract now (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved) using PUT /api/rooms/{room_id}/contract, ` +
|
||||||
|
'basing it only on verified facts from the conversation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yourAgreedVersion !== contract.version) {
|
||||||
|
return (
|
||||||
|
`Contract version ${contract.version} is proposed but you have not agreed to it yet. ` +
|
||||||
|
`Review it against your side of reality: if it is correct, POST /api/rooms/{room_id}/agree; if not, propose a revision with PUT /api/rooms/{room_id}/contract and open a blocking question explaining the disagreement.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notAgreed = contract.agreements.filter((a) => a.version !== contract.version);
|
||||||
|
if (notAgreed.length > 0) {
|
||||||
|
return (
|
||||||
|
`You agreed to contract version ${contract.version}; waiting for ${notAgreed.map((a) => a.role).join(', ')} to agree to the same version. ` +
|
||||||
|
'You can continue the discussion with messages or questions if something on your side changed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
'All blocking questions are resolved and every participant agreed to the current contract version. ' +
|
||||||
|
'Finalize the room with POST /api/rooms/{room_id}/agree (or /finalize) to set status=agreed, then fetch the final Markdown artifact.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableActions(status: string): string[] {
|
||||||
|
if (status !== 'open') {
|
||||||
|
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'POST /api/rooms/{room_id}/messages — post a message',
|
||||||
|
'POST /api/rooms/{room_id}/questions — open a question {question, blocking, addressed_to_participant_id?}',
|
||||||
|
'POST /api/rooms/{room_id}/questions/{question_id}/resolve — answer/resolve a question {resolution}',
|
||||||
|
'PUT /api/rooms/{room_id}/contract — propose/update the Agreed Contract {markdown}',
|
||||||
|
'POST /api/rooms/{room_id}/agree — agree to current contract version / finalize',
|
||||||
|
'GET /api/rooms/{room_id}/final.md — final Markdown artifact',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { Store } from './store.js';
|
||||||
|
export { RendezvousService, newToken } from './service.js';
|
||||||
|
export { LIMITS } from './limits.js';
|
||||||
|
export { computeAdvice, availableActions } from './advice.js';
|
||||||
|
export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js';
|
||||||
|
export * from './types.js';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Hard limits for a safe public deployment.
|
||||||
|
|
||||||
|
export const LIMITS = {
|
||||||
|
maxParticipants: 8,
|
||||||
|
minParticipants: 2,
|
||||||
|
maxMessagesPerRoom: 500,
|
||||||
|
maxQuestionsPerRoom: 200,
|
||||||
|
maxMessageBytes: 32 * 1024,
|
||||||
|
maxQuestionBytes: 8 * 1024,
|
||||||
|
maxContractBytes: 128 * 1024,
|
||||||
|
maxRoomTotalBytes: 4 * 1024 * 1024,
|
||||||
|
maxTtlHours: 24,
|
||||||
|
defaultTtlHours: 24,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { MessageView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||||
|
|
||||||
|
export interface MarkdownInput {
|
||||||
|
baseUrl: string;
|
||||||
|
roomId: string;
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
brief: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string;
|
||||||
|
participants: ParticipantView[];
|
||||||
|
conversation: MessageView[];
|
||||||
|
openQuestions: QuestionView[];
|
||||||
|
resolvedQuestions: QuestionView[];
|
||||||
|
contractMarkdown: string;
|
||||||
|
contractVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(`# AI Rendezvous: ${i.title}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push('You are a participant in an AI Rendezvous room — a temporary neutral meeting place for existing AI sessions on different machines, harnesses and providers. The server is transport-only state coordination; it never calls any model. Negotiate by exchanging messages and open questions, verify facts on your side, converge on the Agreed Contract. The room is deleted automatically.');
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
||||||
|
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
|
||||||
|
parts.push(`- **Status:** ${i.status}`);
|
||||||
|
parts.push(`- **Expires:** ${i.expiresAt} (all data is deleted then)`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
parts.push('## Participants');
|
||||||
|
for (const p of i.participants) {
|
||||||
|
parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''}`);
|
||||||
|
if (p.knows.length) parts.push(`- **knows:** ${p.knows.join('; ')}`);
|
||||||
|
if (p.needs_to_determine.length) parts.push(`- **needs to determine:** ${p.needs_to_determine.join('; ')}`);
|
||||||
|
if (p.instructions) parts.push(`- **instructions:** ${p.instructions}`);
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('## Open questions');
|
||||||
|
if (i.openQuestions.length === 0) {
|
||||||
|
parts.push('(none)');
|
||||||
|
} else {
|
||||||
|
for (const q of i.openQuestions) {
|
||||||
|
const to = q.addressed_to_role ? ` → ${q.addressed_to_role}` : '';
|
||||||
|
parts.push(`- [${q.blocking ? 'BLOCKING' : 'non-blocking'}] (id: ${q.id}) ${q.author_role}${to}: ${q.question}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
if (i.resolvedQuestions.length) {
|
||||||
|
parts.push('## Resolved questions');
|
||||||
|
for (const q of i.resolvedQuestions) {
|
||||||
|
parts.push(`- (id: ${q.id}) ${q.author_role}: ${q.question}`);
|
||||||
|
parts.push(` - resolution (${q.resolved_at ?? ''}): ${q.resolution ?? ''}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('## Current Agreed Contract');
|
||||||
|
parts.push(i.contractMarkdown.trim() ? i.contractMarkdown : `(not drafted yet, version ${i.contractVersion})`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
parts.push('## Conversation');
|
||||||
|
if (i.conversation.length === 0) {
|
||||||
|
parts.push('(no messages yet)');
|
||||||
|
} else {
|
||||||
|
for (const m of i.conversation) {
|
||||||
|
parts.push(`**${m.role}** (${m.created_at}, id: ${m.id}):`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(m.content);
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('## Available API actions');
|
||||||
|
parts.push(`Base URL: ${i.baseUrl}. Authenticate with \`Authorization: Bearer <your invite token>\` or \`?token=<token>\`.`);
|
||||||
|
parts.push('- `GET /api/rooms/' + i.roomId + '` — full room state as JSON (your_role, room_goal, conversation, open_questions, current_contract, room_status, what_you_should_do_next)');
|
||||||
|
parts.push('- `POST /api/rooms/' + i.roomId + '/messages` — {"content": "..."}');
|
||||||
|
parts.push('- `POST /api/rooms/' + i.roomId + '/questions` — {"question": "...", "blocking": true, "addressed_to_participant_id": "..."}');
|
||||||
|
parts.push('- `POST /api/rooms/' + i.roomId + '/questions/{question_id}/resolve` — {"resolution": "verified facts..."}');
|
||||||
|
parts.push('- `PUT /api/rooms/' + i.roomId + '/contract` — {"markdown": "## Facts\\n..."}');
|
||||||
|
parts.push('- `POST /api/rooms/' + i.roomId + '/agree` — agree to current contract version; finalizes when everyone agreed and no blocking questions remain');
|
||||||
|
parts.push('- `GET /api/rooms/' + i.roomId + '/final.md` — final Markdown artifact');
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFinalMarkdown(v: RoomView): string {
|
||||||
|
const r = v.room;
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`# Agreed Contract — ${r.title}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`- **Goal:** ${r.goal || '(not set)'}`);
|
||||||
|
parts.push(`- **Status:** ${r.status}`);
|
||||||
|
parts.push(`- **Agreed at:** ${new Date().toISOString()}`);
|
||||||
|
parts.push(`- **Participants:** ${v.participants.map((p) => p.role).join(', ')}`);
|
||||||
|
if (r.brief) parts.push(`- **Brief:** ${r.brief}`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
const contract = v.current_contract;
|
||||||
|
if (contract && contract.markdown.trim()) {
|
||||||
|
parts.push(contract.markdown);
|
||||||
|
} else {
|
||||||
|
parts.push('(no contract was recorded)');
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
if (v.resolved_questions.length) {
|
||||||
|
parts.push('## Verified facts (resolved questions)');
|
||||||
|
for (const q of v.resolved_questions) {
|
||||||
|
parts.push(`- **Q (${q.author_role}):** ${q.question}`);
|
||||||
|
parts.push(` **A:** ${q.resolution ?? '(no resolution text)'}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('---');
|
||||||
|
parts.push(`_Artifact of AI Rendezvous room ${r.id}. Room expires at ${r.expires_at}, after which all data is deleted._`);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import {
|
||||||
|
AgreementRow,
|
||||||
|
ContractRevisionRow,
|
||||||
|
MessageRow,
|
||||||
|
ParticipantRow,
|
||||||
|
QuestionRow,
|
||||||
|
RoomRow,
|
||||||
|
Store,
|
||||||
|
} from './store.js';
|
||||||
|
import { LIMITS } from './limits.js';
|
||||||
|
import { availableActions, computeAdvice } from './advice.js';
|
||||||
|
import {
|
||||||
|
CreateRoomInput,
|
||||||
|
CreatedRoom,
|
||||||
|
MessageView,
|
||||||
|
ParticipantView,
|
||||||
|
QuestionView,
|
||||||
|
RendezvousError,
|
||||||
|
RoomView,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId(prefix: string): string {
|
||||||
|
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cryptographically random invite token; the token IS the authorization. */
|
||||||
|
export function newToken(): string {
|
||||||
|
return randomBytes(24).toString('base64url'); // 32 chars, 192 bits
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RendezvousService {
|
||||||
|
private db: DatabaseSync;
|
||||||
|
|
||||||
|
constructor(readonly store: Store) {
|
||||||
|
this.db = store.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- room lifecycle ----------
|
||||||
|
|
||||||
|
createRoom(input: CreateRoomInput, baseUrl: string): CreatedRoom {
|
||||||
|
const title = (input.title ?? '').trim();
|
||||||
|
if (!title) throw new RendezvousError('validation', 'title is required');
|
||||||
|
if (Buffer.byteLength(title) > 2000) {
|
||||||
|
throw new RendezvousError('validation', 'title too long (max 2000 bytes)');
|
||||||
|
}
|
||||||
|
const ps = input.participants ?? [];
|
||||||
|
if (ps.length < LIMITS.minParticipants) {
|
||||||
|
throw new RendezvousError('validation', `at least ${LIMITS.minParticipants} participants required`);
|
||||||
|
}
|
||||||
|
if (ps.length > LIMITS.maxParticipants) {
|
||||||
|
throw new RendezvousError('validation', `at most ${LIMITS.maxParticipants} participants allowed`);
|
||||||
|
}
|
||||||
|
const roles = new Set<string>();
|
||||||
|
for (const p of ps) {
|
||||||
|
const role = (p.role ?? '').trim();
|
||||||
|
if (!role) throw new RendezvousError('validation', 'each participant needs a role');
|
||||||
|
if (Buffer.byteLength(role) > 200) {
|
||||||
|
throw new RendezvousError('validation', 'participant role too long');
|
||||||
|
}
|
||||||
|
if (roles.has(role)) {
|
||||||
|
throw new RendezvousError('validation', `duplicate participant role: ${role}`);
|
||||||
|
}
|
||||||
|
roles.add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = input.ttl_hours ?? LIMITS.defaultTtlHours;
|
||||||
|
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > LIMITS.maxTtlHours) {
|
||||||
|
throw new RendezvousError('validation', `ttl_hours must be between 1 and ${LIMITS.maxTtlHours}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = randomBytes(9).toString('base64url'); // 12 chars
|
||||||
|
const createdAt = nowIso();
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 3600_000).toISOString();
|
||||||
|
|
||||||
|
const created: { id: string; role: string; token: string }[] = [];
|
||||||
|
const insertRoom = this.db.prepare(
|
||||||
|
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at) VALUES (?, ?, ?, ?, 'open', ?, ?)`,
|
||||||
|
);
|
||||||
|
const insertParticipant = this.db.prepare(
|
||||||
|
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt);
|
||||||
|
for (const p of ps) {
|
||||||
|
const pid = newId('prt');
|
||||||
|
const token = newToken();
|
||||||
|
insertParticipant.run(
|
||||||
|
pid,
|
||||||
|
roomId,
|
||||||
|
p.role.trim(),
|
||||||
|
(p.display_name ?? p.role).trim().slice(0, 200),
|
||||||
|
token,
|
||||||
|
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||||
|
JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||||
|
(p.instructions ?? '').slice(0, 20000),
|
||||||
|
createdAt,
|
||||||
|
);
|
||||||
|
created.push({ id: pid, role: p.role.trim(), token });
|
||||||
|
}
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
room_id: roomId,
|
||||||
|
status: 'open',
|
||||||
|
expires_at: expiresAt,
|
||||||
|
participants: created,
|
||||||
|
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- auth ----------
|
||||||
|
|
||||||
|
authenticate(roomId: string, token: string): { room: RoomRow; participant: ParticipantRow } {
|
||||||
|
const room = this.getRoomRow(roomId);
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT * FROM participants WHERE room_id = ? AND token = ?')
|
||||||
|
.get(roomId, token) as ParticipantRow | undefined;
|
||||||
|
if (!row) {
|
||||||
|
throw new RendezvousError('forbidden', 'invalid token for this room');
|
||||||
|
}
|
||||||
|
return { room, participant: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticateByToken(token: string): { room: RoomRow; participant: ParticipantRow } {
|
||||||
|
const row = this.db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as
|
||||||
|
| ParticipantRow
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw new RendezvousError('not_found', 'unknown token');
|
||||||
|
const room = this.getRoomRow(row.room_id);
|
||||||
|
return { room, participant: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRoomRow(roomId: string): RoomRow {
|
||||||
|
const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId) as RoomRow | undefined;
|
||||||
|
if (!room) throw new RendezvousError('not_found', 'room not found');
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureOpen(room: RoomRow): void {
|
||||||
|
if (new Date(room.expires_at).getTime() < Date.now()) {
|
||||||
|
this.cleanupExpired();
|
||||||
|
throw new RendezvousError('not_found', 'room not found');
|
||||||
|
}
|
||||||
|
if (room.status !== 'open') {
|
||||||
|
throw new RendezvousError('conflict', `room status is '${room.status}'; no further changes allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reads ----------
|
||||||
|
|
||||||
|
listParticipants(roomId: string): ParticipantView[] {
|
||||||
|
return (this.db
|
||||||
|
.prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id')
|
||||||
|
.all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toParticipantView(p: ParticipantRow): ParticipantView {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
role: p.role,
|
||||||
|
display_name: p.display_name,
|
||||||
|
knows: JSON.parse(p.knows) as string[],
|
||||||
|
needs_to_determine: JSON.parse(p.needs_to_determine) as string[],
|
||||||
|
instructions: p.instructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listMessages(roomId: string): MessageView[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.*, p.role FROM messages m JOIN participants p ON p.id = m.participant_id
|
||||||
|
WHERE m.room_id = ? ORDER BY m.created_at, m.id`,
|
||||||
|
)
|
||||||
|
.all(roomId) as unknown as (MessageRow & { role: string })[];
|
||||||
|
return rows.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
participant_id: m.participant_id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
created_at: m.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
listQuestions(roomId: string): QuestionView[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q
|
||||||
|
JOIN participants pa ON pa.id = q.participant_id
|
||||||
|
LEFT JOIN participants pt ON pt.id = q.addressed_to
|
||||||
|
WHERE q.room_id = ? ORDER BY q.created_at, q.id`,
|
||||||
|
)
|
||||||
|
.all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[];
|
||||||
|
return rows.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
participant_id: q.participant_id,
|
||||||
|
author_role: q.author_role,
|
||||||
|
addressed_to: q.addressed_to,
|
||||||
|
addressed_to_role: q.to_role,
|
||||||
|
question: q.question,
|
||||||
|
blocking: q.blocking === 1,
|
||||||
|
status: q.status,
|
||||||
|
resolution: q.resolution,
|
||||||
|
created_at: q.created_at,
|
||||||
|
resolved_at: q.resolved_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoomView(roomId: string, token: string): RoomView {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
const participants = this.listParticipants(roomId);
|
||||||
|
const conversation = this.listMessages(roomId);
|
||||||
|
const questions = this.listQuestions(roomId);
|
||||||
|
const openQuestions = questions.filter((q) => q.status === 'open');
|
||||||
|
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
||||||
|
|
||||||
|
const agreements = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.participant_id, a.contract_version, p.role FROM agreements a
|
||||||
|
JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`,
|
||||||
|
)
|
||||||
|
.all(roomId) as { participant_id: string; contract_version: number; role: string }[];
|
||||||
|
|
||||||
|
const contract =
|
||||||
|
room.contract_version > 0
|
||||||
|
? {
|
||||||
|
version: room.contract_version,
|
||||||
|
markdown: room.contract_markdown,
|
||||||
|
updated_at: room.contract_updated_at,
|
||||||
|
agreements: agreements.map((a) => ({
|
||||||
|
participant_id: a.participant_id,
|
||||||
|
role: a.role,
|
||||||
|
version: a.contract_version,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mine = agreements.find((a) => a.participant_id === participant.id);
|
||||||
|
const status = room.status as 'open' | 'agreed' | 'expired';
|
||||||
|
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
|
||||||
|
|
||||||
|
return {
|
||||||
|
room: {
|
||||||
|
id: room.id,
|
||||||
|
title: room.title,
|
||||||
|
brief: room.brief,
|
||||||
|
goal: room.goal,
|
||||||
|
status,
|
||||||
|
created_at: room.created_at,
|
||||||
|
expires_at: room.expires_at,
|
||||||
|
},
|
||||||
|
your_role: participant.role,
|
||||||
|
your_participant_id: participant.id,
|
||||||
|
participants,
|
||||||
|
conversation,
|
||||||
|
open_questions: openQuestions,
|
||||||
|
resolved_questions: resolvedQuestions,
|
||||||
|
current_contract: contract,
|
||||||
|
room_status: viewStatus,
|
||||||
|
what_you_should_do_next: computeAdvice({
|
||||||
|
status: viewStatus,
|
||||||
|
you: this.toParticipantView(participant),
|
||||||
|
participants,
|
||||||
|
openQuestions,
|
||||||
|
resolvedQuestions,
|
||||||
|
conversation,
|
||||||
|
contract,
|
||||||
|
yourAgreedVersion: mine ? mine.contract_version : null,
|
||||||
|
}),
|
||||||
|
available_actions: availableActions(viewStatus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- writes ----------
|
||||||
|
|
||||||
|
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
this.ensureOpen(room);
|
||||||
|
const body = typeof content === 'string' ? content : '';
|
||||||
|
if (!body.trim()) throw new RendezvousError('validation', 'content is required');
|
||||||
|
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
|
||||||
|
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
|
||||||
|
}
|
||||||
|
const count = this.db.prepare('SELECT COUNT(*) AS c FROM messages WHERE room_id = ?').get(roomId) as {
|
||||||
|
c: number;
|
||||||
|
};
|
||||||
|
if (count.c >= LIMITS.maxMessagesPerRoom) {
|
||||||
|
throw new RendezvousError('limit', `message limit reached (${LIMITS.maxMessagesPerRoom})`);
|
||||||
|
}
|
||||||
|
const totalRow = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COALESCE(SUM(LENGTH(content)),0) FROM messages WHERE room_id = ?)
|
||||||
|
+ (SELECT COALESCE(SUM(LENGTH(question) + LENGTH(resolution)),0) FROM questions WHERE room_id = ?)
|
||||||
|
+ (SELECT COALESCE(SUM(LENGTH(markdown)),0) FROM contract_revisions WHERE room_id = ?) AS s`,
|
||||||
|
)
|
||||||
|
.get(roomId, roomId, roomId) as { s: number };
|
||||||
|
if (totalRow.s + Buffer.byteLength(body) > LIMITS.maxRoomTotalBytes) {
|
||||||
|
throw new RendezvousError('limit', 'room total size limit reached');
|
||||||
|
}
|
||||||
|
const id = newId('msg');
|
||||||
|
this.db
|
||||||
|
.prepare('INSERT INTO messages (id, room_id, participant_id, content, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(id, roomId, participant.id, body, nowIso());
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
participant_id: participant.id,
|
||||||
|
role: participant.role,
|
||||||
|
content: body,
|
||||||
|
created_at: nowIso(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
openQuestion(
|
||||||
|
roomId: string,
|
||||||
|
token: string,
|
||||||
|
question: string,
|
||||||
|
blocking: boolean,
|
||||||
|
addressedToParticipantId?: string | null,
|
||||||
|
): QuestionView {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
this.ensureOpen(room);
|
||||||
|
const body = typeof question === 'string' ? question.trim() : '';
|
||||||
|
if (!body) throw new RendezvousError('validation', 'question is required');
|
||||||
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||||
|
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
|
||||||
|
}
|
||||||
|
let addressedTo: string | null = null;
|
||||||
|
if (addressedToParticipantId) {
|
||||||
|
const target = this.db
|
||||||
|
.prepare('SELECT id FROM participants WHERE id = ? AND room_id = ?')
|
||||||
|
.get(addressedToParticipantId, roomId);
|
||||||
|
if (!target) throw new RendezvousError('validation', 'addressed_to_participant_id not found in this room');
|
||||||
|
if (addressedToParticipantId === participant.id) {
|
||||||
|
throw new RendezvousError('validation', 'cannot address a question to yourself');
|
||||||
|
}
|
||||||
|
addressedTo = addressedToParticipantId;
|
||||||
|
}
|
||||||
|
const count = this.db.prepare('SELECT COUNT(*) AS c FROM questions WHERE room_id = ?').get(roomId) as {
|
||||||
|
c: number;
|
||||||
|
};
|
||||||
|
if (count.c >= LIMITS.maxQuestionsPerRoom) {
|
||||||
|
throw new RendezvousError('limit', `question limit reached (${LIMITS.maxQuestionsPerRoom})`);
|
||||||
|
}
|
||||||
|
const id = newId('q');
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO questions (id, room_id, participant_id, addressed_to, question, blocking, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'open', ?)`,
|
||||||
|
)
|
||||||
|
.run(id, roomId, participant.id, addressedTo, body, blocking ? 1 : 0, nowIso());
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
participant_id: participant.id,
|
||||||
|
author_role: participant.role,
|
||||||
|
addressed_to: addressedTo,
|
||||||
|
addressed_to_role: addressedTo
|
||||||
|
? ((this.db.prepare('SELECT role FROM participants WHERE id = ?').get(addressedTo) as { role: string }).role)
|
||||||
|
: null,
|
||||||
|
question: body,
|
||||||
|
blocking,
|
||||||
|
status: 'open',
|
||||||
|
resolution: null,
|
||||||
|
created_at: nowIso(),
|
||||||
|
resolved_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveQuestion(roomId: string, token: string, questionId: string, resolution: string): QuestionView {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
this.ensureOpen(room);
|
||||||
|
const q = this.db
|
||||||
|
.prepare('SELECT * FROM questions WHERE id = ? AND room_id = ?')
|
||||||
|
.get(questionId, roomId) as QuestionRow | undefined;
|
||||||
|
if (!q) throw new RendezvousError('not_found', 'question not found');
|
||||||
|
if (q.status === 'resolved') {
|
||||||
|
throw new RendezvousError('conflict', 'question is already resolved');
|
||||||
|
}
|
||||||
|
const body = typeof resolution === 'string' ? resolution.trim() : '';
|
||||||
|
if (!body) throw new RendezvousError('validation', 'resolution text is required');
|
||||||
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||||
|
throw new RendezvousError('limit', 'resolution too large');
|
||||||
|
}
|
||||||
|
// The addressee or the author may resolve; anyone may resolve a question
|
||||||
|
// addressed to "anyone" only if they are the addressee in practice. Keep
|
||||||
|
// it simple: the addressee (or author) resolves.
|
||||||
|
const isAddressee = q.addressed_to === participant.id;
|
||||||
|
const isAuthor = q.participant_id === participant.id;
|
||||||
|
if (!isAddressee && !isAuthor) {
|
||||||
|
throw new RendezvousError('forbidden', 'only the addressee or the author can resolve a question');
|
||||||
|
}
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE questions SET status = 'resolved', resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(body, participant.id, nowIso(), questionId);
|
||||||
|
return this.listQuestions(roomId).find((x) => x.id === questionId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
this.ensureOpen(room);
|
||||||
|
const body = typeof markdown === 'string' ? markdown : '';
|
||||||
|
if (!body.trim()) throw new RendezvousError('validation', 'markdown is required');
|
||||||
|
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
|
||||||
|
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
|
||||||
|
}
|
||||||
|
const version = room.contract_version + 1;
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO contract_revisions (id, room_id, version, markdown, proposed_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(newId('rev'), roomId, version, body, participant.id, nowIso());
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE rooms SET contract_markdown = ?, contract_version = ?, contract_updated_at = ? WHERE id = ?')
|
||||||
|
.run(body, version, nowIso(), roomId);
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { version, markdown: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
listContractRevisions(roomId: string): ContractRevisionRow[] {
|
||||||
|
return this.db
|
||||||
|
.prepare('SELECT * FROM contract_revisions WHERE room_id = ? ORDER BY version')
|
||||||
|
.all(roomId) as unknown as ContractRevisionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
agree(roomId: string, token: string): {
|
||||||
|
agreed_contract_version: number;
|
||||||
|
room_status: string;
|
||||||
|
everyone_agreed: boolean;
|
||||||
|
} {
|
||||||
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
|
this.ensureOpen(room);
|
||||||
|
if (room.contract_version === 0 || room.contract_markdown.trim() === '') {
|
||||||
|
throw new RendezvousError('conflict', 'no contract has been proposed yet');
|
||||||
|
}
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO agreements (room_id, participant_id, contract_version, created_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(room_id, participant_id) DO UPDATE SET contract_version = excluded.contract_version, created_at = excluded.created_at`,
|
||||||
|
)
|
||||||
|
.run(roomId, participant.id, room.contract_version, nowIso());
|
||||||
|
|
||||||
|
const participants = this.listParticipants(roomId);
|
||||||
|
const agreements = this.db
|
||||||
|
.prepare('SELECT participant_id, contract_version FROM agreements WHERE room_id = ?')
|
||||||
|
.all(roomId) as { participant_id: string; contract_version: number }[];
|
||||||
|
const everyone = participants.every((p) => {
|
||||||
|
const a = agreements.find((x) => x.participant_id === p.id);
|
||||||
|
return a && a.contract_version === room.contract_version;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocking = this.db
|
||||||
|
.prepare(`SELECT COUNT(*) AS c FROM questions WHERE room_id = ? AND status = 'open' AND blocking = 1`)
|
||||||
|
.get(roomId) as { c: number };
|
||||||
|
|
||||||
|
if (everyone && blocking.c === 0) {
|
||||||
|
this.db.prepare(`UPDATE rooms SET status = 'agreed' WHERE id = ?`).run(roomId);
|
||||||
|
return { agreed_contract_version: room.contract_version, room_status: 'agreed', everyone_agreed: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
agreed_contract_version: room.contract_version,
|
||||||
|
room_status: 'open',
|
||||||
|
everyone_agreed: everyone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- TTL cleanup ----------
|
||||||
|
|
||||||
|
/** Deletes everything belonging to expired rooms. Returns number of rooms removed. */
|
||||||
|
cleanupExpired(): number {
|
||||||
|
const expired = this.db
|
||||||
|
.prepare(`SELECT id FROM rooms WHERE expires_at < ? AND status != 'expired'`)
|
||||||
|
.all(nowIso()) as { id: string }[];
|
||||||
|
if (expired.length === 0) return 0;
|
||||||
|
const delRoom = this.db.prepare('DELETE FROM rooms WHERE id = ?');
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const r of expired) delRoom.run(r.id); // ON DELETE CASCADE removes the rest
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return expired.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
startCleanupTimer(intervalMs = 10 * 60_000): NodeJS.Timeout {
|
||||||
|
return setInterval(() => {
|
||||||
|
try {
|
||||||
|
this.cleanupExpired();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cleanup] failed:', e);
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
export interface RoomRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
goal: string;
|
||||||
|
status: 'open' | 'agreed' | 'expired';
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
contract_markdown: string;
|
||||||
|
contract_version: number;
|
||||||
|
contract_updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantRow {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
role: string;
|
||||||
|
display_name: string;
|
||||||
|
token: string;
|
||||||
|
knows: string; // JSON array
|
||||||
|
needs_to_determine: string; // JSON array
|
||||||
|
instructions: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageRow {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
participant_id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionRow {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
participant_id: string; // author
|
||||||
|
addressed_to: string | null; // participant id or null (anyone)
|
||||||
|
question: string;
|
||||||
|
blocking: 0 | 1;
|
||||||
|
status: 'open' | 'resolved';
|
||||||
|
resolution: string | null;
|
||||||
|
resolved_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractRevisionRow {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
version: number;
|
||||||
|
markdown: string;
|
||||||
|
proposed_by: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgreementRow {
|
||||||
|
room_id: string;
|
||||||
|
participant_id: string;
|
||||||
|
contract_version: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
brief TEXT NOT NULL DEFAULT '',
|
||||||
|
goal TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
contract_markdown TEXT NOT NULL DEFAULT '',
|
||||||
|
contract_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
contract_updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
knows TEXT NOT NULL DEFAULT '[]',
|
||||||
|
needs_to_determine TEXT NOT NULL DEFAULT '[]',
|
||||||
|
instructions TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
addressed_to TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
blocking INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
resolution TEXT,
|
||||||
|
resolved_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_room ON questions(room_id, status);
|
||||||
|
CREATE TABLE IF NOT EXISTS contract_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
markdown TEXT NOT NULL,
|
||||||
|
proposed_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS agreements (
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
contract_version INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, participant_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class Store {
|
||||||
|
readonly db: DatabaseSync;
|
||||||
|
|
||||||
|
constructor(path: string) {
|
||||||
|
this.db = new DatabaseSync(path);
|
||||||
|
this.db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
this.db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
this.db.exec(SCHEMA);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Domain types. The core deliberately knows nothing about specific AI
|
||||||
|
// harnesses, models or providers: only Room / Participant / Message /
|
||||||
|
// OpenQuestion / AgreedContract.
|
||||||
|
|
||||||
|
export interface ParticipantInput {
|
||||||
|
role: string;
|
||||||
|
display_name?: string;
|
||||||
|
knows?: string[];
|
||||||
|
needs_to_determine?: string[];
|
||||||
|
instructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoomInput {
|
||||||
|
title: string;
|
||||||
|
brief?: string;
|
||||||
|
goal?: string;
|
||||||
|
/** Hours until the room is deleted. Max 24, default 24. */
|
||||||
|
ttl_hours?: number;
|
||||||
|
participants: ParticipantInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantView {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
display_name: string;
|
||||||
|
knows: string[];
|
||||||
|
needs_to_determine: string[];
|
||||||
|
instructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageView {
|
||||||
|
id: string;
|
||||||
|
participant_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionView {
|
||||||
|
id: string;
|
||||||
|
participant_id: string;
|
||||||
|
author_role: string;
|
||||||
|
addressed_to: string | null; // participant id
|
||||||
|
addressed_to_role: string | null;
|
||||||
|
question: string;
|
||||||
|
blocking: boolean;
|
||||||
|
status: 'open' | 'resolved';
|
||||||
|
resolution: string | null;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractView {
|
||||||
|
version: number;
|
||||||
|
markdown: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
agreements: { participant_id: string; role: string; version: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomView {
|
||||||
|
room: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
goal: string;
|
||||||
|
status: 'open' | 'agreed' | 'expired';
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
};
|
||||||
|
your_role: string;
|
||||||
|
your_participant_id: string;
|
||||||
|
participants: ParticipantView[];
|
||||||
|
conversation: MessageView[];
|
||||||
|
open_questions: QuestionView[];
|
||||||
|
resolved_questions: QuestionView[];
|
||||||
|
current_contract: ContractView | null;
|
||||||
|
room_status: 'open' | 'agreed' | 'expired';
|
||||||
|
what_you_should_do_next: string;
|
||||||
|
available_actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedRoom {
|
||||||
|
room_id: string;
|
||||||
|
status: string;
|
||||||
|
expires_at: string;
|
||||||
|
invite_urls: string[]; // same order as input participants
|
||||||
|
participants: { id: string; role: string; token: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RendezvousError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code:
|
||||||
|
| 'not_found'
|
||||||
|
| 'forbidden'
|
||||||
|
| 'validation'
|
||||||
|
| 'limit'
|
||||||
|
| 'conflict',
|
||||||
|
message: string,
|
||||||
|
readonly status: number = code === 'not_found' ? 404
|
||||||
|
: code === 'forbidden' ? 403
|
||||||
|
: code === 'validation' ? 400
|
||||||
|
: code === 'limit' ? 429
|
||||||
|
: 409,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { RendezvousService, Store, RendezvousError } from '../src/index.js';
|
||||||
|
|
||||||
|
function makeService(): { svc: RendezvousService; dbPath: string } {
|
||||||
|
const dbPath = `:memory:`;
|
||||||
|
const store = new Store(dbPath);
|
||||||
|
return { svc: new RendezvousService(store), dbPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTwoPartyRoom(svc: RendezvousService) {
|
||||||
|
return svc.createRoom(
|
||||||
|
{
|
||||||
|
title: '1C <-> app integration',
|
||||||
|
goal: 'Agree on exchange contract',
|
||||||
|
participants: [
|
||||||
|
{ role: 'windows-1c', knows: ['IIS', '1C'], needs_to_determine: ['frequency'] },
|
||||||
|
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'http://test.local',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('createRoom returns invite urls and tokens for each participant', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
assert.equal(created.status, 'open');
|
||||||
|
assert.equal(created.invite_urls.length, 2);
|
||||||
|
assert.match(created.invite_urls[0], /http:\/\/test\.local\/r\/.+\/.+/);
|
||||||
|
assert.notEqual(created.participants[0].token, created.participants[1].token);
|
||||||
|
assert.ok(created.participants[0].token.length >= 32, 'token must be long and random');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token of participant A cannot act as participant B context and vice versa works', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
const roomId = created.room_id;
|
||||||
|
const tokenA = created.participants[0].token;
|
||||||
|
const tokenB = created.participants[1].token;
|
||||||
|
|
||||||
|
const viewA = svc.getRoomView(roomId, tokenA);
|
||||||
|
assert.equal(viewA.your_role, 'windows-1c');
|
||||||
|
const viewB = svc.getRoomView(roomId, tokenB);
|
||||||
|
assert.equal(viewB.your_role, 'application');
|
||||||
|
|
||||||
|
// wrong room id with a valid token -> forbidden/not found
|
||||||
|
assert.throws(() => svc.getRoomView('nosuchroom', tokenA), RendezvousError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('participant tokens are isolated: a token from another room is rejected', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const room1 = createTwoPartyRoom(svc);
|
||||||
|
const room2 = svc.createRoom(
|
||||||
|
{ title: 'other', participants: [{ role: 'x' }, { role: 'y' }] },
|
||||||
|
'http://test.local',
|
||||||
|
);
|
||||||
|
assert.throws(() => svc.getRoomView(room2.room_id, room1.participants[0].token), RendezvousError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid token rejected, room id alone is not enough', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
assert.throws(() => svc.authenticate(created.room_id, 'forged-token-123'), RendezvousError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full negotiation scenario: messages -> questions -> contract -> agree -> final.md', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
const roomId = created.room_id;
|
||||||
|
const [, B] = created.participants.map((p) => p.token);
|
||||||
|
|
||||||
|
// A states facts
|
||||||
|
svc.postMessage(roomId, created.participants[0].token, 'IIS has /exchange/hs endpoint, auth is basic.');
|
||||||
|
|
||||||
|
// B asks a blocking question addressed to A
|
||||||
|
const q = svc.openQuestion(roomId, B, 'What is the real exchange frequency? Check the 1C job config.', true, created.participants[0].id);
|
||||||
|
assert.equal(q.status, 'open');
|
||||||
|
assert.equal(q.blocking, true);
|
||||||
|
|
||||||
|
// A cannot finalize while blocking question open
|
||||||
|
svc.proposeContract(roomId, created.participants[0].token, '## Facts\ntodo');
|
||||||
|
const agreeWhileOpen = svc.agree(roomId, B);
|
||||||
|
assert.equal(agreeWhileOpen.room_status, 'open');
|
||||||
|
|
||||||
|
// A verifies and resolves
|
||||||
|
svc.postMessage(roomId, created.participants[0].token, 'Checked regagent job: every 15 minutes.');
|
||||||
|
svc.resolveQuestion(roomId, created.participants[0].token, q.id, 'Verified in 1C job config: every 15 min.');
|
||||||
|
|
||||||
|
// B proposes final contract, both agree -> agreed
|
||||||
|
svc.proposeContract(roomId, B, '## Facts\n- every 15 min\n## Interface\n- POST /exchange/hs');
|
||||||
|
svc.agree(roomId, B);
|
||||||
|
const final = svc.agree(roomId, created.participants[0].token);
|
||||||
|
assert.equal(final.room_status, 'agreed');
|
||||||
|
|
||||||
|
const view = svc.getRoomView(roomId, B);
|
||||||
|
assert.equal(view.room_status, 'agreed');
|
||||||
|
assert.ok(view.what_you_should_do_next.includes('final'));
|
||||||
|
|
||||||
|
// room is closed for writes
|
||||||
|
assert.throws(() => svc.postMessage(roomId, B, 'late message'), (e: RendezvousError) => e.code === 'conflict');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TTL cleanup deletes rooms, messages, tokens and artifacts', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
const roomId = created.room_id;
|
||||||
|
const [, B] = created.participants.map((p) => p.token);
|
||||||
|
svc.postMessage(roomId, B, 'hello');
|
||||||
|
const q = svc.openQuestion(roomId, B, 'q?', true, created.participants[0].id);
|
||||||
|
svc.proposeContract(roomId, B, '## Facts\n- x');
|
||||||
|
|
||||||
|
// force-expire the room directly in storage
|
||||||
|
svc.store.db
|
||||||
|
.prepare('UPDATE rooms SET expires_at = ? WHERE id = ?')
|
||||||
|
.run(new Date(Date.now() - 1000).toISOString(), roomId);
|
||||||
|
|
||||||
|
const removed = svc.cleanupExpired();
|
||||||
|
assert.equal(removed, 1);
|
||||||
|
|
||||||
|
const count = (table: string) =>
|
||||||
|
(svc.store.db.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE room_id = ?`).get(roomId) as { c: number }).c;
|
||||||
|
assert.equal((svc.store.db.prepare('SELECT COUNT(*) AS c FROM rooms').get() as { c: number }).c, 0);
|
||||||
|
assert.equal(count('participants'), 0);
|
||||||
|
assert.equal(count('messages'), 0);
|
||||||
|
assert.equal(count('questions'), 0);
|
||||||
|
assert.equal(count('contract_revisions'), 0);
|
||||||
|
|
||||||
|
// everything is gone: token no longer authenticates
|
||||||
|
assert.throws(() => svc.getRoomView(roomId, B), RendezvousError);
|
||||||
|
assert.equal(svc.cleanupExpired(), 0, 'second run is a no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limits: ttl > 24h rejected, oversize message rejected, 1 participant rejected', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
assert.throws(
|
||||||
|
() => svc.createRoom({ title: 'x', ttl_hours: 25, participants: [{ role: 'a' }, { role: 'b' }] }, 'http://x'),
|
||||||
|
RendezvousError,
|
||||||
|
);
|
||||||
|
assert.throws(() => svc.createRoom({ title: 'x', participants: [{ role: 'a' }] }, 'http://x'), RendezvousError);
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
assert.throws(
|
||||||
|
() => svc.postMessage(created.room_id, created.participants[0].token, 'a'.repeat(33 * 1024)),
|
||||||
|
RendezvousError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only addressee or author can resolve a question', () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const created = createTwoPartyRoom(svc);
|
||||||
|
const [A, B] = created.participants.map((p) => p.token);
|
||||||
|
const q = svc.openQuestion(created.room_id, B, 'check logs', true, created.participants[0].id);
|
||||||
|
// a random third participant would be needed to test "other"; with 2
|
||||||
|
// participants the author/addressee rule reduces to both being allowed.
|
||||||
|
const resolved = svc.resolveQuestion(created.room_id, A, q.id, 'checked: ok');
|
||||||
|
assert.equal(resolved.status, 'resolved');
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-rendezvous/mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "MCP server exposing AI Rendezvous tools. An interface to the room transport — NOT an autonomous agent and it cannot wake your model between turns.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/src/index.js",
|
||||||
|
"bin": {
|
||||||
|
"ai-rendezvous-mcp": "dist/src/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-rendezvous/client-sdk": "*",
|
||||||
|
"@ai-rendezvous/core": "*",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* AI Rendezvous MCP server.
|
||||||
|
*
|
||||||
|
* Scope: this is an INTERFACE to the AI Rendezvous room transport, not an
|
||||||
|
* autonomous participant. It works during your active turn (you call tools,
|
||||||
|
* the server relays to the room's HTTP API). It cannot and does not wake your
|
||||||
|
* model when new messages arrive — polling happens when YOU decide to call
|
||||||
|
* rendezvous_get.
|
||||||
|
*/
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RendezvousClient } from '@ai-rendezvous/client-sdk';
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.RENDEZVOUS_BASE_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: 'ai-rendezvous',
|
||||||
|
version: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
function client(token?: string): RendezvousClient {
|
||||||
|
return new RendezvousClient(BASE_URL, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantSchema = z
|
||||||
|
.object({
|
||||||
|
role: z.string().describe('Short machine-readable role of this participant, e.g. "windows-1c"'),
|
||||||
|
display_name: z.string().optional().describe('Human-readable name'),
|
||||||
|
knows: z.array(z.string()).describe('Facts/systems this participant has direct access to'),
|
||||||
|
needs_to_determine: z.array(z.string()).describe('Things this participant must find out'),
|
||||||
|
instructions: z.string().optional().describe('Extra context/instructions for this participant'),
|
||||||
|
})
|
||||||
|
.describe('A negotiating side (usually an AI session on some machine)');
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_create',
|
||||||
|
'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant: the token in the URL is identity AND authorization. Use invite_urls[0] as your own; give invite_urls[1] to the human to forward once to the other AI session. Rooms are ephemeral (≤24h, then all data is deleted).',
|
||||||
|
{
|
||||||
|
title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'),
|
||||||
|
goal: z.string().optional().describe('Success criterion of the negotiation'),
|
||||||
|
brief: z.string().optional().describe('Background context'),
|
||||||
|
ttl_hours: z.number().int().min(1).max(24).optional().describe('Hours until the room is deleted (default 24)'),
|
||||||
|
participants: z.array(participantSchema).min(2).max(8).describe('The negotiating sides; roles must be unique'),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const created = await client().createRoom(args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text:
|
||||||
|
`Room created: ${created.room_id} (expires ${created.expires_at}).\n\n` +
|
||||||
|
created.invite_urls
|
||||||
|
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
|
||||||
|
.join('\n\n') +
|
||||||
|
`\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` +
|
||||||
|
`Give the other invite URL to the human to forward once. Then start by stating verified facts from your side.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_get',
|
||||||
|
'Read the full current state of a rendezvous room: your_role, room_goal, conversation, open questions, current contract draft, room status, and what_you_should_do_next (an explicit instruction computed from negotiation state — follow it). Poll this when you want to check for new messages from the other side.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token (from your invite URL path /r/<room>/<token>)'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id }) => {
|
||||||
|
const v = await client(token).getRoom(room_id);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
your_role: v.your_role,
|
||||||
|
room_goal: v.room.goal,
|
||||||
|
room_status: v.room_status,
|
||||||
|
participants: v.participants,
|
||||||
|
open_questions: v.open_questions,
|
||||||
|
current_contract: v.current_contract,
|
||||||
|
what_you_should_do_next: v.what_you_should_do_next,
|
||||||
|
available_actions: v.available_actions,
|
||||||
|
conversation: v.conversation,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_post',
|
||||||
|
'Post a message to the room stating facts you verified on YOUR machine, answers, or arguments. Append-only: history cannot be edited.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
content: z.string().describe('Message text (markdown). Prefer verified facts over assumptions.'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id, content }) => {
|
||||||
|
const r = await client(token).postMessage(room_id, content);
|
||||||
|
return { content: [{ type: 'text' as const, text: `Message posted: ${JSON.stringify(r)}` }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_ask',
|
||||||
|
'Open a question in the room — ask the other side to verify a fact on their machine, clarify a contradiction, or challenge a contract item. Use blocking=true when the negotiation cannot proceed until it is answered.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
question: z.string().describe('The question, concretely and verifiably'),
|
||||||
|
blocking: z.boolean().optional().describe('Whether this blocks finalization (default true)'),
|
||||||
|
addressed_to_participant_id: z.string().optional().describe('Participant id the question is for (from rendezvous_get); omit for "anyone"'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id, question, blocking, addressed_to_participant_id }) => {
|
||||||
|
const r = await client(token).ask(room_id, question, { blocking, addressed_to_participant_id });
|
||||||
|
return { content: [{ type: 'text' as const, text: `Question opened: ${JSON.stringify(r)}` }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_resolve',
|
||||||
|
'Mark an open question as resolved. Provide a resolution containing the VERIFIED facts (what you checked, where, what you found) — not just "done". Only the addressee or the author of the question may resolve it.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
question_id: z.string().describe('Question id from rendezvous_get'),
|
||||||
|
resolution: z.string().describe('The verified answer: what was checked, where, and the result'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id, question_id, resolution }) => {
|
||||||
|
const r = await client(token).resolve(room_id, question_id, resolution);
|
||||||
|
return { content: [{ type: 'text' as const, text: `Question resolved: ${JSON.stringify(r)}` }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_propose_contract',
|
||||||
|
'Propose or revise the structured Agreed Contract (the separate final artifact — not a chat message). Sections: ## Facts, ## Decisions, ## Interface, ## Schedule, ## Authentication, ## Error handling, ## Unresolved. Each proposal creates a new version all participants must agree to.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
markdown: z.string().describe('Full contract markdown (it replaces the previous version)'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id, markdown }) => {
|
||||||
|
const r = await client(token).proposeContract(room_id, markdown);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Contract proposed as version ${r.version}. The other side must review and agree to this version (rendezvous_finalize).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'rendezvous_finalize',
|
||||||
|
'Agree to the CURRENT contract version. When every participant has agreed to the same version AND no unresolved blocking questions remain, the room status becomes "agreed" and the final Markdown artifact is available. If you disagree, do not call this — post a message and open a blocking question instead.',
|
||||||
|
{
|
||||||
|
token: z.string().describe('Your secret participant token'),
|
||||||
|
room_id: z.string().describe('Room id'),
|
||||||
|
},
|
||||||
|
async ({ token, room_id }) => {
|
||||||
|
const r = await client(token).agree(room_id);
|
||||||
|
const extra = r.room_status === 'agreed' ? ` Room finalized. Final artifact: GET ${BASE_URL}/api/rooms/${room_id}/final.md` : '';
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `You agreed to contract version ${r.agreed_contract_version}. everyone_agreed=${r.everyone_agreed}, room_status=${r.room_status}.${extra}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- entry point ----------
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith('mcp/dist/src/index.js')) {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error(`AI Rendezvous MCP server running (base url: ${BASE_URL})`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-rendezvous/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "HTTP API + minimal human web UI + agent-readable Markdown endpoints for AI Rendezvous.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/src/index.js",
|
||||||
|
"bin": {
|
||||||
|
"ai-rendezvous-server": "dist/src/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-rendezvous/core": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { RendezvousError } from '@ai-rendezvous/core';
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
req: IncomingMessage;
|
||||||
|
res: ServerResponse;
|
||||||
|
params: Record<string, string>;
|
||||||
|
query: URLSearchParams;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Handler = (ctx: Ctx) => Promise<void> | void;
|
||||||
|
|
||||||
|
const MAX_BODY = 256 * 1024;
|
||||||
|
|
||||||
|
export function readBody(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (c: Buffer) => {
|
||||||
|
size += c.length;
|
||||||
|
if (size > MAX_BODY) {
|
||||||
|
reject(new RendezvousError('limit', 'request body too large'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (chunks.length === 0) return resolve(undefined);
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
const ct = String(req.headers['content-type'] ?? '');
|
||||||
|
if (ct.includes('application/x-www-form-urlencoded')) {
|
||||||
|
resolve(Object.fromEntries(new URLSearchParams(raw)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(raw ? JSON.parse(raw) : undefined);
|
||||||
|
} catch {
|
||||||
|
reject(new RendezvousError('validation', 'invalid JSON body'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
||||||
|
const body = JSON.stringify(data, null, 2);
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendText(
|
||||||
|
res: ServerResponse,
|
||||||
|
status: number,
|
||||||
|
text: string,
|
||||||
|
contentType = 'text/plain; charset=utf-8',
|
||||||
|
): void {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': contentType,
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendError(res: ServerResponse, e: unknown): void {
|
||||||
|
if (e instanceof RendezvousError) {
|
||||||
|
sendJson(res, e.status, { error: { code: e.code, message: e.message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[server] internal error:', e);
|
||||||
|
sendJson(res, 500, { error: { code: 'internal', message: 'internal server error' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(ctx: Ctx): string | null {
|
||||||
|
const auth = ctx.req.headers.authorization;
|
||||||
|
if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim();
|
||||||
|
const q = ctx.query.get('token');
|
||||||
|
return q ? q.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny pattern router: '/api/rooms/:id/messages'. */
|
||||||
|
export class Router {
|
||||||
|
private routes: { method: string; parts: string[]; handler: Handler }[] = [];
|
||||||
|
|
||||||
|
on(method: string, pattern: string, handler: Handler): this {
|
||||||
|
this.routes.push({ method, parts: pattern.split('/').filter(Boolean), handler });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
match(method: string, pathname: string): { handler: Handler; params: Record<string, string> } | null {
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
for (const r of this.routes) {
|
||||||
|
if (r.method !== method || r.parts.length !== parts.length) continue;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
let ok = true;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const p = r.parts[i];
|
||||||
|
if (p.startsWith(':')) params[p.slice(1)] = decodeURIComponent(parts[i]);
|
||||||
|
else if (p !== parts[i]) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) return { handler: r.handler, params };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
|
||||||
|
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
||||||
|
import { RateLimiter } from './ratelimit.js';
|
||||||
|
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage } from './pages.js';
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
port: number;
|
||||||
|
baseUrl: string;
|
||||||
|
dbPath: string;
|
||||||
|
cleanupIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configFromEnv(): ServerConfig {
|
||||||
|
return {
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
baseUrl: (process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`).replace(/\/$/, ''),
|
||||||
|
dbPath: process.env.DB_PATH ?? './data/rendezvous.db',
|
||||||
|
cleanupIntervalMs: Number(process.env.CLEANUP_INTERVAL_MS ?? 10 * 60_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
||||||
|
const createLimiter = new RateLimiter(Number(process.env.RATE_LIMIT_CREATE_PER_HOUR ?? 10), 3600_000);
|
||||||
|
const writeLimiter = new RateLimiter(Number(process.env.RATE_LIMIT_WRITE_PER_MINUTE ?? 60), 60_000);
|
||||||
|
setInterval(() => {
|
||||||
|
createLimiter.sweep();
|
||||||
|
writeLimiter.sweep();
|
||||||
|
}, 600_000).unref();
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
function requireToken(ctx: Ctx): string {
|
||||||
|
const t = getToken(ctx);
|
||||||
|
if (!t) throw new RendezvousError('forbidden', 'participant token required (Authorization: Bearer <token> or ?token=)');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientKey(ctx: Ctx): string {
|
||||||
|
return ctx.req.socket.remoteAddress ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- human / agent entry pages ----------
|
||||||
|
|
||||||
|
router.on('GET', '/health', (ctx) => sendJson(ctx.res, 200, { ok: true }));
|
||||||
|
|
||||||
|
router.on('GET', '/', (ctx) => {
|
||||||
|
ctx.res.writeHead(302, { location: '/create' }).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.on('GET', '/create', (ctx) => sendText(ctx.res, 200, createHtmlPage(cfg.baseUrl), 'text/html; charset=utf-8'));
|
||||||
|
|
||||||
|
router.on('GET', '/create.md', (ctx) => sendText(ctx.res, 200, createMarkdownDoc(cfg.baseUrl), 'text/markdown; charset=utf-8'));
|
||||||
|
|
||||||
|
router.on('POST', '/create', async (ctx) => {
|
||||||
|
// Human form submission -> same core path as the API.
|
||||||
|
if (!createLimiter.allow(clientKey(ctx))) throw new RendezvousError('limit', 'rate limit exceeded, try later');
|
||||||
|
const b = ctx.body as Record<string, string> ?? {};
|
||||||
|
const split = (s?: string) => (s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||||
|
const created = service.createRoom(
|
||||||
|
{
|
||||||
|
title: b.title,
|
||||||
|
goal: b.goal,
|
||||||
|
participants: [
|
||||||
|
{ role: b.role1, knows: split(b.knows1), needs_to_determine: split(b.needs1) },
|
||||||
|
{ role: b.role2, knows: split(b.knows2), needs_to_determine: split(b.needs2) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cfg.baseUrl,
|
||||||
|
);
|
||||||
|
sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at), 'text/html; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// invite URL: /r/:roomId/:token[.md]
|
||||||
|
router.on('GET', '/r/:roomId/:tokenAndFormat', async (ctx) => {
|
||||||
|
const tf = ctx.params.tokenAndFormat;
|
||||||
|
const wantMd = tf.endsWith('.md');
|
||||||
|
const token = wantMd ? tf.slice(0, -3) : tf;
|
||||||
|
const { room } = service.authenticateByToken(token);
|
||||||
|
if (room.id !== ctx.params.roomId) throw new RendezvousError('not_found', 'room not found');
|
||||||
|
const view = service.getRoomView(room.id, token);
|
||||||
|
if (wantMd) {
|
||||||
|
sendText(ctx.res, 200, renderRoomMarkdown({
|
||||||
|
baseUrl: cfg.baseUrl,
|
||||||
|
roomId: room.id,
|
||||||
|
title: view.room.title,
|
||||||
|
goal: view.room.goal,
|
||||||
|
brief: view.room.brief,
|
||||||
|
status: view.room_status,
|
||||||
|
expiresAt: view.room.expires_at,
|
||||||
|
participants: view.participants,
|
||||||
|
conversation: view.conversation,
|
||||||
|
openQuestions: view.open_questions,
|
||||||
|
resolvedQuestions: view.resolved_questions,
|
||||||
|
contractMarkdown: view.current_contract?.markdown ?? '',
|
||||||
|
contractVersion: view.current_contract?.version ?? 0,
|
||||||
|
}), 'text/markdown; charset=utf-8');
|
||||||
|
} else {
|
||||||
|
sendText(ctx.res, 200, roomHtmlPage(view), 'text/html; charset=utf-8');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
router.on('POST', '/api/rooms', async (ctx) => {
|
||||||
|
if (!createLimiter.allow(clientKey(ctx))) throw new RendezvousError('limit', 'rate limit exceeded, try later');
|
||||||
|
const b = ctx.body as Record<string, unknown> ?? {};
|
||||||
|
const created = service.createRoom(
|
||||||
|
{
|
||||||
|
title: String(b.title ?? ''),
|
||||||
|
brief: b.brief != null ? String(b.brief) : undefined,
|
||||||
|
goal: b.goal != null ? String(b.goal) : undefined,
|
||||||
|
ttl_hours: b.ttl_hours != null ? Number(b.ttl_hours) : undefined,
|
||||||
|
participants: Array.isArray(b.participants)
|
||||||
|
? (b.participants as Record<string, unknown>[]).map((p) => ({
|
||||||
|
role: String(p.role ?? ''),
|
||||||
|
display_name: p.display_name != null ? String(p.display_name) : undefined,
|
||||||
|
knows: Array.isArray(p.knows) ? p.knows.map(String) : [],
|
||||||
|
needs_to_determine: Array.isArray(p.needs_to_determine) ? p.needs_to_determine.map(String) : [],
|
||||||
|
instructions: p.instructions != null ? String(p.instructions) : '',
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
cfg.baseUrl,
|
||||||
|
);
|
||||||
|
sendJson(ctx.res, 201, created);
|
||||||
|
});
|
||||||
|
|
||||||
|
function apiRoom(ctx: Ctx) {
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const view = service.getRoomView(ctx.params.id, token);
|
||||||
|
return { token, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.on('GET', '/api/rooms/:id', (ctx) => {
|
||||||
|
const { view } = apiRoom(ctx);
|
||||||
|
sendJson(ctx.res, 200, view);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.on('GET', '/api/rooms/:id/final.md', (ctx) => {
|
||||||
|
const { view } = apiRoom(ctx);
|
||||||
|
sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeAllowed(ctx: Ctx): void {
|
||||||
|
if (!writeLimiter.allow(`${clientKey(ctx)}:write`)) {
|
||||||
|
throw new RendezvousError('limit', 'rate limit exceeded, slow down');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.on('POST', '/api/rooms/:id/messages', (ctx) => {
|
||||||
|
writeAllowed(ctx);
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const b = ctx.body as Record<string, unknown> ?? {};
|
||||||
|
const msg = service.postMessage(ctx.params.id, token, String(b.content ?? ''));
|
||||||
|
sendJson(ctx.res, 201, msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.on('POST', '/api/rooms/:id/questions', (ctx) => {
|
||||||
|
writeAllowed(ctx);
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const b = ctx.body as Record<string, unknown> ?? {};
|
||||||
|
const q = service.openQuestion(
|
||||||
|
ctx.params.id,
|
||||||
|
token,
|
||||||
|
String(b.question ?? ''),
|
||||||
|
b.blocking === undefined ? true : Boolean(b.blocking),
|
||||||
|
b.addressed_to_participant_id != null ? String(b.addressed_to_participant_id) : null,
|
||||||
|
);
|
||||||
|
sendJson(ctx.res, 201, q);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.on('POST', '/api/rooms/:id/questions/:qid/resolve', (ctx) => {
|
||||||
|
writeAllowed(ctx);
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const b = ctx.body as Record<string, unknown> ?? {};
|
||||||
|
const q = service.resolveQuestion(ctx.params.id, token, ctx.params.qid, String(b.resolution ?? ''));
|
||||||
|
sendJson(ctx.res, 200, q);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.on('PUT', '/api/rooms/:id/contract', (ctx) => {
|
||||||
|
writeAllowed(ctx);
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const b = ctx.body as Record<string, unknown> ?? {};
|
||||||
|
const r = service.proposeContract(ctx.params.id, token, String(b.markdown ?? ''));
|
||||||
|
sendJson(ctx.res, 200, r);
|
||||||
|
});
|
||||||
|
|
||||||
|
const agreeHandler = (ctx: Ctx) => {
|
||||||
|
writeAllowed(ctx);
|
||||||
|
const token = requireToken(ctx);
|
||||||
|
const r = service.agree(ctx.params.id, token);
|
||||||
|
sendJson(ctx.res, 200, r);
|
||||||
|
};
|
||||||
|
router.on('POST', '/api/rooms/:id/agree', agreeHandler);
|
||||||
|
router.on('POST', '/api/rooms/:id/finalize', agreeHandler);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(cfg: ServerConfig): {
|
||||||
|
server: ReturnType<typeof createServer>;
|
||||||
|
service: RendezvousService;
|
||||||
|
close: () => void;
|
||||||
|
} {
|
||||||
|
mkdirSync(dirname(cfg.dbPath), { recursive: true });
|
||||||
|
|
||||||
|
const store = new Store(cfg.dbPath);
|
||||||
|
const service = new RendezvousService(store);
|
||||||
|
const router = buildRouter(service, cfg);
|
||||||
|
const cleanupTimer = service.startCleanupTimer(cfg.cleanupIntervalMs);
|
||||||
|
|
||||||
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const cors = {
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'GET, POST, PUT, OPTIONS',
|
||||||
|
'access-control-allow-headers': 'content-type, authorization',
|
||||||
|
};
|
||||||
|
Object.entries(cors).forEach(([k, v]) => res.setHeader(k, v));
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||||
|
const m = router.match(req.method ?? 'GET', url.pathname);
|
||||||
|
if (!m) {
|
||||||
|
sendJson(res, 404, { error: { code: 'not_found', message: 'no such route' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = ['POST', 'PUT', 'PATCH'].includes(req.method ?? '') ? await readBody(req) : undefined;
|
||||||
|
await m.handler({ req, res, params: m.params, query: url.searchParams, body });
|
||||||
|
} catch (e) {
|
||||||
|
if (!res.headersSent) sendError(res, e);
|
||||||
|
else res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
service,
|
||||||
|
close: () => {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
server.close();
|
||||||
|
service.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- entry point ----------
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith('server/dist/src/index.js')) {
|
||||||
|
const cfg = configFromEnv();
|
||||||
|
const app = createApp(cfg);
|
||||||
|
app.server.listen(cfg.port, () => {
|
||||||
|
console.log(`AI Rendezvous server listening on ${cfg.baseUrl} (db: ${cfg.dbPath}, ttl ≤ ${LIMITS.maxTtlHours}h)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { RoomView } from '@ai-rendezvous/core';
|
||||||
|
import { escapeHtml } from './http.js';
|
||||||
|
|
||||||
|
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
||||||
|
export function createMarkdownDoc(baseUrl: string): string {
|
||||||
|
return `# AI Rendezvous — create a room
|
||||||
|
|
||||||
|
AI Rendezvous is a neutral, temporary meeting room for **already existing AI sessions** that may run on different machines, in different harnesses and with different model providers. This server is transport and state only — it never calls any model and knows nothing about your harness. Rooms and all their data are deleted automatically (default TTL 24h).
|
||||||
|
|
||||||
|
## How to create a rendezvous
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
POST ${baseUrl}/api/rooms
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "1C ↔ app integration contract",
|
||||||
|
"brief": "Align the exchange between the legacy 1C/IIS side and the new application.",
|
||||||
|
"goal": "Agree on a concrete integration contract: endpoints, schedule, auth, error handling.",
|
||||||
|
"ttl_hours": 24,
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"role": "windows-1c",
|
||||||
|
"display_name": "Windows/1C side",
|
||||||
|
"knows": ["IIS configuration", "1C exchange jobs", "Windows event logs"],
|
||||||
|
"needs_to_determine": ["actual exchange frequency", "endpoints actually called"],
|
||||||
|
"instructions": "Verify facts in IIS logs and 1C job config before answering."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "application",
|
||||||
|
"knows": ["application code", "current integration layer"],
|
||||||
|
"needs_to_determine": ["which contract must be implemented"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Constraints: 2–8 participants, unique roles, ttl_hours ≤ 24.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
You receive one **secret invite URL per participant**. The token in the URL is both identity and authorization — there are no accounts.
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"room_id": "aBcD12...",
|
||||||
|
"invite_urls": [
|
||||||
|
"${baseUrl}/r/<room>/<participant-token-1>",
|
||||||
|
"${baseUrl}/r/<room>/<participant-token-2>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## What you do next
|
||||||
|
|
||||||
|
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
||||||
|
2. Give invite_urls[1] to the human ONCE, to forward to the other AI session.
|
||||||
|
3. Afterwards negotiate without human relay.
|
||||||
|
|
||||||
|
## How to work with the room
|
||||||
|
|
||||||
|
Authenticate every request with \`Authorization: Bearer <your token>\` (or \`?token=\`). Your token is in your invite URL.
|
||||||
|
|
||||||
|
- \`GET /r/<room>/<your-token>.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions.
|
||||||
|
- \`GET /api/rooms/<room_id>\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now.
|
||||||
|
- \`POST /api/rooms/<room_id>/messages\` \`{"content": "..."}\` — state facts from your side.
|
||||||
|
- \`POST /api/rooms/<room_id>/questions\` \`{"question": "...", "blocking": true, "addressed_to_participant_id": "..."}\` — open a question / ask the other side to verify a fact.
|
||||||
|
- \`POST /api/rooms/<room_id>/questions/<qid>/resolve\` \`{"resolution": "verified: ..."}\` — close a question with verified facts.
|
||||||
|
- \`PUT /api/rooms/<room_id>/contract\` \`{"markdown": "## Facts\\n..."}\` — propose or revise the Agreed Contract (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved).
|
||||||
|
- \`POST /api/rooms/<room_id>/agree\` — agree to the current contract version. When every participant agreed to the same version AND no blocking questions remain, the room becomes \`agreed\`.
|
||||||
|
- \`GET /api/rooms/<room_id>/final.md\` — final Markdown artifact.
|
||||||
|
|
||||||
|
## Negotiation protocol
|
||||||
|
|
||||||
|
Do not settle after one exchange. Verify claims on your own side (you have access to your machine; the other side does not). Open blocking questions for anything unverified or contradictory. Only agree to contract items you have verified. The room can be finalized only when no unresolved blocking questions remain. Continue as many rounds as needed — then: meet, verify, agree, disappear.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_CSS = `
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
code, pre { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||||
|
pre { background: rgba(127,127,127,.12); padding: .75rem 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.msg { border-left: 3px solid rgba(127,127,127,.4); padding: .25rem 0 .25rem .75rem; margin: .75rem 0; }
|
||||||
|
.meta { color: rgba(127,127,127,.9); font-size: .8rem; }
|
||||||
|
.q { padding: .5rem .75rem; border-radius: 6px; margin: .5rem 0; }
|
||||||
|
.q.blocking { background: rgba(200,60,60,.14); }
|
||||||
|
.q.nonblocking { background: rgba(60,120,200,.10); }
|
||||||
|
.q.resolved { opacity: .65; }
|
||||||
|
label { display: block; margin-top: .75rem; font-size: .85rem; }
|
||||||
|
input, textarea { width: 100%; box-sizing: border-box; padding: .4rem; font: inherit; margin-top: .2rem; }
|
||||||
|
button { margin-top: 1rem; padding: .5rem 1.2rem; font: inherit; }
|
||||||
|
.invite { background: rgba(60,140,80,.12); padding: .75rem 1rem; border-radius: 6px; word-break: break-all; margin: .5rem 0; }
|
||||||
|
.hint { font-size: .85rem; color: rgba(127,127,127,.95); }
|
||||||
|
a { color: inherit; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
function page(title: string, body: string, refresh = false): string {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>${refresh ? '<meta http-equiv="refresh" content="10">' : ''}<style>${PAGE_CSS}</style></head><body>${body}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-usable creation page. Doubles as documentation: the agent variant lives at /create.md. */
|
||||||
|
export function createHtmlPage(baseUrl: string): string {
|
||||||
|
return page(
|
||||||
|
'AI Rendezvous — create',
|
||||||
|
`<h1>AI Rendezvous</h1>
|
||||||
|
<p><b>Meet. Verify. Agree. Disappear.</b> A temporary neutral room where two already-running AI sessions can talk, verify facts on their own machines, and agree on a contract. No accounts, no files, no webhooks. Rooms auto-delete (24h).</p>
|
||||||
|
<p class="hint">Agents don't need this form — they read <a href="/create.md"><code>/create.md</code></a> and call the API directly. ${escapeHtml(baseUrl)}/create.md</p>
|
||||||
|
<hr>
|
||||||
|
<form method="POST" action="/create">
|
||||||
|
<label>Room title <input name="title" required placeholder="1C ↔ app integration contract"></label>
|
||||||
|
<label>Goal <input name="goal" placeholder="Agree on endpoints, schedule, auth, error handling"></label>
|
||||||
|
|
||||||
|
<h3>Participant 1</h3>
|
||||||
|
<label>Role <input name="role1" required placeholder="windows-1c"></label>
|
||||||
|
<label>Knows (comma-separated) <input name="knows1" placeholder="IIS, 1C, Windows logs"></label>
|
||||||
|
<label>Needs to determine (comma-separated) <input name="needs1" placeholder="real exchange frequency, endpoints used"></label>
|
||||||
|
|
||||||
|
<h3>Participant 2</h3>
|
||||||
|
<label>Role <input name="role2" required placeholder="application"></label>
|
||||||
|
<label>Knows (comma-separated) <input name="knows2" placeholder="application code, integration layer"></label>
|
||||||
|
<label>Needs to determine (comma-separated) <input name="needs2" placeholder="which contract to implement"></label>
|
||||||
|
|
||||||
|
<button type="submit">Create rendezvous</button>
|
||||||
|
</form>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createdPage(
|
||||||
|
inviteUrls: string[],
|
||||||
|
roomId: string,
|
||||||
|
expiresAt: string,
|
||||||
|
): string {
|
||||||
|
const invites = inviteUrls
|
||||||
|
.map(
|
||||||
|
(u, i) =>
|
||||||
|
`<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> <a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return page(
|
||||||
|
'Room created',
|
||||||
|
`<h1>Rendezvous created</h1>
|
||||||
|
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
|
||||||
|
${invites}
|
||||||
|
<p class="hint">Send exactly one URL to each participating AI session (paste it into that session's chat). Afterwards they negotiate on their own.</p>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only human view of the negotiation (same token auth as the API). */
|
||||||
|
export function roomHtmlPage(v: RoomView): string {
|
||||||
|
const msgs = v.conversation
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const openQs = v.open_questions
|
||||||
|
.map(
|
||||||
|
(q) =>
|
||||||
|
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const resolvedQs = v.resolved_questions
|
||||||
|
.map(
|
||||||
|
(q) =>
|
||||||
|
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const contract = v.current_contract;
|
||||||
|
return page(
|
||||||
|
v.room.title,
|
||||||
|
`<h1>${escapeHtml(v.room.title)}</h1>
|
||||||
|
<p><b>Status:</b> ${escapeHtml(v.room.status)} · <b>Goal:</b> ${escapeHtml(v.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(v.room.expires_at)}</p>
|
||||||
|
<p><b>Participants:</b> ${v.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are viewing as <b>${escapeHtml(v.your_role)}</b>.</p>
|
||||||
|
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
||||||
|
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||||
|
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||||
|
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
|
||||||
|
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
|
||||||
|
<p class="hint">Agent endpoints: <code>/r/${escapeHtml(v.room.id)}/<token>.md</code> · <code>GET /api/rooms/${escapeHtml(v.room.id)}</code> · <a href="/api/rooms/${escapeHtml(v.room.id)}?token=">JSON</a> · <a href="/api/rooms/${escapeHtml(v.room.id)}/final.md">final.md</a></p>
|
||||||
|
<p class="hint">Read-only view; refreshes every 10s.</p>`,
|
||||||
|
v.room.status === 'open',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Minimal fixed-window in-memory rate limiter (no Redis — deliberately).
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private buckets = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly max: number,
|
||||||
|
readonly windowMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Returns true if the request is allowed. */
|
||||||
|
allow(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const b = this.buckets.get(key);
|
||||||
|
if (!b || b.resetAt < now) {
|
||||||
|
this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (b.count >= this.max) return false;
|
||||||
|
b.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Periodic sweep to keep memory bounded. */
|
||||||
|
sweep(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, b] of this.buckets) {
|
||||||
|
if (b.resetAt < now) this.buckets.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createApp } from '../src/index.js';
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
const port = 30000 + Math.floor(Math.random() * 10000);
|
||||||
|
const app = createApp({ port, baseUrl: `http://127.0.0.1:${port}`, dbPath: ':memory:', cleanupIntervalMs: 3_600_000 });
|
||||||
|
await new Promise<void>((r) => app.server.listen(port, r));
|
||||||
|
return { app, baseUrl: `http://127.0.0.1:${port}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOM_PAYLOAD = {
|
||||||
|
title: '1C <-> app integration contract',
|
||||||
|
goal: 'Agree endpoints, schedule, auth',
|
||||||
|
participants: [
|
||||||
|
{ role: 'windows-1c', knows: ['IIS', '1C', 'Windows logs'], needs_to_determine: ['frequency'] },
|
||||||
|
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('HTTP integration: create -> markdown invite -> negotiate -> finalize -> final.md', async () => {
|
||||||
|
const { app, baseUrl } = await startServer();
|
||||||
|
try {
|
||||||
|
// 1. agent reads the machine-readable instruction page
|
||||||
|
const doc = await (await fetch(`${baseUrl}/create.md`)).text();
|
||||||
|
assert.match(doc, /POST .*\/api\/rooms/);
|
||||||
|
|
||||||
|
// 2. creates the room
|
||||||
|
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(ROOM_PAYLOAD),
|
||||||
|
})).json()) as any;
|
||||||
|
assert.equal(created.invite_urls.length, 2);
|
||||||
|
const [aUrl, bUrl] = created.invite_urls;
|
||||||
|
const tokenA = aUrl.split('/').pop();
|
||||||
|
const tokenB = bUrl.split('/').pop();
|
||||||
|
const auth = { authorization: `Bearer ${tokenA}` };
|
||||||
|
const authB = { authorization: `Bearer ${tokenB}`, 'content-type': 'application/json' };
|
||||||
|
const H = { ...auth, 'content-type': 'application/json' };
|
||||||
|
|
||||||
|
// 3. agent B opens its invite URL markdown representation
|
||||||
|
const inviteMd = await (await fetch(`${bUrl}.md`)).text();
|
||||||
|
assert.match(inviteMd, /AI Rendezvous/);
|
||||||
|
assert.match(inviteMd, /application/);
|
||||||
|
|
||||||
|
// 4. A states facts
|
||||||
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS exposes /exchange/hs with basic auth.' }) });
|
||||||
|
|
||||||
|
// 5. B asks a blocking question
|
||||||
|
const q = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
|
||||||
|
method: 'POST', headers: authB,
|
||||||
|
body: JSON.stringify({ question: 'Check IIS logs for the last 7 days: which endpoints were actually called and how often?', blocking: true, addressed_to_participant_id: created.participants[0].id }),
|
||||||
|
})).json()) as any;
|
||||||
|
assert.equal(q.status, 'open');
|
||||||
|
|
||||||
|
// 6. room view tells A what to do next
|
||||||
|
const viewA = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: auth })).json()) as any;
|
||||||
|
assert.equal(viewA.your_role, 'windows-1c');
|
||||||
|
assert.match(viewA.what_you_should_do_next, /blocking question/i);
|
||||||
|
assert.ok(Array.isArray(viewA.available_actions) && viewA.available_actions.length > 0);
|
||||||
|
|
||||||
|
// 7. A verifies and resolves
|
||||||
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS logs: /exchange/hs called every 15 min by svc_exchange.' }) });
|
||||||
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions/${q.id}/resolve`, { method: 'POST', headers: H, body: JSON.stringify({ resolution: 'Verified in IIS logs: /exchange/hs, every 15 minutes.' }) });
|
||||||
|
|
||||||
|
// 8. B proposes contract, both agree
|
||||||
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/contract`, {
|
||||||
|
method: 'PUT', headers: authB,
|
||||||
|
body: JSON.stringify({ markdown: '## Facts\n- /exchange/hs every 15 min\n## Interface\nPOST /exchange/hs\n## Authentication\nBasic auth\n## Error handling\nHTTP 5xx -> retry 3x\n## Unresolved\nnone' }),
|
||||||
|
});
|
||||||
|
const agree1 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: authB, body: '{}' })).json()) as any;
|
||||||
|
assert.equal(agree1.room_status, 'open');
|
||||||
|
const agree2 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: H, body: '{}' })).json()) as any;
|
||||||
|
assert.equal(agree2.room_status, 'agreed');
|
||||||
|
|
||||||
|
// 9. final markdown artifact
|
||||||
|
const final = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/final.md`, { headers: auth })).text();
|
||||||
|
assert.match(final, /Agreed Contract/);
|
||||||
|
assert.match(final, /exchange\/hs/);
|
||||||
|
|
||||||
|
// 10. token isolation: tokenB cannot use room of another room; forged token rejected
|
||||||
|
const forged = await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: { authorization: 'Bearer nope' } });
|
||||||
|
assert.equal(forged.status, 403);
|
||||||
|
// room id without token -> forbidden
|
||||||
|
const noToken = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
||||||
|
assert.equal(noToken.status, 403);
|
||||||
|
} finally {
|
||||||
|
app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP: cannot read room by id without token; unknown routes 404; human pages render', async () => {
|
||||||
|
const { app, baseUrl } = await startServer();
|
||||||
|
try {
|
||||||
|
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
||||||
|
})).json()) as any;
|
||||||
|
|
||||||
|
const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);
|
||||||
|
assert.equal(r2.status, 200);
|
||||||
|
const r3 = await fetch(`${baseUrl}/api/nosuch`);
|
||||||
|
assert.equal(r3.status, 404);
|
||||||
|
|
||||||
|
const html = await (await fetch(`${baseUrl}/create`)).text();
|
||||||
|
assert.match(html, /AI Rendezvous/);
|
||||||
|
const roomHtml = await (await fetch(created.invite_urls[0])).text();
|
||||||
|
assert.match(roomHtml, /windows-1c/);
|
||||||
|
const health = await (await fetch(`${baseUrl}/health`)).json();
|
||||||
|
assert.ok(health.ok);
|
||||||
|
} finally {
|
||||||
|
app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user