/ concept-collection / commonroom-recorder
Sign in
concept-collection / commonroom-recorder
commonroom-recorder / src / recorder.ts
727 lines · 24.2 KBBlameHistoryRaw
1import * as fs from 'node:fs'
2import * as path from 'node:path'
3import wrtc from '@roamhq/wrtc'
4import {selfId} from './identity.js'
5import {LiveTranscriber} from './livetranscribe.js'
6import {Nostr, peerTopic, roomTopic} from './nostr.js'
7import {Peer, type Signal} from './peer.js'
8import {WavWriter} from './wav.js'
10// The recorder's network layer: commonroom's protocol (presence announcements,
11// per-peer signaling topics, deterministic initiator, control data channel)
12// with all the browser UI/media-capture machinery replaced by audio sinks and
13// file writers. It joins a room as an ordinary — visible — participant that
14// reports itself fully muted, receives every other participant's audio, and
15// writes:
16//
17// audio/<name>-<peer8>-segN.wav one file per participant per connection
18// events.jsonl every join/left/chat/mute/segment event
19// chat.txt human-readable chat + join/left log
20// manifest.json session summary: participants + segments
21//
22// All files are written incrementally (manifest every segment boundary and
23// every 30 s), so a crash loses at most ~1 s of audio.
25export const MAX_PARTICIPANTS = 8
27const ANNOUNCE_INTERVAL_MS = 5000
28const PRESENCE_TTL_MS = 15000
29const CONNECT_RETRY_MS = 15000
30const MANIFEST_INTERVAL_MS = 30000
31const INBOX_POLL_MS = 500
32/** Same cap the browser client applies to chat messages. */
33const CHAT_MAX_LENGTH = 2000
35/** Pad with silence when the sink falls this far behind wall clock, so a
36 * file's sample position always tracks elapsed time (within ~1 s). */
37const PAD_THRESHOLD_FRAC = 1.0 // seconds
38const PAD_MARGIN_FRAC = 0.1 // stay this far behind wall clock when padding
40interface Announcement {
41 peerId: string
42 name: string
45type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
47type ControlMsg =
48 | {
49 t: 'hello'
50 name: string
51 audioMuted: boolean
52 videoMuted: boolean
53 joinedAt: number
54 settings: unknown[]
55 }
56 | {t: 'set'; key: string; value: unknown; rev: number; by: string}
57 | {t: 'mute'; audio: boolean; video: boolean}
58 | {t: 'chat'; text: string}
59 | {t: 'bye'}
61interface AudioSinkData {
62 samples: Int16Array
63 sampleRate: number
64 bitsPerSample?: number
65 channelCount?: number
66 numberOfFrames?: number
69interface Segment {
70 file: string
71 peerId: string
72 name: string
73 startedAt: string
74 endedAt: string | null
75 durationSec: number
76 sampleRate: number
77 channels: number
80interface Conn {
81 peer: Peer
82 createdAt: number
83 name: string | null
84 connected: boolean
85 audioMuted: boolean
86 videoMuted: boolean
87 sink: InstanceType<typeof wrtc.nonstandard.RTCAudioSink> | null
88 writer: WavWriter | null
89 /** Wall-clock ms when the current segment's first audio arrived. */
90 segStartMs: number
91 segment: Segment | null
94export interface RecorderOptions {
95 room: string
96 name: string
97 outDir: string
98 /** Chat line sent to each participant when we connect to them (so everyone
99 * in the room sees, once, that recording is happening). null = none. */
100 notice: string | null
101 /** Live transcription (faster-whisper) while recording; null = off. */
102 transcribe: {model: string | null; language: string | null} | null
103 onLog: (line: string) => void
104 /** Unrecoverable situation (e.g. the room is full). */
105 onFatal: (message: string) => void
108const sanitize = (name: string): string => {
109 const s = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
110 return (s || 'peer').slice(0, 24)
113const iso = (ms: number): string => new Date(ms).toISOString()
115const stamp = (ms: number): string => {
116 const d = new Date(ms)
117 const p = (n: number, w = 2) => String(n).padStart(w, '0')
118 return (
119 `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
120 `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
121 )
124export class Recorder {
125 private nostr = new Nostr()
126 private root = ''
127 private presence = new Map<string, {name: string; lastSeen: number}>()
128 private conns = new Map<string, Conn>()
129 private unsubs: (() => void)[] = []
130 private timers: ReturnType<typeof setInterval>[] = []
131 private startedAtMs = 0
132 private stopped = false
134 /** Last known display name per peer (for the manifest). */
135 private names = new Map<string, string>()
136 /** Peers currently counted present (got their hello, not yet left). */
137 private present = new Set<string>()
138 /** Peers we've ever logged join/left for (reconnects get a fresh line). */
139 private seenEver = new Set<string>()
140 /** peerId -> epoch ms until which we won't reconnect: an announcement
141 * published just before a peer's bye can arrive just after it (relay
142 * latency) and would otherwise trigger an instant, pointless reconnect. */
143 private byeCooldown = new Map<string, number>()
144 /** Per-peer segment counter, surviving reconnects. */
145 private segCounts = new Map<string, number>()
146 private segments: Segment[] = []
148 // Outgoing placeholder tracks, shared across all connections (like the
149 // browser's single localStream): a silent mic and a camera that never
150 // produces a frame — the shape of a fully muted participant.
151 private audioSource = new wrtc.nonstandard.RTCAudioSource()
152 private videoSource = new wrtc.nonstandard.RTCVideoSource()
153 private audioTrack = this.audioSource.createTrack()
154 private videoTrack = this.videoSource.createTrack()
156 private audioDir: string
157 private inboxDir: string
158 private eventsPath: string
159 private chatPath: string
160 private manifestPath: string
161 private liveT: LiveTranscriber | null = null
163 constructor(private opts: RecorderOptions) {
164 this.audioDir = path.join(opts.outDir, 'audio')
165 this.inboxDir = path.join(opts.outDir, 'inbox')
166 this.eventsPath = path.join(opts.outDir, 'events.jsonl')
167 this.chatPath = path.join(opts.outDir, 'chat.txt')
168 this.manifestPath = path.join(opts.outDir, 'manifest.json')
169 }
171 async start() {
172 fs.mkdirSync(this.audioDir, {recursive: true})
173 fs.mkdirSync(this.inboxDir, {recursive: true})
174 fs.writeFileSync(
175 path.join(this.opts.outDir, 'AGENT.md'),
176 agentInstructions(this.opts.room, this.opts.name)
177 )
178 this.startedAtMs = Date.now()
179 if (this.opts.transcribe) {
180 this.liveT = new LiveTranscriber({
181 outDir: this.opts.outDir,
182 room: this.opts.room,
183 model: this.opts.transcribe.model,
184 language: this.opts.transcribe.language,
185 startedAtMs: this.startedAtMs,
186 onLog: this.opts.onLog
187 })
188 }
189 this.event({type: 'start', room: this.opts.room, peerId: selfId, name: this.opts.name})
190 this.chatLine(`* recording started (room: ${this.opts.room})`)
191 this.opts.onLog(`joined room "${this.opts.room}" as "${this.opts.name}" (peer ${selfId.slice(0, 8)})`)
192 this.opts.onLog(`writing to ${this.opts.outDir}`)
194 this.root = await roomTopic(this.opts.room)
195 const selfTopic = await peerTopic(this.root, selfId)
197 this.unsubs.push(
198 this.nostr.subscribe(selfTopic, (content, from) => {
199 if (from === selfId || this.stopped) return
200 let msg: PeerMsg
201 try {
202 msg = JSON.parse(content)
203 } catch {
204 return
205 }
206 this.handlePeerMsg(from, msg)
207 })
208 )
210 this.unsubs.push(
211 this.nostr.subscribe(this.root, (content, from) => {
212 if (from === selfId || this.stopped) return
213 let ann: Partial<Announcement>
214 try {
215 ann = JSON.parse(content)
216 } catch {
217 return
218 }
219 if (ann.peerId !== from || typeof ann.name !== 'string') return
220 const annName = ann.name.slice(0, 40)
221 this.presence.set(from, {name: annName, lastSeen: Date.now()})
222 this.names.set(from, annName)
223 this.maybeConnect(from)
224 })
225 )
227 void this.announce()
228 this.timers.push(setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS))
229 this.timers.push(setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS))
230 this.timers.push(setInterval(() => this.writeManifest(), MANIFEST_INTERVAL_MS))
231 this.timers.push(setInterval(() => this.pollInbox(), INBOX_POLL_MS))
232 this.writeManifest()
233 }
235 // ---- inbox ---------------------------------------------------------------
236 //
237 // Anything (a human, an AI agent following the live transcript.md or
238 // events.jsonl) can drop a file into <out>/inbox/ and its content is sent
239 // to the room as a chat message from the recorder, then the file is
240 // deleted. Write atomically (tmp name or dotfile, then rename): files
241 // ending in .tmp or starting with '.' are ignored, and a file is left
242 // alone until its mtime is at least 300 ms old.
244 private pollInbox() {
245 if (this.stopped) return
246 let names: string[]
247 try {
248 names = fs.readdirSync(this.inboxDir)
249 } catch {
250 return
251 }
252 for (const name of names.sort()) {
253 if (name.startsWith('.') || name.endsWith('.tmp')) continue
254 const p = path.join(this.inboxDir, name)
255 try {
256 const st = fs.statSync(p)
257 if (!st.isFile() || Date.now() - st.mtimeMs < 300) continue
258 const text = fs.readFileSync(p, 'utf8').trim().slice(0, CHAT_MAX_LENGTH)
259 fs.unlinkSync(p)
260 if (text) this.sendChat(text)
261 } catch {
262 /* ignore (file may have been removed concurrently) */
263 }
264 }
265 }
267 private broadcastControl(msg: ControlMsg) {
268 const payload = JSON.stringify(msg)
269 for (const conn of this.conns.values()) conn.peer.send(payload)
270 }
272 /** Send a chat message to the room as the recorder, and log it. */
273 private sendChat(text: string) {
274 this.broadcastControl({t: 'chat', text})
275 this.event({type: 'chat', peerId: selfId, name: this.opts.name, text})
276 this.chatLine(`${this.opts.name}: ${text}`)
277 this.opts.onLog(`${this.opts.name}: ${text}`)
278 this.liveT?.onEvent({
279 timeMs: Date.now(),
280 type: 'chat',
281 speaker: this.opts.name,
282 text
283 })
284 }
286 // ---- presence and the mesh ----------------------------------------------
288 private async announce() {
289 if (this.stopped) return
290 const ann: Announcement = {peerId: selfId, name: this.opts.name}
291 void this.nostr.publish(this.root, JSON.stringify(ann))
292 }
294 private sweepPresence() {
295 const cutoff = Date.now() - PRESENCE_TTL_MS
296 for (const [peerId, p] of this.presence) {
297 if (p.lastSeen < cutoff) this.presence.delete(peerId)
298 }
299 }
301 private async sendToPeer(peerId: string, msg: PeerMsg) {
302 const topic = await peerTopic(this.root, peerId)
303 void this.nostr.publish(topic, JSON.stringify(msg))
304 }
306 private atCapacity(): boolean {
307 return this.conns.size >= MAX_PARTICIPANTS - 1
308 }
310 private maybeConnect(peerId: string) {
311 if (this.stopped || peerId === selfId) return
312 const cooldown = this.byeCooldown.get(peerId)
313 if (cooldown !== undefined) {
314 if (Date.now() < cooldown) return
315 this.byeCooldown.delete(peerId)
316 }
317 const existing = this.conns.get(peerId)
318 if (existing) {
319 const stalled =
320 !existing.connected &&
321 Date.now() - existing.createdAt > CONNECT_RETRY_MS
322 if (!stalled) return
323 this.conns.delete(peerId) // deleted first so the close handler no-ops
324 this.closeConn(peerId, existing)
325 }
326 if (this.atCapacity()) {
327 void this.sendToPeer(peerId, {t: 'room-full'})
328 return
329 }
330 this.createPeer(peerId, selfId < peerId)
331 }
333 private createPeer(peerId: string, initiator: boolean): Conn {
334 const peer = new Peer(initiator, this.audioTrack, this.videoTrack)
335 const conn: Conn = {
336 peer,
337 createdAt: Date.now(),
338 name: null,
339 connected: false,
340 audioMuted: true,
341 videoMuted: true,
342 sink: null,
343 writer: null,
344 segStartMs: 0,
345 segment: null
346 }
347 this.conns.set(peerId, conn)
349 peer.setHandlers({
350 signal: signal => {
351 void this.sendToPeer(peerId, {t: 'signal', signal})
352 },
353 track: track => {
354 if (track.kind !== 'audio' || conn.sink) return
355 this.attachSink(peerId, conn, track)
356 },
357 connect: () => {
358 if (conn.connected) return // connectionState can flap during ICE settling
359 conn.connected = true
360 peer.send(
361 JSON.stringify({
362 t: 'hello',
363 name: this.opts.name,
364 audioMuted: true,
365 videoMuted: true,
366 joinedAt: this.startedAtMs,
367 settings: []
368 } satisfies ControlMsg)
369 )
370 if (this.opts.notice) {
371 peer.send(
372 JSON.stringify({t: 'chat', text: this.opts.notice} satisfies ControlMsg)
373 )
374 }
375 },
376 data: raw => this.handleControl(peerId, conn, raw),
377 close: () => {
378 if (this.conns.get(peerId) === conn) {
379 this.conns.delete(peerId)
380 this.closeConn(peerId, conn)
381 if (this.present.delete(peerId)) {
382 const name = this.displayName(peerId, conn)
383 this.event({type: 'left', peerId, name})
384 this.chatLine(`* ${name} left`)
385 this.opts.onLog(`${name} left`)
386 this.liveT?.onEvent({timeMs: Date.now(), type: 'system', text: `${name} left`})
387 }
388 }
389 }
390 })
392 return conn
393 }
395 private handlePeerMsg(from: string, msg: PeerMsg) {
396 switch (msg.t) {
397 case 'signal': {
398 let conn = this.conns.get(from)
399 if (!conn) {
400 // An offer can arrive before we've seen the peer's announcement.
401 if (msg.signal?.type !== 'offer') return
402 if (this.atCapacity()) {
403 void this.sendToPeer(from, {t: 'room-full'})
404 return
405 }
406 conn = this.createPeer(from, false)
407 }
408 void conn.peer.signal(msg.signal)
409 return
410 }
411 case 'room-full': {
412 // Only fatal while we have no foothold — once connected, we're in.
413 if (this.conns.size === 0) {
414 this.opts.onFatal(
415 `The room is full (up to ${MAX_PARTICIPANTS} participants) — nothing recorded.`
416 )
417 }
418 return
419 }
420 }
421 }
423 // ---- control channel ----------------------------------------------------
425 private displayName(peerId: string, conn: Conn | null): string {
426 return (
427 this.presence.get(peerId)?.name ??
428 conn?.name ??
429 this.names.get(peerId) ??
430 peerId.slice(0, 8)
431 )
432 }
434 private handleControl(peerId: string, conn: Conn, raw: string) {
435 if (this.conns.get(peerId) !== conn) return
436 let msg: ControlMsg
437 try {
438 msg = JSON.parse(raw)
439 } catch {
440 return
441 }
442 switch (msg.t) {
443 case 'hello': {
444 if (typeof msg.name === 'string' && msg.name) {
445 conn.name = msg.name.slice(0, 40)
446 this.names.set(peerId, conn.name)
447 }
448 conn.audioMuted = msg.audioMuted !== false
449 conn.videoMuted = msg.videoMuted !== false
450 if (!this.present.has(peerId)) {
451 this.present.add(peerId)
452 const name = this.displayName(peerId, conn)
453 const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0
454 const alreadyHere =
455 joinedAt <= this.startedAtMs && !this.seenEver.has(peerId)
456 this.seenEver.add(peerId)
457 this.event({type: 'join', peerId, name, alreadyHere})
458 this.chatLine(`* ${name} ${alreadyHere ? 'was already here' : 'joined'}`)
459 this.opts.onLog(`${name} ${alreadyHere ? 'was already here' : 'joined'} (mic ${conn.audioMuted ? 'muted' : 'on'})`)
460 this.liveT?.onEvent({
461 timeMs: Date.now(),
462 type: 'system',
463 text: `${name} ${alreadyHere ? 'was already here' : 'joined'}`
464 })
465 }
466 return
467 }
468 case 'mute': {
469 if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
470 return
471 }
472 if (conn.audioMuted !== msg.audio) {
473 this.opts.onLog(
474 `${this.displayName(peerId, conn)} ${msg.audio ? 'muted' : 'unmuted'} their mic`
475 )
476 }
477 conn.audioMuted = msg.audio
478 conn.videoMuted = msg.video
479 this.event({
480 type: 'mute',
481 peerId,
482 name: this.displayName(peerId, conn),
483 audio: msg.audio,
484 video: msg.video
485 })
486 return
487 }
488 case 'chat': {
489 if (typeof msg.text !== 'string') return
490 const text = msg.text.slice(0, 2000)
491 if (!text.trim()) return
492 const name = this.displayName(peerId, conn)
493 this.event({type: 'chat', peerId, name, text})
494 this.chatLine(`${name}: ${text}`)
495 this.opts.onLog(`${name}: ${text}`)
496 this.liveT?.onEvent({timeMs: Date.now(), type: 'chat', speaker: name, text})
497 return
498 }
499 case 'set': // room settings don't matter to the recorder
500 return
501 case 'bye': {
502 this.presence.delete(peerId)
503 this.byeCooldown.set(peerId, Date.now() + 3000)
504 conn.peer.destroy() // its close handler finalizes the segment
505 return
506 }
507 }
508 }
510 // ---- audio capture ------------------------------------------------------
512 private attachSink(peerId: string, conn: Conn, track: MediaStreamTrack) {
513 const sink = new wrtc.nonstandard.RTCAudioSink(track)
514 conn.sink = sink
515 sink.ondata = (data: AudioSinkData) => {
516 if (this.stopped || this.conns.get(peerId) !== conn) return
517 const channels = data.channelCount ?? 1
518 const rate = data.sampleRate
519 if (!rate || !data.samples?.length) return
521 // A decoder format change (rare) starts a fresh segment.
522 if (
523 conn.writer &&
524 (conn.writer.sampleRate !== rate || conn.writer.channels !== channels)
525 ) {
526 this.endSegment(peerId, conn)
527 }
529 const now = Date.now()
530 if (!conn.writer) {
531 // Before the first RTP packet the sink delivers all-zero frames (at a
532 // provisional sample rate, even) — don't open a file until there is
533 // actual audio. A participant who never unmutes produces no file.
534 if (!data.samples.some(s => s !== 0)) return
535 const n = (this.segCounts.get(peerId) ?? 0) + 1
536 this.segCounts.set(peerId, n)
537 const name = this.displayName(peerId, conn)
538 const file = path.join(
539 'audio',
540 `${sanitize(name)}-${peerId.slice(0, 8)}-seg${n}.wav`
541 )
542 conn.writer = new WavWriter(
543 path.join(this.opts.outDir, file),
544 rate,
545 channels
546 )
547 conn.segStartMs = now
548 conn.segment = {
549 file,
550 peerId,
551 name,
552 startedAt: iso(now),
553 endedAt: null,
554 durationSec: 0,
555 sampleRate: rate,
556 channels
557 }
558 this.event({type: 'segment-start', peerId, name, file, sampleRate: rate, channels})
559 this.opts.onLog(`recording ${name} -> ${file}`)
560 this.liveT?.onSegmentStart(
561 file,
562 path.join(this.opts.outDir, file),
563 name,
564 rate,
565 channels,
566 now,
567 conn.writer
568 )
569 } else {
570 // If the sink stalled (network gap, DTX), pad with silence so sample
571 // position keeps tracking wall-clock time.
572 const expected = Math.floor(((now - conn.segStartMs) / 1000) * rate)
573 const deficit = expected - conn.writer.framesWritten
574 if (deficit > rate * PAD_THRESHOLD_FRAC) {
575 conn.writer.appendSilence(deficit - Math.floor(rate * PAD_MARGIN_FRAC))
576 }
577 }
578 conn.writer.append(data.samples)
579 this.liveT?.onAudio(conn.segment!.file, conn.writer.framesWritten, data.samples)
580 }
581 }
583 private endSegment(peerId: string, conn: Conn) {
584 if (!conn.writer || !conn.segment) return
585 conn.writer.finalize()
586 this.liveT?.onSegmentEnd(conn.segment.file)
587 conn.segment.endedAt = iso(Date.now())
588 conn.segment.durationSec = Math.round(conn.writer.durationSec * 100) / 100
589 this.segments.push(conn.segment)
590 this.event({
591 type: 'segment-end',
592 peerId,
593 name: conn.segment.name,
594 file: conn.segment.file,
595 durationSec: conn.segment.durationSec
596 })
597 this.opts.onLog(
598 `closed ${conn.segment.file} (${conn.segment.durationSec.toFixed(1)}s)`
599 )
600 conn.writer = null
601 conn.segment = null
602 this.writeManifest()
603 }
605 /** Tear down a conn's media capture and finalize its segment. */
606 private closeConn(peerId: string, conn: Conn) {
607 try {
608 conn.sink?.stop()
609 } catch {
610 /* ignore */
611 }
612 conn.sink = null
613 this.endSegment(peerId, conn)
614 conn.peer.destroy()
615 }
617 // ---- output files -------------------------------------------------------
619 private event(ev: Record<string, unknown>) {
620 const line = JSON.stringify({time: iso(Date.now()), ...ev})
621 try {
622 fs.appendFileSync(this.eventsPath, line + '\n')
623 } catch {
624 /* ignore */
625 }
626 }
628 private chatLine(text: string) {
629 try {
630 fs.appendFileSync(this.chatPath, `[${stamp(Date.now())}] ${text}\n`)
631 } catch {
632 /* ignore */
633 }
634 }
636 private writeManifest() {
637 const active = [...this.conns.values()]
638 .filter(c => c.segment && c.writer)
639 .map(c => ({
640 ...c.segment!,
641 durationSec: Math.round(c.writer!.durationSec * 100) / 100
642 }))
643 const manifest = {
644 room: this.opts.room,
645 recorder: {peerId: selfId, name: this.opts.name},
646 startedAt: iso(this.startedAtMs),
647 endedAt: this.stopped ? iso(Date.now()) : null,
648 participants: Object.fromEntries(this.names),
649 segments: [...this.segments, ...active]
650 }
651 try {
652 const tmp = this.manifestPath + '.tmp'
653 fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n')
654 fs.renameSync(tmp, this.manifestPath)
655 } catch {
656 /* ignore */
657 }
658 }
660 // ---- shutdown -----------------------------------------------------------
662 async stop(): Promise<{segments: number; participants: number}> {
663 if (this.stopped) return {segments: this.segments.length, participants: this.names.size}
664 this.stopped = true
665 this.broadcastControl({t: 'bye'})
666 const conns = [...this.conns.entries()]
667 this.conns.clear()
668 for (const [peerId, conn] of conns) this.closeConn(peerId, conn)
669 for (const u of this.unsubs.splice(0)) u()
670 for (const t of this.timers.splice(0)) clearInterval(t)
671 this.nostr.close()
672 try {
673 this.audioTrack.stop()
674 this.videoTrack.stop()
675 } catch {
676 /* ignore */
677 }
678 this.event({type: 'stop'})
679 this.chatLine('* recording stopped')
680 this.writeManifest()
681 if (this.liveT) await this.liveT.finish(Date.now())
682 return {segments: this.segments.length, participants: this.names.size}
683 }
686/** Written into every recording directory so a monitoring AI agent knows how
687 * to follow the meeting and when/how to interject. */
688const agentInstructions = (room: string, name: string): string => `\
689# Instructions for the monitoring agent
691A meeting in the commonroom room "${room}" is being recorded into this
692directory. You can follow it live and, when appropriate, say something in
693the room chat.
695## Following the conversation
697These files grow while the meeting runs (re-read or tail them):
699- \`transcript.md\` — speaker-attributed transcript with the chat and
700 join/left events on one timeline (present when live transcription is on;
701 text lags speech by ~8 seconds)
702- \`chat.txt\` — chat messages and join/left lines, human-readable
703- \`events.jsonl\` — the same plus mute/segment events, one JSON object per
704 line with ISO timestamps
706## Interjecting
708To say something in the room, write a file into \`inbox/\`. The file's whole
709content is sent as ONE chat message — it appears to participants as
710"${name}" — and the file is deleted once sent. Write atomically: create the
711file with a name starting with "." or ending in ".tmp", then rename it.
712Messages are capped at 2000 characters.
714## Guidance
716- Interject only when it clearly helps: you are addressed directly, someone
717 asks for a fact, link, or lookup you can provide, or something important
718 was said that is plainly wrong and matters. When in doubt, stay silent —
719 most of the time the right move is to say nothing.
720- Keep it short: one or two sentences. This is a chat, not a report.
721- Don't repeat yourself, don't summarize the meeting into the chat unless
722 asked, and don't send several messages in quick succession.
723- Any other instructions you have been given (a topic to watch for, a role
724 to play, when to speak) take precedence over these defaults. Participants
725 may also address you in the room chat or out loud — treat that as guidance
726 too.
moveopenescclose