heyquilt 0.3.142 → 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/RELEASES.md +10 -0
- package/bin/quilt.js +49 -1
- package/package.json +1 -1
- package/src/account.js +5 -0
- package/src/api/memory-store.js +7 -0
- package/src/api/routes/voice.js +70 -2
- package/src/api/supabase-store.js +2 -0
- package/src/autowork.js +14 -2
- package/src/control.js +17 -2
- package/src/git-leftovers.js +81 -0
- package/src/inbox.js +15 -2
- package/src/mcp.js +17 -1
- package/src/protocol.js +3 -1
- package/src/relay-ai-vm.js +4 -1
- package/src/relay-mcp.js +26 -3
- package/src/relay-webhooks.js +15 -5
- package/src/server.js +4 -2
- package/src/session-follow.js +3 -1
- package/src/session.js +40 -3
- package/src/source-live/live-block.js +8 -3
- package/src/status.js +1 -1
- package/src/ui/agent-guide.js +1 -0
- package/src/ui/app.css +3 -0
- package/src/ui/voice-call.js +38 -1
- package/src/ui/voice.js +47 -8
- package/src/ui-server.js +22 -2
- package/src/voice.js +131 -7
- package/src/webhooks.js +12 -3
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
|
-
/**
|
|
79
|
-
|
|
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) {
|