heyquilt 0.3.141 → 0.3.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,9 +12,10 @@
12
12
  // connects there with a subscription. Each keeps its own inbox state (what the webhook has told),
13
13
  // separate from quilt_inbox's, so a POSTed event still shows there when the agent asks.
14
14
  import { scanInbox } from './inbox.js'
15
+ import { callEntries, liveCallOf } from './voice.js'
15
16
  import { hostedIn, newHosted } from './hosted-agents.js'
16
17
  import { readTasks } from './tasks.js'
17
- import { deliverWebhook, makeSubscription, webhookPayload, publicWebhookHost, WEBHOOK_EVENTS } from './webhooks.js'
18
+ import { deliverWebhook, makeSubscription, webhookPayload, publicWebhookHost, takesEvent, WEBHOOK_EVENTS } from './webhooks.js'
18
19
  import { withContext } from './conversation.js'
19
20
 
20
21
  // A subscription with `coalesce` turns a burst into one POST: events are gathered for SETTLE_MS,
@@ -31,7 +32,13 @@ const inContext = (room, me, events) => {
31
32
  return withContext(events, all.filter((m) => m && m.id && (!m.to || m.to === me || m.by === me)), { names: [me], agent: true })
32
33
  }
33
34
 
34
- const scan = (doc, name, state) => scanInbox({ messages: chatFor(doc, name), tasks: readTasks(doc.getMap('tasks')), reader: { name, asAi: false, agent: true } }, state)
35
+ const scan = (doc, name, state, room = null) => scanInbox({
36
+ messages: chatFor(doc, name),
37
+ tasks: readTasks(doc.getMap('tasks')),
38
+ // The call going on, which every agent of the session is in while it is transcribed.
39
+ voice: room && room.awareness ? callEntries(doc, { names: [name], call: liveCallOf(room.awareness), members: room.memberList ? room.memberList() : [] }) : [],
40
+ reader: { name, asAi: false, agent: true }
41
+ }, state)
35
42
 
36
43
  /** The payload for a burst (oldest first): the first event exactly as one POST carries it, with every one of them in `events`. */
