AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server

This commit is contained in:
2026-09-06 19:00:16 +03:00
commit f3abd3c741
35 changed files with 4213 additions and 0 deletions
+18
View File
@@ -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"
}
}
+97
View File
@@ -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);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src"]
}
+15
View File
@@ -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"
}
}
+106
View File
@@ -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',
];
}
+6
View File
@@ -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';
+14
View File
@@ -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;
+122
View File
@@ -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');
}
+520
View File
@@ -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();
}
}
+142
View File
@@ -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();
}
}
+107
View File
@@ -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);
}
}
+157
View File
@@ -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');
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src", "test"]
}
+22
View File
@@ -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"
}
}
+192
View File
@@ -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})`);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src"]
}
+20
View File
@@ -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"
}
}
+120
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
+260
View File
@@ -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)`);
});
}
+184
View File
@@ -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: 28 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> &nbsp;<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)}/&lt;token&gt;.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',
);
}
+31
View File
@@ -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);
}
}
}
+116
View File
@@ -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();
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src", "test"]
}