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.
package/src/voice.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // The audio (and a shared screen) goes from app window to app window, never through the relay or this
4
4
  // process. What people say, when the owner turned transcripts on and they agreed, is kept as
5
5
  // lines in the session's document for everyone, agents included, to read.
6
+ // Agents join a call too: someone in it adds them (or one joins when asked), they hear it through
7
+ // those lines, and what they say back is a line each app in the call speaks aloud (Fluxions, on quilt-api).
6
8
  import crypto from 'node:crypto'
7
9
 
8
10
  // One offer or answer with its gathered addresses is a few kilobytes; anything far bigger isn't one.
@@ -75,12 +77,15 @@ export function setTranscribe (doc, { on, by, now = Date.now() }) {
75
77
  doc.getMap('voiceSettings').set('transcribe', { on: !!on, by, ts: now })
76
78
  }
77
79
 
78
- /** One person's line of speech in a call's transcript. */
79
- export function addLine (doc, { call, by, text, now = Date.now() }) {
80
+ /**
81
+ * One person's line of speech in a call's transcript, or an agent's (`spoken`: each app in the
82
+ * call plays it aloud in the agent's voice).
83
+ */
84
+ export function addLine (doc, { call, by, text, spoken = false, now = Date.now() }) {
80
85
  const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_LINE)
81
86
  if (!t) return null
82
87
  if (typeof call !== 'string' || !CALL.test(call)) throw new Error('That is not a call.')
83
- const line = { id: crypto.randomBytes(8).toString('hex'), call, by, text: t, ts: now }
88
+ const line = { id: crypto.randomBytes(8).toString('hex'), call, by, text: t, ts: now, ...(spoken ? { spoken: true } : {}) }
84
89
  const lines = doc.getArray('voiceLines')
85
90
  doc.transact(() => {
86
91
  lines.push([line])
@@ -110,6 +115,112 @@ export function readLines (doc, { call = '', since = 0, limit = 200 } = {}) {
110
115
  return { lines: picked.slice(-n), more: picked.length > n, calls: [...calls.values()].sort((a, b) => a.start - b.start) }
111
116
  }
112
117
 
118
+ // ------------------------------------------------------------ agents in a call --
119
+ // Every agent in the session is in a transcribed call, like a person, but muted: saying its name
120
+ // unmutes it. It hears the call through the transcript, and while unmuted what is said wakes it
121
+ // and it answers out loud; it mutes again once the conversation with it has gone quiet.
122
+
123
+ // After an agent speaks, or is named, it stays unmuted this long: a conversation going on.
124
+ export const AGENT_FOLLOW_MS = 90 * 1000
125
+ // Lines of the call before one that wakes an agent, shown with it.
126
+ export const AGENT_CONTEXT = 8
127
+ export const AGENT_NEEDS_TRANSCRIPTS = 'Agents hear a call through its transcript, and this one is not transcribed: the session owner turns transcripts on.'
128
+
129
+ /** Whether agent `name` left call `call` (quilt_voice_join leave). */
130
+ const leftCall = (doc, call, name) => {
131
+ const v = doc.getMap('voiceAgents').get(name)
132
+ return !!(v && typeof v === 'object' && v.call === call && v.left === true)
133
+ }
134
+
135
+ /** Lines of call `call`, oldest first. */
136
+ const callLines = (doc, call) => doc.getArray('voiceLines').toArray().filter((l) => l && l.call === call && typeof l.text === 'string')
137
+
138
+ /** Until when an agent (`names`) is unmuted, from a call's lines (0: muted): AGENT_FOLLOW_MS after the latest line it said or that named it. */
139
+ export function agentOpenUntil (lines, names) {
140
+ let until = 0
141
+ for (const l of lines) {
142
+ if (names.includes(l.by) || names.some((n) => saysName(l.text, n))) until = Math.max(until, (l.ts || 0) + AGENT_FOLLOW_MS)
143
+ }
144
+ return until
145
+ }
146
+
147
+ /**
148
+ * The agents in call `call` (the one going on): every agent member of the session while the call
149
+ * is transcribed, but those that left it. Each with `until`: unmuted until then (0: muted).
150
+ */
151
+ export function callAgents (doc, call, members = []) {
152
+ if (!call || !transcribeSetting(doc, members).on) return []
153
+ const lines = callLines(doc, call)
154
+ return (members || []).filter((m) => m && m.kind === 'agent' && m.name && !leftCall(doc, call, m.name))
155
+ .map((m) => ({ name: m.name, until: agentOpenUntil(lines, [m.name]) }))
156
+ .sort((a, b) => a.name.localeCompare(b.name))
157
+ }
158
+
159
+ /** Agent `name` leaves call `call` (`leave`), or comes back to it. */
160
+ export function setAgentLeft (doc, { call, name, leave = true, now = Date.now() }) {
161
+ if (leave) doc.getMap('voiceAgents').set(name, { call, left: true, ts: now })
162
+ else if (doc.getMap('voiceAgents').has(name)) doc.getMap('voiceAgents').delete(name)
163
+ }
164
+
165
+ /** Why agent `name` isn't in call `call` now, or '' when it is. */
166
+ export function notInCall (doc, { call, name, members = [] }) {
167
+ if (!call) return 'There is no call going on in this session.'
168
+ if (!transcribeSetting(doc, members).on) return AGENT_NEEDS_TRANSCRIPTS
169
+ if (!(members || []).some((m) => m && m.kind === 'agent' && m.name === name)) return 'Only the session\'s agents are in its calls.'
170
+ if (leftCall(doc, call, name)) return 'You left this call: come back with quilt_voice_join.'
171
+ return ''
172
+ }
173
+
174
+ // A name as spoken words, loosely: "Daniel's AI" is "daniels ai".
175
+ const words = (s) => String(s || '').toLowerCase().replace(/['’]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
176
+
177
+ /** Whether a line said out loud names `name` (its words in a row, or run together, any case or punctuation): "Moss, can you…", "GrokBot" for Grok-Bot. */
178
+ export function saysName (text, name) {
179
+ const n = words(name)
180
+ if (!n) return false
181
+ const t = ` ${words(text)} `
182
+ return t.includes(` ${n} `) || t.includes(` ${n.replace(/ /g, '')} `)
183
+ }
184
+
185
+ /**
186
+ * What reaches agent `names[0]` from call `call` (the one going on), as inbox entries (inbox.js
187
+ * scanInbox `voice`): each line by someone else that names it, or comes while it is unmuted
188
+ * (AGENT_FOLLOW_MS after it spoke or was named). Each line carries the lines just before it.
189
+ */
190
+ export function callEntries (doc, { names, call, members = [] }) {
191
+ const me = names && names[0]
192
+ if (!me || notInCall(doc, { call, name: me, members })) return []
193
+ const lines = callLines(doc, call)
194
+ const out = []
195
+ let open = 0 // until when what is said is for us
196
+ lines.forEach((l, i) => {
197
+ if (names.includes(l.by)) { open = l.ts + AGENT_FOLLOW_MS; return }
198
+ const named = names.some((n) => saysName(l.text, n))
199
+ if (named) open = l.ts + AGENT_FOLLOW_MS
200
+ else if (l.ts > open) return
201
+ out.push({ id: `v-${l.id}`, kind: 'voice', by: l.by, text: l.text, ts: l.ts, call, ...(named ? { named: true } : {}), said: lines.slice(Math.max(0, i - AGENT_CONTEXT), i).map((x) => ({ by: x.by, text: x.text, ts: x.ts })) })
202
+ })
203
+ return out
204
+ }
205
+
206
+ /** The call going on in a session (someone is in it), from presence: its id, or ''. */
207
+ export function liveCallOf (awareness) {
208
+ for (const s of awareness.getStates().values()) {
209
+ const v = s && s.name ? cleanVoice(s.voice) : null
210
+ if (v && v.call) return v.call
211
+ }
212
+ return ''
213
+ }
214
+
215
+ /** Agent `name` says `text` out loud in call `call` (the one going on): its line, which every app in the call speaks. */
216
+ export function agentSays (doc, { call, name, text, members = [] }) {
217
+ const why = notInCall(doc, { call, name, members })
218
+ if (why) throw new Error(why)
219
+ const line = addLine(doc, { call, by: name, text, spoken: true })
220
+ if (!line) throw new Error('Say something.')
221
+ return line
222
+ }
223
+
113
224
  /** Transcript lines as plain text for an agent: one per line, with who and when. */
114
225
  export function renderLines ({ lines, more, calls }, { tz } = {}) {
115
226
  if (!lines.length) return calls.length ? 'No transcript lines match.' : 'Nothing has been transcribed in this session yet.'
@@ -150,6 +261,8 @@ export function voiceView ({ awareness, clientID, members = [], doc }) {
150
261
  // When the call began: its first person in. The chat shows it, and the calls before it.
151
262
  const starts = [...(me ? [me.since] : []), ...peers.map((p) => p.since)].filter((x) => x > 0)
152
263
  const said = doc ? readLines(doc, { limit: 1 }) : { calls: [] }
264
+ // A call is going on while anyone is in it; its agents leave with the last person.
265
+ const going = !!(me || peers.length)
153
266
  return {
154
267
  me: clientID,
155
268
  in: !!me,
@@ -163,7 +276,9 @@ export function voiceView ({ awareness, clientID, members = [], doc }) {
163
276
  // The latest lines of the call going on now, as each is written down.
164
277
  recent: doc && call ? readLines(doc, { call, limit: RECENT }).lines : [],
165
278
  // Every call that has a transcript: when, who spoke, how many lines.
166
- calls: said.calls
279
+ calls: said.calls,
280
+ // The agents in the call going on now, and the session's agents who could join it.
281
+ agents: doc && going ? callAgents(doc, call, members) : []
167
282
  }
168
283
  }
169
284
 
@@ -177,15 +292,24 @@ export function inVoice (awareness, clientID) {
177
292
  return out
178
293
  }
179
294
 
295
+ export const JOIN_DESCRIPTION = 'Leave the session\'s voice call, or come back to it. While the owner has transcripts on, you are in every call, like a person but muted: ' +
296
+ 'when someone says your name you are unmuted, woken with what they said, and answer out loud with quilt_voice_say; you mute again a minute and a half after the conversation with you goes quiet. ' +
297
+ 'leave: true to stop hearing the call until it ends.'
298
+
299
+ export const SAY_DESCRIPTION = 'Say something out loud in the voice call: everyone in it hears it in your voice, and it goes in the transcript. ' +
300
+ 'Talk the way people do in a call: one to three short sentences, no lists, no code, no markdown, no links (put those in chat with quilt_message). ' +
301
+ 'Answer what was said to you; stay quiet when a line was not for you. [laugh], [sigh] and [hesitate] are spoken as sounds.'
302
+
180
303
  export const TRANSCRIPT_DESCRIPTION = 'Read what people said in the session\'s voice calls: the transcript lines, oldest first, with who said what and when. ' +
181
304
  'People talk in calls in the Quilt app; while the session owner has transcripts on, each person who agreed has their own speech written here. ' +
182
305
  'quilt_status says who is in a call now. Read the latest call before acting on something decided out loud, and ask in chat when a line is unclear.'
183
306
 
184
- /** Who is in a call, for an agent's status: a heading and a line each, or nothing when nobody is. */
185
- export function voiceStatusLines (people, transcribe) {
307
+ /** Who is in a call, for an agent's status: a heading and a line each, or nothing when nobody is. `agents`: the agents in it ({ name }). */
308
+ export function voiceStatusLines (people, transcribe, agents = [], me = '') {
186
309
  if (!people.length) return []
187
310
  return [
188
311
  `## In a voice call${transcribe ? ' (transcribed: quilt_voice_transcript reads it)' : ' (not transcribed: ask in chat what was decided)'}`,
189
- ...people.map((p) => `- ${p.name}${p.muted ? ' (muted)' : ''}${p.sharing ? ' (sharing their screen)' : ''}${transcribe && !p.transcribing ? ' (not transcribed)' : ''}`)
312
+ ...people.map((p) => `- ${p.name}${p.muted ? ' (muted)' : ''}${p.sharing ? ' (sharing their screen)' : ''}${transcribe && !p.transcribing ? ' (not transcribed)' : ''}`),
313
+ ...agents.map((a) => `- ${a.name === me ? 'You' : a.name} (an agent, ${a.until > Date.now() ? 'unmuted' : 'muted until someone says its name'}${a.name === me ? '; answer out loud with quilt_voice_say' : ''})`)
190
314
  ]
191
315
  }
package/src/webhooks.js CHANGED
@@ -16,8 +16,12 @@ import crypto from 'node:crypto'
16
16
  import dns from 'node:dns'
17
17
  import net from 'node:net'
18
18
 
19
- export const WEBHOOK_EVENTS = ['chat.mention', 'chat.dm', 'task.assigned', 'email.received']
20
- export const EVENT_OF_KIND = { mention: 'chat.mention', dm: 'chat.dm', task: 'task.assigned', email: 'email.received' }
19
+ export const WEBHOOK_EVENTS = ['chat.mention', 'chat.dm', 'task.assigned', 'email.received', 'voice.said']
20
+ export const EVENT_OF_KIND = { mention: 'chat.mention', dm: 'chat.dm', task: 'task.assigned', email: 'email.received', voice: 'voice.said' }
21
+
22
+ /** Whether a subscription takes `event`. One made before voice.said that took every event then takes it too. */
23
+ export const takesEvent = (events, event) => events.includes(event) ||
24
+ (event === 'voice.said' && ['chat.mention', 'chat.dm', 'task.assigned', 'email.received'].every((e) => events.includes(e)))
21
25
  export const MAX_URL = 2000
22
26
  export const MAX_SECRET = 200
23
27
  export const MIN_SECRET = 16
@@ -141,7 +145,7 @@ export function describeSubscription (sub, { showSecret = false } = {}) {
141
145
  if (!sub) return 'No webhook: Quilt only answers quilt_inbox when you ask.'
142
146
  const lines = [`Webhook: Quilt POSTs to ${sub.url} on ${sub.events.join(', ')}${sub.bearer ? ', with your bearer key in the Authorization header' : ''}${sub.coalesce ? ', a burst in one POST (its events in `events`)' : ''}${sub.local ? ' (sent from this computer: the relay cannot reach that URL)' : ''}.`]
143
147
  if (showSecret) lines.push(`Secret (shown once; check x-quilt-signature with it): ${sub.secret}`)
144
- lines.push('Each POST is JSON ({ event, id, session, workspace, room, to, by, text, ts, task?, mail?, context? }; session: the session it is from, which you name as `session` in the tools you answer with, so an answer never lands in another session; workspace: { id } or null; mail: { id } of an email to the session, read with quilt_mail; context: the messages before this one between you and its sender, oldest first) with x-quilt-event, x-quilt-delivery, x-quilt-timestamp and ' +
148
+ lines.push('Each POST is JSON ({ event, id, session, workspace, room, to, by, text, ts, task?, mail?, call?, context? }; session: the session it is from, which you name as `session` in the tools you answer with, so an answer never lands in another session; workspace: { id } or null; mail: { id } of an email to the session, read with quilt_mail; call: { id, named?, before? } for voice.said, a line said to you in the session voice call (named: it says your name), answered out loud with quilt_voice_say; context: the messages before this one between you and its sender, oldest first) with x-quilt-event, x-quilt-delivery, x-quilt-timestamp and ' +
145
149
  'x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"). Answer 2xx quickly; a failed POST is retried a few times. ' +
146
150
  'What you are told is still in quilt_inbox. quilt_webhook_test POSTs a webhook.test event now; stop with quilt_webhook_unsubscribe.')
147
151
  return lines.join('\n')
@@ -157,6 +161,11 @@ export function webhookPayload (e, { room = '', to = '', workspace = '' } = {})
157
161
  if (e.kind === 'email' && e.mail) p.mail = { id: String(e.mail) } // read it with quilt_mail (read: id)
158
162
  if (e.commit) p.commit = true // work this member asked to commit was committed: nothing to answer
159
163
  if (e.nudge) p.nudge = true // a reminder from Quilt (uncommitted work): nothing to answer
164
+ // Said to the agent in the session's voice call; answered with quilt_voice_say.
165
+ if (e.kind === 'voice') {
166
+ p.call = { id: String(e.call || ''), ...(e.named ? { named: true } : {}) }
167
+ if (Array.isArray(e.said) && e.said.length) p.call.before = e.said.map((l) => ({ by: l.by, text: l.text, ts: l.ts }))
168
+ }
160
169
  // The conversation before this with whoever it is from (conversation.js), so an agent woken
161
170
  // fresh knows what "that" refers to; `earlier` older messages are a quilt_conversation away.
162
171
  if (Array.isArray(e.context) && e.context.length) {