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/RELEASES.md +14 -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/seam-model.js +22 -12
- 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/relay-webhooks.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
package/src/session-follow.js
CHANGED
|
@@ -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')
|
package/src/ui/agent-guide.js
CHANGED
|
@@ -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); }
|
package/src/ui/seam-model.js
CHANGED
|
@@ -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 =
|
|
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
|
|
34
|
-
: s.opening ? `This folder is starting to follow
|
|
35
|
-
: [`This folder's work is live
|
|
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
|
|
39
|
-
return { label, title, aria: `Source control: ${name
|
|
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 =
|
|
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 ? `
|
|
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 =
|
|
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
|
|
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
|
|
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
|
}
|
package/src/ui/voice-call.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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),
|