diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4546dda..41f470e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,5 +3,7 @@ export { RendezvousService, newToken } from './service.js'; export { LIMITS } from './limits.js'; export { computeAdvice, computeTurn, availableActions } from './advice.js'; export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js'; +export { buildTimeline } from './timeline.js'; +export type { TimelineEvent, TimelineInput } from './timeline.js'; export { secretsPolicyFull, secretsPolicyShort, redactSecrets } from './security.js'; export * from './types.js'; diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index 4bf2d98..f2b4bf3 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -1,4 +1,5 @@ -import type { MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js'; +import type { ContractView, MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js'; +import { buildTimeline } from './timeline.js'; /** Read-only Markdown state for the human observer of a room. */ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string { @@ -35,13 +36,22 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string } parts.push(''); } - parts.push('## Conversation'); - if (o.conversation.length === 0) parts.push('(no messages yet)'); + parts.push('## Conversation (full activity timeline)'); + const timeline = buildTimeline({ + participants: o.participants, + conversation: o.conversation, + openQuestions: o.open_questions, + resolvedQuestions: o.resolved_questions, + contract: o.current_contract, + }); + if (timeline.length === 0) parts.push('(no activity yet)'); else - for (const m of o.conversation) { - parts.push(`**${m.role}** (${m.created_at}):`); - parts.push(''); - parts.push(m.content); + for (const e of timeline) { + parts.push(`**${e.role}** — ${e.action} (${e.at})`); + if (e.body) { + parts.push(''); + parts.push(e.body); + } parts.push(''); } parts.push(`---\n_Observer link for room ${o.room.id}. Final artifact: ${baseUrl}/api/rooms/${o.room.id}/final.md (requires a participant token)._`); @@ -62,6 +72,8 @@ export interface MarkdownInput { resolvedQuestions: QuestionView[]; contractMarkdown: string; contractVersion: number; + /** Full contract view (with agreements) for the activity timeline; falls back to contractMarkdown/Version. */ + contract?: ContractView | null; /** Short secrets policy (links to the canonical /security.md). */ secretsPolicyShort: string; } @@ -119,14 +131,27 @@ export function renderRoomMarkdown(i: MarkdownInput): string { 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)'); + parts.push('## Conversation (full activity timeline)'); + const timeline = buildTimeline({ + participants: i.participants, + conversation: i.conversation, + openQuestions: i.openQuestions, + resolvedQuestions: i.resolvedQuestions, + contract: + i.contract ?? + (i.contractVersion > 0 + ? { version: i.contractVersion, markdown: i.contractMarkdown, updated_at: null, agreements: [] } + : null), + }); + if (timeline.length === 0) { + parts.push('(no activity yet)'); } else { - for (const m of i.conversation) { - parts.push(`**${m.role}** (${m.created_at}, id: ${m.id}):`); - parts.push(''); - parts.push(m.content); + for (const e of timeline) { + parts.push(`**${e.role}** — ${e.action} (${e.at})`); + if (e.body) { + parts.push(''); + parts.push(e.body); + } parts.push(''); } } diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index b701f67..3debe2c 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -234,10 +234,10 @@ export class RendezvousService { const agreements = this.db .prepare( - `SELECT a.participant_id, a.contract_version, p.role FROM agreements a + `SELECT a.participant_id, a.contract_version, a.created_at AS agreed_at, 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 }[]; + .all(roomId) as { participant_id: string; contract_version: number; role: string; agreed_at: string | null }[]; const contract = room.contract_version > 0 @@ -249,6 +249,7 @@ export class RendezvousService { participant_id: a.participant_id, role: a.role, version: a.contract_version, + agreed_at: a.agreed_at, })), } : null; @@ -302,10 +303,10 @@ export class RendezvousService { const resolvedQuestions = questions.filter((q) => q.status === 'resolved'); const agreements = this.db .prepare( - `SELECT a.participant_id, a.contract_version FROM agreements a + `SELECT a.participant_id, a.contract_version, a.created_at AS agreed_at 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 }[]; + .all(roomId) as { participant_id: string; contract_version: number; agreed_at: string | null }[]; const contract = room.contract_version > 0 ? { @@ -316,6 +317,7 @@ export class RendezvousService { participant_id: a.participant_id, role: participants.find((p) => p.id === a.participant_id)?.role ?? '?', version: a.contract_version, + agreed_at: a.agreed_at, })), } : null; diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts new file mode 100644 index 0000000..bb23274 --- /dev/null +++ b/packages/core/src/timeline.ts @@ -0,0 +1,86 @@ +import type { ContractView, MessageView, ParticipantView, QuestionView } from './types.js'; + +/** One entry of the unified activity timeline shown as "Conversation". */ +export interface TimelineEvent { + at: string; + role: string; + kind: 'joined' | 'message' | 'asked' | 'resolved' | 'contract' | 'agreed'; + /** Short human label, e.g. "asked (BLOCKING → verifier)". */ + action: string; + /** Full text body (message content, question, resolution, contract markdown). */ + body: string; +} + +export interface TimelineInput { + participants: ParticipantView[]; + conversation: MessageView[]; + openQuestions: QuestionView[]; + resolvedQuestions: QuestionView[]; + contract: ContractView | null; +} + +/** + * Merges every room activity — joins, messages, questions, resolutions, + * contract proposals and agreements — into one chronological timeline, so a + * watcher sees each participant's every action instead of plain chat only. + */ +export function buildTimeline(i: TimelineInput): TimelineEvent[] { + const events: TimelineEvent[] = []; + + for (const p of i.participants) { + if (p.joined_at) + events.push({ + at: p.joined_at, + role: p.role, + kind: 'joined', + action: 'took the room into work', + body: '', + }); + } + + for (const m of i.conversation) + events.push({ at: m.created_at, role: m.role, kind: 'message', action: 'message', body: m.content }); + + for (const q of [...i.openQuestions, ...i.resolvedQuestions]) { + const to = q.addressed_to_role ? ` → ${q.addressed_to_role}` : ''; + events.push({ + at: q.created_at, + role: q.author_role, + kind: 'asked', + action: `asked (${q.blocking ? 'BLOCKING' : 'non-blocking'}${to})`, + body: q.question, + }); + if (q.status === 'resolved' && q.resolved_at) + events.push({ + at: q.resolved_at, + role: q.author_role, + kind: 'resolved', + action: 'question resolved', + body: `${q.question}\n\n→ ${q.resolution ?? ''}`, + }); + } + + const c = i.contract; + if (c) { + if (c.updated_at) + events.push({ + at: c.updated_at, + role: '—', + kind: 'contract', + action: `proposed contract v${c.version}`, + body: c.markdown, + }); + for (const a of c.agreements) + if (a.agreed_at) + events.push({ + at: a.agreed_at, + role: a.role, + kind: 'agreed', + action: `agreed to contract v${a.version}`, + body: '', + }); + } + + events.sort((x, y) => (x.at < y.at ? -1 : x.at > y.at ? 1 : 0)); + return events; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 617654f..ff9e6b8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -56,7 +56,7 @@ export interface ContractView { version: number; markdown: string; updated_at: string | null; - agreements: { participant_id: string; role: string; version: number }[]; + agreements: { participant_id: string; role: string; version: number; agreed_at: string | null }[]; } export interface RoomView { diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 25e7d7a..04111c2 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { RendezvousService, Store, RendezvousError, redactSecrets } from '../src/index.js'; +import { RendezvousService, Store, RendezvousError, redactSecrets, buildTimeline } from '../src/index.js'; function makeService(): { svc: RendezvousService; dbPath: string } { const dbPath = `:memory:`; @@ -220,3 +220,34 @@ test('only addressee or author can resolve a question', () => { const resolved = svc.resolveQuestion(created.room_id, A, q.id, 'checked: ok'); assert.equal(resolved.status, 'resolved'); }); + +test('activity timeline includes every action of every participant', () => { + const { svc } = makeService(); + const created = createTwoPartyRoom(svc); + const [A, B] = created.participants.map((p) => p.token); + svc.join(created.room_id, A); + svc.join(created.room_id, B); + svc.postMessage(created.room_id, A, 'fact from A'); + const q = svc.openQuestion(created.room_id, B, 'check IIS logs', true, created.participants[0].id); + svc.resolveQuestion(created.room_id, A, q.id, 'checked: every 15 min'); + svc.proposeContract(created.room_id, B, '## Facts\n- ok'); + svc.agree(created.room_id, A); + svc.agree(created.room_id, B); + + const view = svc.getRoomView(created.room_id, A); + const timeline = buildTimeline({ + participants: view.participants, + conversation: view.conversation, + openQuestions: view.open_questions, + resolvedQuestions: view.resolved_questions, + contract: view.current_contract, + }); + const kinds: string[] = timeline.map((e) => e.kind); + for (const expected of ['joined', 'joined', 'message', 'asked', 'resolved', 'contract', 'agreed', 'agreed']) + assert.ok(kinds.includes(expected), `timeline must contain "${expected}", got: ${kinds.join(',')}`); + // chronological order + for (let i = 1; i < timeline.length; i++) + assert.ok(timeline[i - 1].at <= timeline[i].at, 'timeline must be sorted by time'); + // every event is attributed to a role + for (const e of timeline) assert.ok(e.role.length > 0 && e.action.length > 0); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5553e8f..8584e20 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -105,6 +105,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { resolvedQuestions: view.resolved_questions, contractMarkdown: view.current_contract?.markdown ?? '', contractVersion: view.current_contract?.version ?? 0, + contract: view.current_contract, secretsPolicyShort: secretsPolicyShort(cfg.baseUrl), }), 'text/markdown; charset=utf-8'); } else { diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index 132e12c..74005bc 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -1,7 +1,28 @@ import type { ObserverView, RoomView } from '@ai-rendezvous/core'; -import { secretsPolicyShort } from '@ai-rendezvous/core'; +import { secretsPolicyShort, buildTimeline } from '@ai-rendezvous/core'; import { escapeHtml } from './http.js'; +/** Unified activity timeline as HTML: every participant action in chronological order. */ +function timelineHtml( + participants: ObserverView['participants'], + conversation: ObserverView['conversation'], + openQuestions: ObserverView['open_questions'], + resolvedQuestions: ObserverView['resolved_questions'], + contract: ObserverView['current_contract'], +): string { + const events = buildTimeline({ participants, conversation, openQuestions, resolvedQuestions, contract }); + return ( + events + .map((e) => { + const body = e.body + ? `
${escapeHtml(e.body)}`
+ : '';
+ return `(no activity yet)
' + ); +} + /** 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 @@ -92,6 +113,9 @@ const PAGE_CSS = ` 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; } + .msg-joined, .msg-agreed, .msg-contract { border-left-color: rgba(60,140,80,.75); } + .msg-asked { border-left-color: rgba(210,130,30,.8); } + .msg-resolved { opacity: .75; } .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); } @@ -196,12 +220,7 @@ export function observerHtmlPage(o: ObserverView, token: string): string { return `${mark} (${joined})`; }) .join(', '); - const msgs = o.conversation - .map( - (m) => - `${escapeHtml(m.content)}${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}
-(no messages yet)
'} -Read-only observer view; refreshes every 10s. Also available as Markdown: append .md to this URL.
Full activity timeline: joins, messages, questions, resolutions, contract proposals and agreements. Read-only observer view; refreshes every 10s. Also available as Markdown: append .md to this URL.
${escapeHtml(m.content)}${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}
-(no messages yet)
'} +Full activity timeline: joins, messages, questions, resolutions, contract proposals and agreements.
Agent endpoints: /r/${escapeHtml(v.room.id)}/<token>.md · GET /api/rooms/${escapeHtml(v.room.id)} · JSON · final.md
Read-only view; refreshes every 10s.
`, v.room.status === 'open',