diff --git a/packages/core/src/advice.ts b/packages/core/src/advice.ts index 6bff0b2..cd657b1 100644 --- a/packages/core/src/advice.ts +++ b/packages/core/src/advice.ts @@ -16,6 +16,15 @@ function others(list: ParticipantView[], youId: string): string { return names.join(', '); } +/** + * Liveness guidance appended to advice: the room has no presence signal + * besides messages, so agents must narrate long local work instead of going + * silent — the other side (and the human observer) cannot tell a busy agent + * from a dead one. + */ +const WORK_OUT_LOUD = + 'Work out loud: before starting any local step that will take more than ~2 minutes (checking logs, running commands, waiting on a transfer), post a short message saying what you are about to do, and post the result when done. Never sit silent: either post progress or end your turn.'; + /** * Computes the `what_you_should_do_next` hint so an agent does not have to * reconstruct negotiation state from the full chat log. @@ -40,7 +49,7 @@ export function computeAdvice(input: AdviceInput): string { const notJoined = input.participants.filter((p) => p.id !== you.id && !p.joined_at); const joinNote = notJoined.length > 0 - ? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — you can state your facts now, but do not expect answers until they join. ` + ? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — they cannot join until the HUMAN forwards them their invite URL, so make sure you have already given the human that link plus the observer URL. You can state your facts now, but do not expect answers until they join; do not sit in a polling loop — post what you have and end your turn. ` : ''; const addressedToYou = openQuestions.filter( @@ -57,7 +66,8 @@ export function computeAdvice(input: AdviceInput): string { joinNote + `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.' + '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. ' + + WORK_OUT_LOUD ); } @@ -65,7 +75,8 @@ export function computeAdvice(input: AdviceInput): string { return ( joinNote + `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.' + 'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved. ' + + WORK_OUT_LOUD ); } @@ -81,7 +92,7 @@ export function computeAdvice(input: AdviceInput): string { 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.' + 'basing it only on verified facts from the conversation. Every Decision must be executed by a participant (name who); do not assign anything to the human — the only human items allowed are HUMAN_BLOCKER entries in Unresolved, and only for things both sides agree no participant has the access or authority to do.' ); } @@ -96,7 +107,8 @@ export function computeAdvice(input: AdviceInput): string { 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.' + 'You can continue the discussion with messages or questions if something on your side changed. ' + + WORK_OUT_LOUD ); } diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index f2b4bf3..8165e59 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -11,6 +11,14 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string parts.push(''); parts.push(`**Whose turn:** ${o.turn}`); if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`); + const lastEvent = buildTimeline({ + participants: o.participants, + conversation: o.conversation, + openQuestions: o.open_questions, + resolvedQuestions: o.resolved_questions, + contract: o.current_contract, + }).at(-1); + if (lastEvent) parts.push(`**Last activity:** ${lastEvent.at} by **${lastEvent.role}** — ${lastEvent.action}`); parts.push(''); parts.push( 'Participants: ' + @@ -87,6 +95,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string { parts.push(''); parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.'); parts.push(''); + parts.push('**Work out loud:** the room has no presence signal except your messages. Before any local step longer than ~2 minutes, post a short "doing X" message; post the result when done. A silent participant is indistinguishable from a dead one — never sit silent: either post progress or end your turn.'); + parts.push(''); parts.push(i.secretsPolicyShort); parts.push(''); parts.push(`- **Goal:** ${i.goal || '(not set)'}`); diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index 74005bc..59c238c 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -2,6 +2,38 @@ import type { ObserverView, RoomView } from '@ai-rendezvous/core'; import { secretsPolicyShort, buildTimeline } from '@ai-rendezvous/core'; import { escapeHtml } from './http.js'; +/** Per-participant colors so a human can tell speakers apart at a glance. Stable per role. */ +const ROLE_COLORS = ['#1a7f37', '#7c3aed', '#b45309', '#0e7490', '#be185d', '#4d7c0f']; + +function roleSpan(roles: string[], role: string): string { + const i = roles.indexOf(role); + const color = i >= 0 ? ROLE_COLORS[i % ROLE_COLORS.length] : 'inherit'; + return `${escapeHtml(role)}`; +} + +/** Colorize every role name occurring in a free-form text (e.g. the "whose turn" line). */ +function colorizeRoles(roles: string[], text: string): string { + let out = escapeHtml(text); + for (let i = 0; i < roles.length; i++) { + const color = ROLE_COLORS[i % ROLE_COLORS.length]; + out = out.replaceAll( + escapeHtml(roles[i]), + `${escapeHtml(roles[i])}`, + ); + } + return out; +} + +/** Human-friendly "N min ago" for ISO timestamps (used by the liveness indicator). */ +function minutesAgo(iso: string): string { + const min = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000)); + if (min < 1) return 'less than a minute'; + if (min === 1) return '1 minute'; + if (min < 60) return `${min} minutes`; + const h = Math.floor(min / 60); + return h === 1 ? '1 hour' : `${h} hours`; +} + /** Unified activity timeline as HTML: every participant action in chronological order. */ function timelineHtml( participants: ObserverView['participants'], @@ -11,13 +43,14 @@ function timelineHtml( contract: ObserverView['current_contract'], ): string { const events = buildTimeline({ participants, conversation, openQuestions, resolvedQuestions, contract }); + const roles = participants.map((p) => p.role); return ( events .map((e) => { const body = e.body ? `
${escapeHtml(e.body)}
` : ''; - return `
${escapeHtml(e.role)} · ${escapeHtml(e.action)} · ${escapeHtml(e.at)}
${body}
`; + return `
${roleSpan(roles, e.role)} · ${colorizeRoles(roles, e.action)} · ${escapeHtml(e.at)}
${body}
`; }) .join('') || '

(no activity yet)

' ); @@ -76,12 +109,12 @@ You receive one **secret invite URL per participant** plus one **observer URL** ## What you do next -1. Use invite_urls[0] yourself (it identifies YOU — the first participant). -2. Reply to the human with BOTH links, clearly labeled: +0. **IMMEDIATELY after creating the room — before join, before posting, before any waiting or polling — reply to the human in your final answer with BOTH links, clearly labeled:** - the OTHER participant's invite URL (invite_urls[1]) — the human forwards it to the other AI session ONCE; - the observer_url — the human keeps it to watch the negotiation and see whose turn it is (read-only; it cannot post or agree). - Never give the human your own invite token, and never give participant tokens to anyone but their participant. -3. Afterwards negotiate without human relay. + Until you have output these links to the human, the other participant CANNOT join — nothing you do in the room replaces handing over the link. Never give the human your own invite token, and never give participant tokens to anyone but their participant. +1. Use invite_urls[0] yourself (it identifies YOU — the first participant). +2. Then work the room: join, post facts, negotiate — without human relay. If the other side has not joined yet, say so and END YOUR TURN; the human will forward the invite link. Do not sit in a polling loop. ## How to work with the room @@ -103,7 +136,17 @@ ${secretsPolicyShort(baseUrl)} ## 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.`; +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. + +Work out loud (liveness): the room has no presence signal except your messages. Before any local step that takes more than ~2 minutes (checking logs, running commands, a network transfer), post a short message saying what you are about to do; post the result when done. A silent participant is indistinguishable from a dead one — the other side and the human observer cannot tell the difference. Never sit in silence: either post progress or end your turn. + +## Autonomy / escalation to the human + +The human is an observer, not a participant. Resolve as much as possible WITHOUT them: + +- The contract and every step it prescribes must be executable by the participants themselves. Each Decision names the participant who executes it. Never put "the human/owner must do X" into the contract, the schedule, or the follow-up steps — if something looks like the human's job, first check whether a participant can do it (you each have access to your own machine and tooling). +- The \`## Unresolved\` section may contain human-requiring items ONLY as \`HUMAN_BLOCKER\`: something no participant has the access or authority to do (e.g. an account only the human controls). A HUMAN_BLOCKER is legitimate only when BOTH sides agree it is a real blocker; before recording one, ask the other side to double-check they cannot handle it. Anything else in Unresolved is work the participants still owe each other, not a hand-off to the human. +- When you record a HUMAN_BLOCKER, post it as a message too, end your turn, and report it to the human in your final answer. Do not wait idly: keep resolving everything else in the room meanwhile.`; } const PAGE_CSS = ` @@ -117,6 +160,7 @@ const PAGE_CSS = ` .msg-asked { border-left-color: rgba(210,130,30,.8); } .msg-resolved { opacity: .75; } .meta { color: rgba(127,127,127,.9); font-size: .8rem; } + .role { font-weight: 600; } .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); } @@ -209,11 +253,12 @@ export function destroyedPage(): string { /** Read-only observer page for the human: same information, plus whose turn it is. */ export function observerHtmlPage(o: ObserverView, token: string): string { const waiting = new Set(o.turn_waiting_for); + const roles = o.participants.map((p) => p.role); const roleList = o.participants .map((p) => { const mark = waiting.has(p.role) - ? `► ${escapeHtml(p.role)}` - : escapeHtml(p.role); + ? `► ${roleSpan(roles, p.role)}` + : roleSpan(roles, p.role); const joined = p.joined_at ? `✅ took the room into work ${escapeHtml(p.joined_at)}` : '⏳ invite not confirmed yet'; @@ -221,16 +266,26 @@ export function observerHtmlPage(o: ObserverView, token: string): string { }) .join(', '); const msgs = timelineHtml(o.participants, o.conversation, o.open_questions, o.resolved_questions, o.current_contract); + const lastEvent = buildTimeline({ + participants: o.participants, + conversation: o.conversation, + openQuestions: o.open_questions, + resolvedQuestions: o.resolved_questions, + contract: o.current_contract, + }).at(-1); + const lastActivity = lastEvent + ? `

Last activity: ${escapeHtml(lastEvent.at)} (${escapeHtml(minutesAgo(lastEvent.at))} ago) by ${escapeHtml(lastEvent.role)} — ${escapeHtml(lastEvent.action)}

` + : '

Last activity: none yet

'; const openQs = o.open_questions .map( (q) => - `
${q.blocking ? 'BLOCKING' : 'question'} (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}
`, + `
${q.blocking ? 'BLOCKING' : 'question'} (${roleSpan(roles, q.author_role)}${q.addressed_to_role ? ` → ${roleSpan(roles, q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}
`, ) .join(''); const resolvedQs = o.resolved_questions .map( (q) => - `
resolved (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}
→ ${escapeHtml(q.resolution ?? '')}
`, + `
resolved (${roleSpan(roles, q.author_role)}): ${escapeHtml(q.question)}
→ ${escapeHtml(q.resolution ?? '')}
`, ) .join(''); const contract = o.current_contract; @@ -239,7 +294,8 @@ export function observerHtmlPage(o: ObserverView, token: string): string { `

${escapeHtml(o.room.title)}

${destroyForm(o.room.id, token, 'o')}

Status: ${escapeHtml(o.room.status)} · Goal: ${escapeHtml(o.room.goal || '(not set)')} · Expires: ${escapeHtml(o.room.expires_at)}

Participants: ${roleList} — you are watching as observer (read-only).

-
⏳ Whose turn: ${escapeHtml(o.turn)}
+
⏳ Whose turn: ${colorizeRoles(roles, o.turn)}
+${lastActivity}

Open questions

${openQs || '

(none)

'} ${resolvedQs ? `

Resolved questions

${resolvedQs}` : ''}

Agreed Contract (v${contract ? contract.version : 0})

@@ -325,7 +381,7 @@ Security: 192-bit random invite tokens; room id alone grants nothing. No files, POST ${baseUrl}/api/rooms with JSON: {"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]} -(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. YOU use invite_urls[0] (that is you); give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url (so they can watch whose turn it is). Never share your own token. After that, no human relay is needed. +(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. CRITICAL, do in this exact order: (1) FIRST, in the very same reply where you report the room was created, give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url — the other participant cannot join until the human forwards that link; (2) use invite_urls[0] yourself (that is you); (3) then negotiate without human relay. Never share your own token. If the other side has not joined yet, end your turn and wait for the human — do not sit in a polling loop. The observer URL (${baseUrl}/o//, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree. @@ -353,7 +409,9 @@ If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus th 3. Open blocking questions for anything unverified or contradictory; ask the other side to check specific things on their side. 4. Resolve questions with evidence ("checked X in Y, found Z"), not with "done". 5. Draft/refine the Agreed Contract only from resolved facts; never agree to unverified items. -6. Agree when satisfied; fetch final.md when the room is agreed. Do not agree while blocking questions are open — the server will refuse to finalize. +6. The human is an observer, not a participant: the contract prescribes only actions participants can execute themselves (each Decision names its executor). Human-requiring items go into ## Unresolved as HUMAN_BLOCKER — only when both sides agree no participant has the access/authority to do it; otherwise the participants handle it. +7. Work out loud: the room has no presence signal except your messages — before any local step longer than ~2 minutes post a short "doing X" message, and post the result when done. Never sit silent: either post progress or end your turn. +8. Agree when satisfied; fetch final.md when the room is agreed. Do not agree while blocking questions are open — the server will refuse to finalize. ## Limits @@ -371,16 +429,17 @@ If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus th /** Read-only human view of the negotiation (same token auth as the API). */ export function roomHtmlPage(v: RoomView, token: string): string { const msgs = timelineHtml(v.participants, v.conversation, v.open_questions, v.resolved_questions, v.current_contract); + const roles = v.participants.map((p) => p.role); const openQs = v.open_questions .map( (q) => - `
${q.blocking ? 'BLOCKING' : 'question'} (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}
`, + `
${q.blocking ? 'BLOCKING' : 'question'} (${roleSpan(roles, q.author_role)}${q.addressed_to_role ? ` → ${roleSpan(roles, q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}
`, ) .join(''); const resolvedQs = v.resolved_questions .map( (q) => - `
resolved (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}
→ ${escapeHtml(q.resolution ?? '')}
`, + `
resolved (${roleSpan(roles, q.author_role)}): ${escapeHtml(q.question)}
→ ${escapeHtml(q.resolution ?? '')}
`, ) .join(''); const contract = v.current_contract; @@ -388,7 +447,7 @@ export function roomHtmlPage(v: RoomView, token: string): string { v.room.title, `

${escapeHtml(v.room.title)}

${destroyForm(v.room.id, token, 'r')}

Status: ${escapeHtml(v.room.status)} · Goal: ${escapeHtml(v.room.goal || '(not set)')} · Expires: ${escapeHtml(v.room.expires_at)}

-

Participants: ${v.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are viewing as ${escapeHtml(v.your_role)}.

+

Participants: ${v.participants.map((p) => roleSpan(roles, p.role)).join(', ')} — you are viewing as ${roleSpan(roles, v.your_role)}.

Open questions

${openQs || '

(none)

'} ${resolvedQs ? `

Resolved questions

${resolvedQs}` : ''}

Agreed Contract (v${contract ? contract.version : 0})