37
44
  export function batchPayload (payloads) {
@@ -106,14 +113,14 @@ export function hostedWebhooks ({ hosted, saveHosted, log = () => {}, fetch, del
106
113
  const name = (!at.hosted && (room.meta.members[account]?.name || r.name)) || sub.name
107
114
  // A session the subscription has not taken stock of yet (joined while the room was not loaded,
108
115
  // or an entry from before this relay): what is already there is not news.
109
- if (r.webhookState === undefined) { r.webhookState = scan(room.doc, name, null).state; changed = true; continue }
110
- const got = scan(room.doc, name, r.webhookState)
116
+ if (r.webhookState === undefined) { r.webhookState = scan(room.doc, name, null, room).state; changed = true; continue }
117
+ const got = scan(room.doc, name, r.webhookState, room)
111
118
  r.webhookState = got.state
112
119
  changed = true
113
120
  if (!got.events.length) continue
114
121
  const payloads = inContext(room, name, got.events)
115
122
  .map((e) => webhookPayload(e, { room: room.name, to: name, workspace: room.meta.workspace || '' }))
116
- .filter((p) => sub.events.includes(p.event))
123
+ .filter((p) => takesEvent(sub.events, p.event))
117
124
  if (!payloads.length) continue
118
125
  if (sub.coalesce) queue(account, room.name, payloads)
119
126
  else for (const p of payloads) post(account, sub, p)
@@ -128,6 +135,9 @@ export function hostedWebhooks ({ hosted, saveHosted, log = () => {}, fetch, del
128
135
  const run = () => { try { check(room) } catch (err) { log(`[${room.name}] webhooks: ${err.message}`) } }
129
136
  room.doc.getArray('chat').observe(run)
130
137
  room.doc.getMap('tasks').observeDeep(run)
138
+ // What is said in a call to an agent in it.
139
+ room.doc.getArray('voiceLines').observe(run)
140
+ room.doc.getMap('voiceAgents').observe(run)
131
141
  }
132
142
 
133
143
  const restock = (h) => {
package/src/server.js CHANGED
@@ -4804,9 +4804,11 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4804
4804
  if (!publicKey) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4805
4805
  const key = parsePublicKey(publicKey)
4806
4806
  if (!person || person.length > MAX_NAME || !key) return reject(socket, 400, 'Bad name or identity key')
4807
- // Every session keeps its files in branch documents: an app that can't sync them is told to update.
4807
+ // An app that can't sync branch documents or squares is told to update. Apps from R3 on (0.3.143) say
4808
+ // `quilts` and no longer `branches` (Seam's plans/quilt-source-control/16 flag 9); every app with
4809
+ // `quilts` syncs branch documents too, so either one is enough.
4808
4810
  const features = String(url.searchParams.get('features') || '').split(',')
4809
- if (!features.includes('branches')) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4811
+ if (!features.includes('branches') && !features.includes('quilts')) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4810
4812
  if (roomEnded(name)) return reject(socket, 410, ENDED_MESSAGE)
4811
4813
  const ip = clientIp(req)
4812
4814
  const starter = pass ? account : ip
@@ -380,7 +380,9 @@ export class SquareFollower {
380
380
  log: (m) => { if (s.log) s.log(`🧵 ${m}`) },
381
381
  started: (b) => { here.starting = b },
382
382
  // Who changed what goes on as on the branch: this folder's edits on record as its member's.
383
- onEdits: s.squareEdits ? (list, doc, held) => { if (this.here === here) s.squareEdits(list, doc, held) } : null
383
+ onEdits: s.squareEdits ? (list, doc, held) => { if (this.here === here) s.squareEdits(list, doc, held) } : null,
384
+ // A file the square wrote to disk: the app redraws its Files list and an open file (as for a change its watcher heard).
385
+ onWrite: (p) => { if (this.here === here && s.emit) s.emit('file-changed', { path: p }) }
384
386
  })
385
387
  if (this.here !== here) { await block.close().catch(() => {}); return }
386
388
  if (empty) s.joinedEmpty = false
package/src/session.js CHANGED
@@ -57,7 +57,7 @@ import { RELAY_NAME as RELAY_BY, isRelay } from './ui/chat.js'
57
57
  import { cleanTyping, TYPING_MS, AGENT_TYPING_MS } from './ui/chat.js'
58
58
  import { DEFAULT_KEY } from './branchdocs.js'
59
59
  import { boxEntry } from './boxes.js'
60
- import { cleanVoice, newCallId, voiceView, inVoice, transcribeSetting, setTranscribe, addLine, readLines, MAX_IN_CALL, CALL_FULL } from './voice.js'
60
+ import { cleanVoice, newCallId, voiceView, inVoice, transcribeSetting, setTranscribe, addLine, readLines, MAX_IN_CALL, CALL_FULL, callAgents, setAgentLeft, notInCall, callEntries, agentSays } from './voice.js'
61
61
  import { toolInUse } from './agents/common.js'
62
62
  import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
63
63
 
@@ -885,6 +885,12 @@ export class Session extends EventEmitter {
885
885
  this.scheduleStatusWrite()
886
886
  })
887
887
  this.activity.observe(() => this.scheduleStatusWrite())
888
+ // An agent in a call is woken by what is said to it there.
889
+ if (this.kind === 'agent') {
890
+ const heard = (ev, tr) => this.scanInbox({ quiet: tr.origin === LOCAL, personas: false })
891
+ this.doc.getArray('voiceLines').observe(heard)
892
+ this.doc.getMap('voiceAgents').observe(heard)
893
+ }
888
894
  this.tasks.observe((ev, tr) => {
889
895
  this.scanInbox({ quiet: tr.origin === LOCAL })
890
896
  this.scheduleStatusWrite()
@@ -3137,6 +3143,35 @@ export class Session extends EventEmitter {
3137
3143
  return readLines(this.doc, opts)
3138
3144
  }
3139
3145
 
3146
+ /** The call going on now (someone is in it): its id, or ''. */
3147
+ liveCall () {
3148
+ const v = this.voice()
3149
+ return v.in || v.peers.length ? v.call : ''
3150
+ }
3151
+
3152
+ /**
3153
+ * An agent leaves the call going on (`leave`), or comes back to it. While transcripts are on every
3154
+ * agent of the session is in it, muted until someone says its name.
3155
+ */
3156
+ voiceJoin ({ leave = false } = {}) {
3157
+ if (this.kind !== 'agent') throw new Error(`In this session you are ${this.name}: people join calls in the Quilt app. The session's agents (and "${this.name}'s AI") are in every transcribed call.`)
3158
+ const call = this.liveCall()
3159
+ if (!call) throw new Error('There is no call going on in this session.')
3160
+ setAgentLeft(this.doc, { call, name: this.name, leave })
3161
+ if (leave) return { in: false, call }
3162
+ const why = notInCall(this.doc, { call, name: this.name, members: this.members })
3163
+ if (why) throw new Error(why)
3164
+ const v = this.voice()
3165
+ return { in: true, call, me: this.name, people: v.peers.map((p) => p.name), agents: v.agents.map((a) => a.name) }
3166
+ }
3167
+
3168
+ /** What an agent in the call says out loud: a line every app in the call speaks in its voice. */
3169
+ voiceSay (text) {
3170
+ if (this.kind !== 'agent') throw new Error('Only agents speak in a call through Quilt.')
3171
+ if (!this.mayTalk()) throw new Error(TALK_REFUSED)
3172
+ return agentSays(this.doc, { call: this.liveCall(), name: this.name, text, members: this.members })
3173
+ }
3174
+
3140
3175
  // --------------------------------------------------------------- typing --
3141
3176
 
3142
3177
  /**
@@ -3470,6 +3505,8 @@ export class Session extends EventEmitter {
3470
3505
  events = this.inboxTracker.scan({
3471
3506
  messages,
3472
3507
  tasks,
3508
+ // An agent in the session's call: what is said to it there.
3509
+ voice: this.kind === 'agent' && this.conn ? callEntries(this.doc, { names: [this.name], call: this.liveCall(), members: this.members }) : [],
3473
3510
  reader: this.inboxReader()
3474
3511
  }, { quiet: quiet || !this.ready })
3475
3512
  } catch (err) {
@@ -5420,8 +5457,8 @@ export class Session extends EventEmitter {
5420
5457
  work: this.work,
5421
5458
  ...(myVoice && myVoice.me ? { voice: { muted: myVoice.muted, ...(myVoice.transcribing ? { transcribing: true } : {}), ...(myVoice.sharing ? { sharing: true } : {}) } } : {})
5422
5459
  },
5423
- // The session's call: whether the owner has transcripts on.
5424
- voice: { transcribe: transcribeSetting(this.doc, this.members).on },
5460
+ // The session's call: whether the owner has transcripts on, and the agents in it.
5461
+ voice: { transcribe: transcribeSetting(this.doc, this.members).on, agents: callAgents(this.doc, this.conn ? this.liveCall() : '', this.members) },
5425
5462
  peers,
5426
5463
  claims: [...this.claims.values()].sort((a, b) => a.ts - b.ts),
5427
5464
  tasks: this.taskList(),
@@ -68,13 +68,15 @@ export class LiveBlock {
68
68
  * since it last synced it; `skip(path)`: the session never syncs it (`skipDir(path)`: nor
69
69
  * anything in that folder); `record`: where block.json
70
70
  * goes; `reader`: a client for reads (square, tree, blobs, diff), else `client`; `log`;
71
+ * `onWrite(path)`: the block wrote (or deleted) that file on disk from the square, so whoever shows the
72
+ * folder hears of it without waiting on a file watcher (macOS's FSEvents drops events under load).
71
73
  * `onEdits(list, doc, held)`: the edits this folder made, as each scan takes them into the live
72
74
  * document `doc` ([{ path, kind: created|edited|deleted, detail, before?, after? }]), so the session
73
75
  * keeps them on record (who changed what) as it did on its branch; `held()` resolves once the relay
74
76
  * holds them.
75
77
  */
76
- constructor ({ dir, client, quilt, square, paths = [''], relay, api = '', inPlace = null, skip = null, skipDir = null, record = null, reader = null, log = null, onEdits = null, started = null, stepMs = STEP_MS }) {
77
- Object.assign(this, { dir: path.resolve(dir), client, quilt, squareRef: square, paths, relayOpts: relay, api, inPlace, skip, skipDir, record, reader: reader || client, logFn: log, onEdits, stepMs })
78
+ constructor ({ dir, client, quilt, square, paths = [''], relay, api = '', inPlace = null, skip = null, skipDir = null, record = null, reader = null, log = null, onEdits = null, onWrite = null, started = null, stepMs = STEP_MS }) {
79
+ Object.assign(this, { dir: path.resolve(dir), client, quilt, squareRef: square, paths, relayOpts: relay, api, inPlace, skip, skipDir, record, reader: reader || client, logFn: log, onEdits, onWrite, stepMs })
78
80
  // `started(block)`: told as it starts to open, so whoever waits for it can stop it part-way (abandon).
79
81
  if (started) started(this)
80
82
  this.known = {} // path -> { blob, mode, size, mtimeMs }: what is on disk as this block last read or wrote it
@@ -564,7 +566,7 @@ export class LiveBlock {
564
566
 
565
567
  write (p, want) {
566
568
  const abs = path.join(this.dir, ...p.split('/'))
567
- if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; this.lastText.delete(p); return }
569
+ if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; this.lastText.delete(p); this.wrote(p); return }
568
570
  fs.mkdirSync(path.dirname(abs), { recursive: true })
569
571
  const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.quilt-tmp`)
570
572
  if (want.mode === 'symlink') { fs.rmSync(abs, { force: true }); fs.symlinkSync(want.bytes.toString('utf8'), abs) } else {
@@ -576,8 +578,11 @@ export class LiveBlock {
576
578
  if (this.doc && liveMaps(this.doc).files.has(p) && want.bytes.length <= MAX_LIVE_TEXT) this.lastText.set(p, { id: this.known[p].blob, text: want.bytes.toString('utf8') })
577
579
  else this.lastText.delete(p)
578
580
  this.saveSoon()
581
+ this.wrote(p)
579
582
  }
580
583
 
584
+ wrote (p) { if (this.onWrite) { try { this.onWrite(p) } catch {} } }
585
+
581
586
  /** Moves the block to the document's head (a commit by anyone in the square), then writes the overlay out. */
582
587
  async pull () {
583
588
  if (this.closed) return
package/src/status.js CHANGED
@@ -46,7 +46,7 @@ export function renderStatus (st, { asAi = false, mentionYours = false } = {}) {
46
46
  }
47
47
  out.push('')
48
48
  const inCall = [...(st.me.voice ? [{ name: st.me.name, ...st.me.voice }] : []), ...st.peers.filter((p) => p.voice).map((p) => ({ name: p.name, ...p.voice }))]
49
- const call = voiceStatusLines(inCall, !!(st.voice && st.voice.transcribe))
49
+ const call = voiceStatusLines(inCall, !!(st.voice && st.voice.transcribe), (st.voice && st.voice.agents) || [], st.me.kind === 'agent' ? st.me.name : '')
50
50
  if (call.length) out.push(...call, '')
51
51
  if (st.agents && st.agents.length) {
52
52
  out.push('## Agents and how Quilt wakes them')
@@ -62,6 +62,7 @@ export function agentGuide ({ apiUrl = 'https://api.heyquilt.com', via = 'both'
62
62
  `- Read: \`quilt_read_messages\`${cli ? ` (CLI: \`quilt messages\`, or \`--all\` for history). With the CLI, every quilt answer also starts with anything new for you` : ''}.`,
63
63
  `- What wakes you (a direct message, a mention, a task) comes with the conversation before it: what you and its sender said to each other, directly or in the room, oldest first, and the message a reply answers (in \`quilt_inbox\`, in the webhook's "context", in a channel event, in your tool's hooks). "That", "it", "again" and "retry" mean the latest thing there. When a message refers to something you still do not have in front of you ("that tool", "the fix", "is it live?"), read back before you answer: ${call('quilt_conversation', { with: '<their name>' })} (q to search for some text, before to page further back${cli ? '; CLI: `quilt messages --with <name>`, `--grep <text>`, `--before <id>`' : ''}). Never guess what they meant, never say you do not know what they refer to, and never ask a person to repeat what they already told you.`,
64
64
  `- People also talk in voice calls in the Quilt app. \`quilt_status\` says who is in a call and whether it is transcribed. When it is, read what was said before acting on it: ${call('quilt_voice_transcript', {})}. When it is not, ask in chat what was decided.`,
65
+ `- While a call is transcribed, every agent of the session is in it, like a person but muted. When someone says your name you are unmuted and woken with what they said: answer out loud with ${call('quilt_voice_say', { text: '...' })}, in one to three short spoken sentences (lists, code and links go in chat). What is said in the next minute and a half wakes you too; then you mute again. Stay quiet when a line is not for you. ${call('quilt_voice_join', { leave: true })} leaves the call.`,
65
66
  '- A person and their AI share the person\'s name. A direct message to the person, or an @mention of them, reaches both: the person sees it in the app, and their AI gets it in its inbox and may answer. To give their AI work, assign a task to the person with to_ai: true. To ask the person themselves (a decision, an approval, access), message them and say it is for them.',
66
67
  '- Other agents are reached the same way, by their own name: an @mention or a direct message wakes them (through their inbox, their webhook or their next tool call). They answer you like they answer a person.',
67
68
  '- When to talk: when you start something, when you need something from someone, when you are blocked, and when you finish. Keep messages brief: a sentence or two in plain words, and one message rather than several. No play-by-play, recaps, logs or pasted code.',
package/src/ui/app.css CHANGED
@@ -478,6 +478,9 @@ textarea.input { height: auto; padding: 10px 12px; resize: vertical; }
478
478
  .call-face.talk { box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--ok); }
479
479
  .call-muted { position: absolute; right: -4px; bottom: -3px; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%; background: var(--panel); color: var(--bad); }
480
480
  .call-muted svg { width: 9px; height: 9px; }
481
+ /* An agent in the call: the agent mark on its face, top right (its mute mark stays bottom right). */
482
+ .call-ai { position: absolute; right: -4px; top: -4px; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%; background: var(--violet); color: #fff; box-shadow: 0 0 0 1.5px var(--panel); }
483
+ .call-ai svg { width: 8px; height: 8px; }
481
484
  .call-tx { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: 500 11.5px var(--sans); cursor: pointer; white-space: nowrap; }
482
485
  .call-tx:hover, .call-tx[aria-expanded="true"] { background: var(--panel-2); }
483
486
  .call-tx .d { width: 6px; height: 6px; border-radius: 50%; background: var(--faint); }
@@ -17,12 +17,22 @@ const quiltName = (st) => (st.quilt && (st.quilt.slug || st.quilt.id)) || 'the q
17
17
  /** The square this folder follows, as Seam names it (`sq:` and the end of its id), or ''. */
18
18
  export const squareName = (st) => (st && st.square && st.square.short ? `sq:${st.square.short}` : '')
19
19
 
20
+ /** The branch whose square this folder follows (`st.squares` is branch -> square name), or ''. */
21
+ export function squareBranch (st) {
22
+ const name = squareName(st)
23
+ const hit = name && Object.entries(st.squares || {}).find(([, sq]) => sq === name)
24
+ return hit ? hit[0] : (name && st.branch) || ''
25
+ }
26
+
27
+ /** The square as a person reads it: its branch (a square's id means nothing to anyone), or its id when no branch is known. */
28
+ const squareWords = (st) => squareBranch(st) || squareName(st)
29
+
20
30
  /**
21
31
  * The source control button at layout 3: { label, title, aria } for the square this folder follows, or
22
32
  * why it follows none yet. `label` '' leaves the button with its icon and count only.
23
33
  */
24
34
  export function seamChip (st) {
25
- const name = squareName(st)
35
+ const name = squareWords(st)
26
36
  const s = st.square
27
37
  const label = name
28
38
  const title = st.moving ? movingCard(st).text
@@ -30,13 +40,13 @@ export function seamChip (st) {
30
40
  : st.readOnly && !(s && !s.opening && !s.problem)
31
41
  ? `Your edits here are not reaching anyone: ${st.readOnly.why}`
32
42
  : !s ? `This session's code is committed and merged in Seam (${quiltName(st)}), and this folder is not following a square yet.`
33
- : s.problem ? `This folder's work belongs in square ${name}, but it can't follow it yet: ${s.problem}`
34
- : s.opening ? `This folder is starting to follow square ${name}.`
35
- : [`This folder's work is live in square ${name} of ${quiltName(st)}. Click to commit it or merge it onto main.`, ...catchUpWords(s)].join(' ')
36
- // The number is the square's work not committed yet (everyone's on it): what a commit takes.
43
+ : s.problem ? `This folder's work belongs on ${name}, but it can't follow it yet: ${s.problem}`
44
+ : s.opening ? `This folder is starting to follow ${name}.`
45
+ : [`This folder's work is live on ${name} in ${quiltName(st)}, with everyone else's there. Click to commit it or merge it onto main.`, ...catchUpWords(s)].join(' ')
46
+ // The number is the square's work not committed yet (everyone's on it, not only yours): what a commit takes.
37
47
  const count = s && s.work ? s.work.count : 0
38
- const countLine = count ? `${files(count)} not committed in this square.` : ''
39
- return { label, title, aria: `Source control: ${name ? `square ${name}` : 'Seam'}${count ? `, ${files(count)} not committed` : ''}`, count, countLine }
48
+ const countLine = count ? `${files(count)} changed on ${name} and not committed yet, by everyone on it.` : ''
49
+ return { label, title, aria: `Source control: ${name || 'Seam'}${count ? `, ${files(count)} not committed` : ''}`, count, countLine }
40
50
  }
41
51
 
42
52
  const files = (n) => `${n} file${n === 1 ? '' : 's'}`
@@ -98,10 +108,10 @@ export function squareCard (st) {
98
108
  */
99
109
  export function squarePanelHtml (st, { busy = '' } = {}) {
100
110
  const s = st.square
101
- const name = squareName(st)
111
+ const name = squareWords(st)
102
112
  const following = s && !s.opening && !s.problem
103
113
  const viewer = !!(st.access && st.access.role === 'viewer')
104
- const head = `<div class="br-here sq-here"><div class="br-title">${name ? `Square <b>${esc(name)}</b> of ${esc(quiltName(st))}` : `Code committed and merged in Seam: <b>${esc(quiltName(st))}</b>`}</div>`
114
+ const head = `<div class="br-here sq-here"><div class="br-title"${squareName(st) ? ` title="Seam's square ${esc(squareName(st))}"` : ''}>${name ? `Live work on <b>${esc(name)}</b> in ${esc(quiltName(st))}` : `Code committed and merged in Seam: <b>${esc(quiltName(st))}</b>`}</div>`
105
115
  // While the session moves, nothing is committed or merged from here: the move is what it waits for.
106
116
  if (st.moving) return `${head}<div class="br-sub warn">${esc(movingCard(st).text)}</div></div>`
107
117
  if (st.squareless) {
@@ -112,13 +122,13 @@ export function squarePanelHtml (st, { busy = '' } = {}) {
112
122
  const why = st.readOnly ? st.readOnly.why : s ? s.line : 'This folder is not following a square yet: Quilt opens it by itself once the session says which.'
113
123
  return `${head}<div class="br-sub${st.readOnly || (s && s.problem) ? ' warn' : ''}">${esc(why)}</div></div>`
114
124
  }
115
- const sub = '<div class="br-sub">Every edit here is live for everyone on this square. Commit it to keep a version; merge it to put it on main (GitHub gets main from Seam by itself).</div>'
125
+ const sub = `<div class="br-sub">Everyone on ${esc(name)} edits this together, live: the files below are everyone's edits, not only yours. Commit them to keep a version; merge them to put them on main (GitHub gets main from Seam by itself).</div>`
116
126
  const caught = catchUpWords(s)
117
127
  const work = (caught.length ? `<div class="br-sub${s.conflicted ? ' warn' : ''}">${esc(caught.join(' '))}</div>` : '') + workHtml(s.work)
118
128
  if (viewer) return `${head}${sub}${work}</div>`
119
129
  return `${head}${sub}${work}
120
130
  <form class="cp-commit" data-square-commit><input class="input" name="message" placeholder="What this commit is" aria-label="Commit message" maxlength="2000" required autocomplete="off"${busy ? ' disabled' : ''}><button type="submit" class="btn sm primary"${busy ? ' disabled' : ''}>${busy === 'commit' ? 'Committing…' : 'Commit'}</button></form>
121
- <div class="sq-acts"><button type="button" class="btn sm" data-square-merge${busy ? ' disabled' : ''} title="Commits what is not committed yet, then merges ${esc(name)} onto main once its checks pass">${busy === 'merge' ? 'Merging…' : 'Merge onto main'}</button></div>
131
+ <div class="sq-acts"><button type="button" class="btn sm" data-square-merge${busy ? ' disabled' : ''} title="Commits what is not committed yet, then merges it onto main once its checks pass">${busy === 'merge' ? 'Merging…' : 'Merge onto main'}</button></div>
122
132
  </div>`
123
133
  }
124
134
 
@@ -201,7 +211,7 @@ export function squaresSideHtml (st) {
201
211
  add(mine, 'you')
202
212
  for (const p of st.peers || []) if (p && p.name && p.branch && squares[p.branch] && !(p.persona && p.of === (st.me && st.me.name))) add(squares[p.branch], p.mine ? 'your AI' : p.name)
203
213
  const n = st.square && st.square.work ? st.square.work.count : 0
204
- const rows = [...groups].map(([sq, who]) => `<li class="sq-side-row${sq === mine ? ' on' : ''}"><div><b>${esc(sq)}</b>${branchOf[sq] ? ` <span class="hint">${esc(branchOf[sq])}</span>` : ''}</div><div class="hint">${esc(who.join(', '))}${sq === mine ? ` · ${n ? `${files(n)} not committed` : 'all committed'}` : ''}</div></li>`).join('')
214
+ const rows = [...groups].map(([sq, who]) => `<li class="sq-side-row${sq === mine ? ' on' : ''}"><div title="Seam's square ${esc(sq)}"><b>${esc(branchOf[sq] || sq)}</b></div><div class="hint">${esc(who.join(', '))}${sq === mine ? ` · ${n ? `${files(n)} not committed` : 'all committed'}` : ''}</div></li>`).join('')
205
215
  // The pane's own title says Squares already (session.js, as it says Boxes outside Seam): no second heading.
206
216
  return rows ? `<ul class="sq-side">${rows}</ul>` : ''
207
217
  }
@@ -33,6 +33,42 @@ export class VoiceCall {
33
33
  this.view = null
34
34
  this.sending = null
35
35
  this.again = false
36
+ this.agentTalking = null // the agent whose line is playing now
37
+ this.spoken = Promise.resolve() // agents' lines, played one after another
38
+ }
39
+
40
+ /**
41
+ * An agent's line (a WAV, base64), played after any before it. While it plays our microphone
42
+ * sends nothing to the transcript, so the agent's voice from our speakers is never written down
43
+ * as ours (and never wakes the agent again).
44
+ */
45
+ play (name, wav) {
46
+ this.spoken = this.spoken.then(() => this.playNow(name, wav)).catch((err) => console.warn('agent voice', err.message))
47
+ return this.spoken
48
+ }
49
+
50
+ async playNow (name, wav) {
51
+ if (!this.in || !this.ctx) return
52
+ const bytes = Uint8Array.from(atob(wav), (c) => c.charCodeAt(0))
53
+ const audio = await this.ctx.decodeAudioData(bytes.buffer)
54
+ if (!this.in) return
55
+ const src = this.ctx.createBufferSource()
56
+ src.buffer = audio
57
+ src.connect(this.ctx.destination)
58
+ // What we were saying goes to the transcript now, before the agent speaks.
59
+ this.segmenter?.end()
60
+ this.agentTalking = name
61
+ this.onChange()
62
+ await new Promise((resolve) => {
63
+ this.stopAgent = () => { try { src.stop() } catch {} resolve() }
64
+ src.onended = resolve
65
+ src.start()
66
+ })
67
+ // The room's echo dies down before the microphone counts again.
68
+ await new Promise((resolve) => setTimeout(resolve, 300))
69
+ this.stopAgent = null
70
+ this.agentTalking = null
71
+ this.onChange()
36
72
  }
37
73
 
38
74
  /** Joins: the microphone opens (the browser or system may ask first), then everyone already in is called. */
@@ -61,6 +97,7 @@ export class VoiceCall {
61
97
  this.in = false
62
98
  clearInterval(this.timer)
63
99
  clearInterval(this.retry)
100
+ this.stopAgent?.()
64
101
  this.stopShare()
65
102
  this.segmenter?.end()
66
103
  for (const id of [...this.links.keys()]) this.drop(id)
@@ -266,7 +303,7 @@ export class VoiceCall {
266
303
  this.clip({ audio: toBase64(wavBytes(pcm, 16000)), seconds: pcm.length / 16000 }).catch((err) => console.warn('voice clip', err.message))
267
304
  }
268
305
  })
269
- this.worklet.port.onmessage = (e) => { if (this.transcribing && !this.muted) this.segmenter.push(e.data) }
306
+ this.worklet.port.onmessage = (e) => { if (this.transcribing && !this.muted && !this.agentTalking) this.segmenter.push(e.data) }
270
307
  this.ctx.createMediaStreamSource(this.stream).connect(this.worklet)
271
308
  // A node nobody listens to may never run: it goes on to the speakers, silenced.
272
309
  const hush = this.ctx.createGain()
package/src/ui/voice.js CHANGED
@@ -11,7 +11,7 @@ import { screenShown, shareRefused } from './voice-model.js'
11
11
  import { playSound } from './sounds.js'
12
12
 
13
13
  const views = new Map() // session id -> the call as that session's server sees it
14
- const EMPTY = { peers: [], transcribe: false, recent: [], calls: [], since: 0, call: '' }
14
+ const EMPTY = { peers: [], transcribe: false, recent: [], calls: [], since: 0, call: '', agents: [] }
15
15
  const FULL = 6 // voice.js MAX_IN_CALL: everyone connects to everyone, so a call holds six
16
16
  let live = null // { sid, call }: the call this window is in (one at a time)
17
17
  let shown = null // { id, who, chatChanged }: the session on screen
@@ -31,6 +31,7 @@ let selfClosed = false // our own screen's preview closed, until we stop sharing
31
31
  let selfVideo = null // our own screen's preview: one element, like screenVideo
32
32
  let drawn = '' // what the pinned call was last drawn from: redrawn only when that changes, so a click is never lost mid-press
33
33
  let callsDrawn = ''
34
+ let heard = { call: '', since: 0, ids: new Set() } // agents' lines this window has played (or let pass) in the call it is in
34
35
 
35
36
  const NO_SCREEN = "Quilt can't see your screen. Allow it for Quilt in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen Quilt."
36
37
  const NO_MIC = "Quilt can't use your microphone. Allow it for Quilt in System Settings → Privacy & Security → Microphone, then try again."
@@ -91,7 +92,7 @@ export function voiceEvent (id, view) {
91
92
  if (view.in && !(live && live.sid === id)) { api('POST', `/api/sessions/${id}/voice`, { in: false }).then((v) => voiceEvent(id, v), () => {}); return }
92
93
  }
93
94
  // The call we're in: it plays the join and leave sounds itself (onPeople), so a relay blip plays neither.
94
- if (live && live.sid === id) live.call.update(view)
95
+ if (live && live.sid === id) { live.call.update(view); speakAgents(id, view) }
95
96
  if (shown && shown.id === id) {
96
97
  renderCall()
97
98
  // A call ended: its card joins the chat. The call going on now changes every few seconds and
@@ -128,6 +129,7 @@ async function join (id, transcribing) {
128
129
  onPeople: (sound) => playSound(sound, { force: true })
129
130
  })
130
131
  live = { sid: id, call }
132
+ heard = { call: '', since: Date.now(), ids: new Set() }
131
133
  await call.join({ transcribing })
132
134
  playSound('callIn', { force: true })
133
135
  } catch (err) {
@@ -229,14 +231,47 @@ async function onClick (e) {
229
231
  }
230
232
  }
231
233
 
232
- // A face in the call; `key` ('me' or a presence id) lets its speaking ring change without redrawing anything.
234
+ /**
235
+ * An agent's lines in the call we're in, said since we joined: each spoken in its voice (quilt-api,
236
+ * Fluxions), one after another. Without voices on the server the line still shows in the call.
237
+ */
238
+ function speakAgents (id, view) {
239
+ if (!live || live.sid !== id || !live.call.in) return
240
+ if (heard.call !== view.call) heard = { call: view.call, since: heard.since, ids: new Set() }
241
+ for (const l of view.recent) {
242
+ if (!l.spoken || heard.ids.has(l.id)) continue
243
+ heard.ids.add(l.id)
244
+ if (l.ts < heard.since - 2000) continue
245
+ const call = live.call
246
+ api('GET', `/api/sessions/${id}/voice/speech?line=${encodeURIComponent(l.id)}`).then((r) => {
247
+ if (live && live.call === call && r && r.audio) call.play(l.by, r.audio)
248
+ }, (err) => {
249
+ // Once a call: voices not set up, or the month's share heard.
250
+ if (!heard.told && (err.status === 503 || err.status === 429 || err.status === 404)) { heard.told = true; toast(err.status === 404 ? "Agents' voices aren't on Quilt's servers yet: their lines show in the call." : err.message) }
251
+ })
252
+ }
253
+ }
254
+
255
+ // A face in the call; `key` ('me', a presence id, or 'agent:<name>') lets its speaking ring change without redrawing anything.
233
256
  const face = (key, name, color, muted) => `<span class="call-face" data-face="${key}" title="${esc(name)}${muted ? ' (muted)' : ''}">${avatar(name, color)}${muted ? `<span class="call-muted">${I.micOff}</span>` : ''}</span>`
257
+ // An agent in the call: like a person, with the agent mark, muted until someone says its name; its ring lights while its line plays.
258
+ const agentFace = (a, color, now) => {
259
+ const muted = !(a.until > now)
260
+ return `<span class="call-face agent" data-face="agent:${esc(a.name)}" title="${esc(a.name)}, an agent: ${muted ? 'muted until someone says its name' : 'listening'}">${avatar(a.name, color)}<span class="call-ai" aria-hidden="true">${I.bot}</span>${muted ? `<span class="call-muted">${I.micOff}</span>` : ''}</span>`
261
+ }
262
+ // An agent unmuted by its name mutes again on its own: draw the call again when that happens.
263
+ let unmuteTimer = null
264
+ function redrawWhenMuted (agents, now) {
265
+ clearTimeout(unmuteTimer)
266
+ const next = Math.min(...agents.map((a) => a.until).filter((u) => u > now))
267
+ if (Number.isFinite(next)) unmuteTimer = setTimeout(renderCall, next - now + 50)
268
+ }
234
269
  const minutes = (from, to = Date.now()) => { const m = Math.round((to - from) / 60000); return m < 1 ? 'under a minute' : `${m} min` }
235
270
  const names = (list) => list.length <= 2 ? list.join(' and ') : `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}`
236
271
 
237
272
  /** One line of what someone said. */
238
273
  function lineHtml (l, colorOf) {
239
- return `<div class="call-line">${avatar(l.by, colorOf(l.by))}<div class="call-said"><div class="call-whosaid"><b>${esc(l.by)}</b><span>${esc(clock(l.ts))}</span></div><div>${esc(l.text)}</div></div></div>`
274
+ return `<div class="call-line">${avatar(l.by, colorOf(l.by))}<div class="call-said"><div class="call-whosaid"><b>${esc(l.by)}</b>${l.spoken ? '<span class="ai-tag">AI</span>' : ''}<span>${esc(clock(l.ts))}</span></div><div>${esc(l.text)}</div></div></div>`
240
275
  }
241
276
 
242
277
  /** Rings on whoever speaks now, without redrawing (it changes many times a second). */
@@ -244,7 +279,7 @@ function paintTalk () {
244
279
  if (!live || !shown || live.sid !== shown.id) return
245
280
  for (const el of document.querySelectorAll('#call-pin [data-face]')) {
246
281
  const k = el.dataset.face
247
- el.classList.toggle('talk', live.call.speaking(k === 'me' ? 'me' : Number(k)))
282
+ el.classList.toggle('talk', k.startsWith('agent:') ? live.call.agentTalking === k.slice(6) : live.call.speaking(k === 'me' ? 'me' : Number(k)))
248
283
  }
249
284
  }
250
285
 
@@ -274,7 +309,11 @@ export function renderCall () {
274
309
  // Closing the preview lasts until we stop sharing; the next share shows it again.
275
310
  if (!c?.sharing) selfClosed = false
276
311
  renderSelf(c)
277
- const key = JSON.stringify([id, here, joining, asking, options, owner, elsewhere, view.transcribe, others.map((p) => [p.id, p.name, p.color, p.muted, p.transcribing, p.sharing]), me.name, me.color, c && [c.muted, c.transcribing, c.sharing], selfClosed, showing, minutes(view.since || Date.now())])
312
+ const now = Date.now()
313
+ const agents = view.agents || []
314
+ redrawWhenMuted(agents, now)
315
+ const agentFaces = agents.map((a) => agentFace(a, colorOf(a.name), now)).join('')
316
+ const key = JSON.stringify([id, here, joining, asking, options, owner, elsewhere, view.transcribe, others.map((p) => [p.id, p.name, p.color, p.muted, p.transcribing, p.sharing]), agents.map((a) => [a.name, a.until > now]), me.name, me.color, c && [c.muted, c.transcribing, c.sharing], selfClosed, showing, minutes(view.since || Date.now())])
278
317
  if (key === drawn && (pin.hidden || pin.childElementCount)) { paintLines(pin, lines); paintTalk(); return }
279
318
  drawn = key
280
319
  linesDrawn = null
@@ -287,7 +326,7 @@ export function renderCall () {
287
326
  <p class="cp-note">This call is transcribed: what you say is written down for everyone in the session, agents included. Audio is never kept.</p>
288
327
  <div class="cp-two"><button type="button" class="btn sm primary" data-call-join="transcribed">${others.length ? 'Join' : 'Start'}, transcribed</button><button type="button" class="btn sm" data-call-join="plain">${others.length ? 'Join' : 'Start'}, not transcribed</button></div>`
289
328
  } else if (!here) {
290
- pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><span class="call-faces">${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>
329
+ pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><span class="call-faces">${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}${agentFaces}</span>
291
330
  <span class="cp-what"><b>${esc(names(others.map((p) => p.name)))} ${others.length === 1 ? 'is' : 'are'} in a call</b><span class="call-sub">${[dur, view.transcribe ? 'transcribed' : '', ...others.filter((p) => p.sharing).map((p) => `${esc(p.name)} sharing a screen`)].filter(Boolean).join(' · ')}</span></span>
292
331
  <button type="button" class="btn sm primary cp-end" data-call-join="ask" ${elsewhere ? 'disabled title="Leave your other call first"' : others.length >= FULL ? 'disabled title="This call is full: calls hold six people for now."' : ''}>Join</button></div>`
293
332
  } else {
@@ -305,7 +344,7 @@ export function renderCall () {
305
344
  <button type="button" class="call-ic cp-end" data-call-share aria-pressed="${c.sharing}" title="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}" aria-label="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}">${c.sharing ? I.screenOff : I.screenShare}</button>
306
345
  <button type="button" class="call-ic" data-call-mute aria-pressed="${c.muted}" title="${c.muted ? 'Unmute' : 'Mute'}" aria-label="${c.muted ? 'Unmute' : 'Mute'}">${c.muted ? I.micOff : I.mic}</button>
307
346
  <button type="button" class="call-ic bad" data-call-leave title="Leave the call" aria-label="Leave the call">${I.hangUp}</button></div>
308
- <div class="cp-row"><span class="call-faces">${face('me', me.name, me.color, c.muted)}${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>${tag}</div>
347
+ <div class="cp-row"><span class="call-faces">${face('me', me.name, me.color, c.muted)}${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}${agentFaces}</span>${tag}</div>
309
348
  ${options ? `<div class="cp-opts">${myChoice}${ownerChoice}</div>` : ''}
310
349
  ${c.sharing ? `<div class="cp-share">${I.screenShare}<span class="cp-says">Sharing your screen</span>${selfClosed ? '<button type="button" class="btn sm ghost" data-self-share="open" title="Show what you share, in the corner">Show</button>' : ''}<button type="button" class="btn sm" data-call-share>Stop sharing</button></div>` : ''}
311
350
  ${others.filter((p) => p.sharing).map((p) => `<div class="cp-share">${I.screenShare}<span class="cp-says">${esc(p.name)} is sharing a screen</span>${p.id === showing ? '<span class="call-sub">in the middle</span>' : `<button type="button" class="btn sm" data-screen-show="${p.id}">Show</button>`}</div>`).join('')}
package/src/ui-server.js CHANGED
@@ -20,7 +20,7 @@ import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
20
20
  import { migrateDir } from './legacy.js'
21
21
  import { writePrivateJson } from './private-file.js'
22
22
  import { autoToolFor } from './autowork-tools.js'
23
- import { readAccount, saveAccount, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, agentDetails, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, listSandboxKeys, createSandboxKey, rotateSandboxKey, revokeSandboxKey, removeSandboxKey, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect, voiceIce, transcribeVoice, apiUrl } from './account.js'
23
+ import { readAccount, saveAccount, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, agentDetails, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, listSandboxKeys, createSandboxKey, rotateSandboxKey, revokeSandboxKey, removeSandboxKey, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect, voiceIce, transcribeVoice, speakVoice, apiUrl } from './account.js'
24
24
  import { aiName } from './persona.js'
25
25
  import { effectiveAccess, builtinType } from './session-access.js'
26
26
  import { cleanSessionName, BAD_SESSION_NAME, SESSION_NAME_MAX } from './session-name.js'
@@ -248,7 +248,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
248
248
  // below — it returns earlier — so it needs no entry here.)
249
249
  const UNRECORDED = new Set(['POST /api/report'])
250
250
  // Sent many times a call (call setup, each clip of speech): only kept when slow or failed.
251
- const QUIET_WHEN_OK = new Set(['POST /api/sessions/:id/voice', 'POST /api/sessions/:id/voice/clip'])
251
+ const QUIET_WHEN_OK = new Set(['POST /api/sessions/:id/voice', 'POST /api/sessions/:id/voice/clip', 'GET /api/sessions/:id/voice/speech'])
252
252
  function recordRoute (key, { startedAt, status, body, error }) {
253
253
  // The reporter's own `enabled()` is the single gate on the "report" setting.
254
254
  if (UNRECORDED.has(key)) return
@@ -517,6 +517,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
517
517
  s.conn.awareness.on('change', pushVoice)
518
518
  s.doc.getMap('voiceSettings').observe(pushVoice)
519
519
  s.doc.getArray('voiceLines').observe(pushVoice)
520
+ s.doc.getMap('voiceAgents').observe(pushVoice)
520
521
  // The person's cloud AI, when they connected one, joins the sessions they join.
521
522
  followWithCloudAi(entry.run.invite, log)
522
523
  if (workspace && mode === 'create') {
@@ -694,6 +695,23 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
694
695
  return { line, used: out.used, cap: out.cap }
695
696
  }
696
697
 
698
+ // An agent's line in the call we're in, as speech from quilt-api (Fluxions): asked once per line,
699
+ // kept a little while so the window asking again (a reload) costs nothing.
700
+ const spoken = new Map() // line id -> Promise<{ audio }>
701
+ async function voiceSpeech (s, lineId) {
702
+ const v = s.voice()
703
+ if (!v.in) throw httpError(409, 'You are not in the call.')
704
+ const line = s.voiceTranscript({ call: v.call, limit: 1000 }).lines.find((l) => l.id === lineId)
705
+ if (!line || !line.spoken || !v.agents.some((a) => a.name === line.by)) throw httpError(404, 'That is not an agent\'s line in this call.')
706
+ if (!spoken.has(line.id)) {
707
+ const got = asAccount((token) => speakVoice({ token, text: line.text, by: line.by })).then((r) => ({ audio: r.audio, voice: r.voice }))
708
+ spoken.set(line.id, got)
709
+ got.catch(() => spoken.delete(line.id))
710
+ if (spoken.size > 50) spoken.delete(spoken.keys().next().value)
711
+ }
712
+ return spoken.get(line.id)
713
+ }
714
+
697
715
  // Agent invites and the list of your agents come from the accounts API, as this computer's account.
698
716
  // A 401 there is checked against /v1/me before it counts: only a token the API no longer knows
699
717
  // signs the app out (an older API that doesn't take the app's token for these yet just errors).
@@ -1033,6 +1051,8 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1033
1051
  'POST /api/sessions/:id/voice/clip': (b, id) => voiceClip(get(id), b),
1034
1052
  'GET /api/sessions/:id/voice/transcript': (b, id, url) => get(id).voiceTranscript({ call: url.searchParams.get('call') || '' }),
1035
1053
  'GET /api/voice/ice': () => voiceServers(),
1054
+ // An agent's line in the call, as speech.
1055
+ 'GET /api/sessions/:id/voice/speech': (b, id, url) => voiceSpeech(get(id), url.searchParams.get('line') || ''),
1036
1056
  // The composer says while someone types (and stops when they clear it or send).
1037
1057
  'POST /api/sessions/:id/typing': (b, id) => get(id).setTyping(b.on !== false, { to: b.to || null }),
1038
1058
  'POST /api/sessions/:id/claim': (b, id) => get(id).claim(b.pattern, b.note),