heyquilt 0.3.108 → 0.3.116
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/README.md +42 -15
- package/RELEASES.md +103 -0
- package/assets/vendor/LICENSES.md +285 -0
- package/assets/vendor/mammoth.js +13 -0
- package/assets/vendor/marked.js +78 -0
- package/assets/vendor/xlsx.mjs +28228 -0
- package/bin/quilt.js +123 -8
- package/desktop/main.js +33 -3
- package/package.json +10 -2
- package/src/account.js +26 -3
- package/src/agent-join.js +1 -0
- package/src/ai-vm.js +116 -7
- package/src/api/api-machines.js +45 -0
- package/src/api/autoscale.js +71 -0
- package/src/api/cf-mail.js +46 -0
- package/src/api/fly-machines.js +10 -0
- package/src/api/join-text.js +19 -5
- package/src/api/leader.js +40 -0
- package/src/api/memory-store.js +80 -0
- package/src/api/releases.js +1 -1
- package/src/api/routes/agent-wake.js +112 -0
- package/src/api/routes/agents.js +71 -1
- package/src/api/routes/ai-vm.js +19 -3
- package/src/api/routes/github.js +14 -1
- package/src/api/routes/mail.js +422 -0
- package/src/api/routes/voice.js +132 -0
- package/src/api/scaler.js +70 -0
- package/src/api/server.js +57 -10
- package/src/api/supabase-store.js +64 -0
- package/src/app-workspaces.js +4 -0
- package/src/autowork.js +20 -4
- package/src/branch-view.js +371 -0
- package/src/branchdocs.js +17 -1
- package/src/chat-links.js +41 -18
- package/src/cloud-ai.js +10 -13
- package/src/connection.js +1 -1
- package/src/control.js +64 -11
- package/src/duties.js +12 -0
- package/src/gitstate.js +117 -8
- package/src/hooks.js +16 -11
- package/src/inbox-wait.js +59 -0
- package/src/inbox.js +24 -5
- package/src/integrations.js +7 -2
- package/src/key-cli.js +7 -5
- package/src/mail-address.js +105 -0
- package/src/mail-tools.js +143 -0
- package/src/mcp-proxy.js +2 -1
- package/src/mcp.js +62 -39
- package/src/merge3.js +43 -10
- package/src/on-mention.js +97 -0
- package/src/persona.js +2 -2
- package/src/protocol.js +2 -1
- package/src/quilts/archiver.js +168 -0
- package/src/quilts/blame.js +103 -0
- package/src/quilts/block.js +228 -0
- package/src/quilts/cbor.js +92 -0
- package/src/quilts/cli.js +254 -0
- package/src/quilts/client.js +92 -0
- package/src/quilts/conflict-format.js +190 -0
- package/src/quilts/describe.js +120 -0
- package/src/quilts/dogfood.js +82 -0
- package/src/quilts/events.js +146 -0
- package/src/quilts/export-git.js +126 -0
- package/src/quilts/export.js +81 -0
- package/src/quilts/fastmerge.js +41 -0
- package/src/quilts/hash.js +44 -0
- package/src/quilts/ids.js +12 -0
- package/src/quilts/import-git.js +193 -0
- package/src/quilts/import.js +98 -0
- package/src/quilts/index.js +40 -0
- package/src/quilts/limits.js +67 -0
- package/src/quilts/live-block.js +251 -0
- package/src/quilts/live-square.js +104 -0
- package/src/quilts/merge.js +438 -0
- package/src/quilts/objects.js +139 -0
- package/src/quilts/pack.js +118 -0
- package/src/quilts/records.js +210 -0
- package/src/quilts/routes.js +89 -0
- package/src/quilts/service.js +815 -0
- package/src/quilts/setup.js +34 -0
- package/src/quilts/squares.js +239 -0
- package/src/quilts/store-memory.js +531 -0
- package/src/quilts/store-rpc.js +64 -0
- package/src/quilts/store-supabase.js +13 -0
- package/src/quilts/textdiff.js +96 -0
- package/src/quilts/tools.js +201 -0
- package/src/quilts/tree.js +351 -0
- package/src/quilts/window.js +119 -0
- package/src/quilts/xdiff.js +435 -0
- package/src/relay-ai-vm.js +31 -3
- package/src/relay-ask.js +34 -0
- package/src/relay-commit.js +37 -6
- package/src/relay-mcp.js +176 -47
- package/src/relay-outbound.js +40 -18
- package/src/relay-prune.js +196 -0
- package/src/relay-repo-moves.js +43 -0
- package/src/relay-sync.js +89 -10
- package/src/relay-turn-sync.js +28 -15
- package/src/relay-upstream.js +88 -3
- package/src/relay-webhooks.js +148 -34
- package/src/release-words.js +18 -0
- package/src/releases.js +1 -1
- package/src/say-args.js +21 -0
- package/src/secrets-client.js +14 -13
- package/src/server.js +539 -55
- package/src/session.js +779 -117
- package/src/setup.js +13 -8
- package/src/status.js +18 -5
- package/src/tasks.js +17 -6
- package/src/ui/agent-guide.js +17 -9
- package/src/ui/agent-place.js +0 -16
- package/src/ui/agents-table.js +102 -0
- package/src/ui/app.css +291 -35
- package/src/ui/app.js +69 -11
- package/src/ui/board.js +15 -2
- package/src/ui/boxes.js +12 -0
- package/src/ui/changes-seen.js +45 -0
- package/src/ui/changes.js +22 -1
- package/src/ui/chat.js +16 -23
- package/src/ui/cloud-ai.js +5 -6
- package/src/ui/common.js +4 -0
- package/src/ui/feedback-model.js +88 -0
- package/src/ui/feedback.js +166 -0
- package/src/ui/files.js +30 -6
- package/src/ui/fileview.js +73 -19
- package/src/ui/git-rule.js +28 -0
- package/src/ui/home.js +147 -176
- package/src/ui/invite.js +11 -9
- package/src/ui/loom-model.js +58 -42
- package/src/ui/loom.css +33 -18
- package/src/ui/loom.js +22 -8
- package/src/ui/preview-model.js +132 -0
- package/src/ui/preview.js +172 -0
- package/src/ui/rail-model.js +12 -8
- package/src/ui/rail.js +1 -1
- package/src/ui/session.js +301 -315
- package/src/ui/sounds.js +4 -1
- package/src/ui/voice-call.js +233 -0
- package/src/ui/voice-model.js +143 -0
- package/src/ui/voice-worklet.js +26 -0
- package/src/ui/voice.js +280 -0
- package/src/ui-server.js +129 -12
- package/src/voice.js +184 -0
- package/src/wake-tool.js +76 -0
- package/src/wake.js +79 -0
- package/src/webhook-cli.js +62 -0
- package/src/webhooks.js +25 -9
package/src/voice.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Voice in a session (plans/voice.md). Who is in the call, muted or not, and the call setup
|
|
2
|
+
// (each pair's WebRTC offer and answer) travel in presence, so the relay needs nothing new.
|
|
3
|
+
// The audio itself goes from app window to app window, never through the relay or this
|
|
4
|
+
// process. What people say, when the owner turned transcripts on and they agreed, is kept as
|
|
5
|
+
// lines in the session's document for everyone, agents included, to read.
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
// One offer or answer with its gathered addresses is a few kilobytes; anything far bigger isn't one.
|
|
9
|
+
export const MAX_SDP = 16 * 1024
|
|
10
|
+
// Signals one app keeps out at once: one per person in the call.
|
|
11
|
+
export const MAX_SIGNALS = 16
|
|
12
|
+
// A transcript line: one clip of speech, cut at a pause.
|
|
13
|
+
export const MAX_LINE = 2000
|
|
14
|
+
// Lines the session keeps; older ones go as new ones come.
|
|
15
|
+
export const MAX_LINES = 5000
|
|
16
|
+
// Lines of the call going on now that the app shows as they come.
|
|
17
|
+
export const RECENT = 3
|
|
18
|
+
// Everyone connects to everyone else, so a call holds this many people before the audio suffers.
|
|
19
|
+
export const MAX_IN_CALL = 6
|
|
20
|
+
export const CALL_FULL = `This call is full: calls hold ${MAX_IN_CALL} people for now.`
|
|
21
|
+
const CALL = /^[A-Za-z0-9_-]{1,40}$/
|
|
22
|
+
|
|
23
|
+
/** A signal for one person: an offer or an answer (`n` names the offer it answers), with or without its SDP. */
|
|
24
|
+
export function cleanSignal (raw) {
|
|
25
|
+
if (!raw || typeof raw !== 'object') return null
|
|
26
|
+
if (raw.t !== 'offer' && raw.t !== 'answer') return null
|
|
27
|
+
if (typeof raw.n !== 'string' || !/^[A-Za-z0-9]{1,32}$/.test(raw.n)) return null
|
|
28
|
+
const out = { t: raw.t, n: raw.n }
|
|
29
|
+
if (raw.sdp !== undefined) {
|
|
30
|
+
if (typeof raw.sdp !== 'string' || !raw.sdp || raw.sdp.length > MAX_SDP) return null
|
|
31
|
+
out.sdp = raw.sdp
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Our voice presence, cleaned: null when not in voice. */
|
|
37
|
+
export function cleanVoice (raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object' || raw.in !== true) return null
|
|
39
|
+
const sig = {}
|
|
40
|
+
if (raw.sig && typeof raw.sig === 'object') {
|
|
41
|
+
for (const [id, s] of Object.entries(raw.sig).slice(0, MAX_SIGNALS)) {
|
|
42
|
+
const c = /^\d{1,12}$/.test(id) ? cleanSignal(s) : null
|
|
43
|
+
if (c) sig[id] = c
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
in: true,
|
|
48
|
+
muted: raw.muted === true,
|
|
49
|
+
since: Number.isFinite(raw.since) && raw.since > 0 ? Math.floor(raw.since) : 0,
|
|
50
|
+
call: typeof raw.call === 'string' && CALL.test(raw.call) ? raw.call : '',
|
|
51
|
+
// This person's app sends their speech to the transcript (they agreed, and the owner turned it on).
|
|
52
|
+
...(raw.transcribing === true ? { transcribing: true } : {}),
|
|
53
|
+
sig
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A new call's id, when nobody is in voice yet. */
|
|
58
|
+
export function newCallId (now = Date.now()) {
|
|
59
|
+
return `${now.toString(36)}-${crypto.randomBytes(4).toString('hex')}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether transcripts are on: the owner's switch, in the session document. A switch set by anyone else counts for nothing. */
|
|
63
|
+
export function transcribeSetting (doc, members = []) {
|
|
64
|
+
const v = doc.getMap('voiceSettings').get('transcribe')
|
|
65
|
+
if (!v || typeof v !== 'object') return { on: false }
|
|
66
|
+
const owner = (members || []).find((m) => m && m.role === 'owner')
|
|
67
|
+
if (!owner || v.by !== owner.name) return { on: false }
|
|
68
|
+
return { on: v.on === true, by: v.by, ts: Number(v.ts) || 0 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The owner turns transcripts on or off for everyone in the session. */
|
|
72
|
+
export function setTranscribe (doc, { on, by, now = Date.now() }) {
|
|
73
|
+
doc.getMap('voiceSettings').set('transcribe', { on: !!on, by, ts: now })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One person's line of speech in a call's transcript. */
|
|
77
|
+
export function addLine (doc, { call, by, text, now = Date.now() }) {
|
|
78
|
+
const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_LINE)
|
|
79
|
+
if (!t) return null
|
|
80
|
+
if (typeof call !== 'string' || !CALL.test(call)) throw new Error('That is not a call.')
|
|
81
|
+
const line = { id: crypto.randomBytes(8).toString('hex'), call, by, text: t, ts: now }
|
|
82
|
+
const lines = doc.getArray('voiceLines')
|
|
83
|
+
doc.transact(() => {
|
|
84
|
+
lines.push([line])
|
|
85
|
+
const over = lines.length - MAX_LINES
|
|
86
|
+
if (over > 0) lines.delete(0, over)
|
|
87
|
+
})
|
|
88
|
+
return line
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Transcript lines, oldest first: of one call, or after a time, or the most recent `limit`.
|
|
93
|
+
* `calls` lists each call once with who spoke and when it started and ended.
|
|
94
|
+
*/
|
|
95
|
+
export function readLines (doc, { call = '', since = 0, limit = 200 } = {}) {
|
|
96
|
+
const all = doc.getArray('voiceLines').toArray().filter((l) => l && typeof l.text === 'string' && typeof l.call === 'string')
|
|
97
|
+
const calls = new Map()
|
|
98
|
+
for (const l of all) {
|
|
99
|
+
const c = calls.get(l.call) || { call: l.call, start: l.ts, end: l.ts, people: [], lines: 0 }
|
|
100
|
+
c.start = Math.min(c.start, l.ts)
|
|
101
|
+
c.end = Math.max(c.end, l.ts)
|
|
102
|
+
if (!c.people.includes(l.by)) c.people.push(l.by)
|
|
103
|
+
c.lines++
|
|
104
|
+
calls.set(l.call, c)
|
|
105
|
+
}
|
|
106
|
+
const picked = all.filter((l) => (!call || l.call === call) && (!since || l.ts > since))
|
|
107
|
+
const n = Math.max(1, Math.min(1000, Number(limit) || 200))
|
|
108
|
+
return { lines: picked.slice(-n), more: picked.length > n, calls: [...calls.values()].sort((a, b) => a.start - b.start) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Transcript lines as plain text for an agent: one per line, with who and when. */
|
|
112
|
+
export function renderLines ({ lines, more, calls }, { tz } = {}) {
|
|
113
|
+
if (!lines.length) return calls.length ? 'No transcript lines match.' : 'Nothing has been transcribed in this session yet.'
|
|
114
|
+
const time = (ts) => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', ...(tz ? { timeZone: tz } : {}) })
|
|
115
|
+
const out = []
|
|
116
|
+
if (more) out.push('(older lines left out; ask with since or call for them)')
|
|
117
|
+
let call = ''
|
|
118
|
+
for (const l of lines) {
|
|
119
|
+
if (l.call !== call) {
|
|
120
|
+
call = l.call
|
|
121
|
+
const c = calls.find((x) => x.call === call)
|
|
122
|
+
out.push(`Call ${call}${c ? ` (${new Date(c.start).toISOString().slice(0, 16).replace('T', ' ')} UTC, ${c.people.join(', ')})` : ''}:`)
|
|
123
|
+
}
|
|
124
|
+
out.push(`- ${time(l.ts)} ${l.by}: ${l.text}`)
|
|
125
|
+
}
|
|
126
|
+
return out.join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The call as one app sees it: our presence client id, whether we're in, and everyone else in
|
|
131
|
+
* voice with the signal they left for us (only ours: everyone's travels, each reads its own).
|
|
132
|
+
*/
|
|
133
|
+
export function voiceView ({ awareness, clientID, members = [], doc }) {
|
|
134
|
+
const me = cleanVoice(awareness.getLocalState()?.voice)
|
|
135
|
+
const peers = []
|
|
136
|
+
for (const [id, s] of awareness.getStates()) {
|
|
137
|
+
if (id === clientID || !s || !s.name) continue
|
|
138
|
+
const v = cleanVoice(s.voice)
|
|
139
|
+
if (!v) continue
|
|
140
|
+
peers.push({ id, name: s.name, color: s.color || '', muted: v.muted, since: v.since, call: v.call, ...(v.transcribing ? { transcribing: true } : {}), signal: v.sig[String(clientID)] || null })
|
|
141
|
+
}
|
|
142
|
+
peers.sort((a, b) => a.since - b.since || a.id - b.id)
|
|
143
|
+
const t = doc ? transcribeSetting(doc, members) : { on: false }
|
|
144
|
+
const call = me ? me.call : (peers[0] ? peers[0].call : '')
|
|
145
|
+
// When the call began: its first person in. The chat shows it, and the calls before it.
|
|
146
|
+
const starts = [...(me ? [me.since] : []), ...peers.map((p) => p.since)].filter((x) => x > 0)
|
|
147
|
+
const said = doc ? readLines(doc, { limit: 1 }) : { calls: [] }
|
|
148
|
+
return {
|
|
149
|
+
me: clientID,
|
|
150
|
+
in: !!me,
|
|
151
|
+
muted: !!(me && me.muted),
|
|
152
|
+
call,
|
|
153
|
+
since: starts.length ? Math.min(...starts) : 0,
|
|
154
|
+
transcribe: t.on,
|
|
155
|
+
peers,
|
|
156
|
+
// The latest lines of the call going on now, as each is written down.
|
|
157
|
+
recent: doc && call ? readLines(doc, { call, limit: RECENT }).lines : [],
|
|
158
|
+
// Every call that has a transcript: when, who spoke, how many lines.
|
|
159
|
+
calls: said.calls
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Who is in voice, for status: name -> { muted, transcribing }. */
|
|
164
|
+
export function inVoice (awareness, clientID) {
|
|
165
|
+
const out = new Map()
|
|
166
|
+
for (const [id, s] of awareness.getStates()) {
|
|
167
|
+
const v = s && s.name ? cleanVoice(s.voice) : null
|
|
168
|
+
if (v) out.set(s.name, { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}), ...(id === clientID ? { me: true } : {}) })
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
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. ' +
|
|
174
|
+
'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. ' +
|
|
175
|
+
'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.'
|
|
176
|
+
|
|
177
|
+
/** Who is in a call, for an agent's status: a heading and a line each, or nothing when nobody is. */
|
|
178
|
+
export function voiceStatusLines (people, transcribe) {
|
|
179
|
+
if (!people.length) return []
|
|
180
|
+
return [
|
|
181
|
+
`## In a voice call${transcribe ? ' (transcribed: quilt_voice_transcript reads it)' : ' (not transcribed: ask in chat what was decided)'}`,
|
|
182
|
+
...people.map((p) => `- ${p.name}${p.muted ? ' (muted)' : ''}${transcribe && !p.transcribing ? ' (not transcribed)' : ''}`)
|
|
183
|
+
]
|
|
184
|
+
}
|
package/src/wake-tool.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// quilt_wake (plans/agent-wake.md): step 2 of joining, the same in a folder (mcp.js, through the
|
|
2
|
+
// app's control API) and hosted (relay-mcp.js). The agent picks how Quilt wakes it; the tool sets
|
|
3
|
+
// that way up where it runs (the relay's webhook or email subscription, the folder's --on-mention
|
|
4
|
+
// command), switches the others off so the agent is never woken twice, and records the choice with
|
|
5
|
+
// the accounts API so people can see it.
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { WAKE_METHODS, describeWake, wakeLabel } from './wake.js'
|
|
8
|
+
import { describeSubscription } from './webhooks.js'
|
|
9
|
+
|
|
10
|
+
export const WAKE_DESCRIPTION = 'Step 2 of joining Quilt: choose how Quilt wakes you when you are mentioned, sent a direct message, handed a task or sent email. ' +
|
|
11
|
+
'With no method: the ways that fit you and what you chose. With method: sets it up and records it (people see how each agent wakes). ' +
|
|
12
|
+
'webhook takes url (secret, bearer optional); email takes address, then code from the email Quilt sends; command takes command (on a computer running quilt join); schedule takes every_minutes.'
|
|
13
|
+
|
|
14
|
+
export const WAKE_INPUT = {
|
|
15
|
+
method: z.enum(WAKE_METHODS).optional().describe('How Quilt wakes you; leave out to see the ways that fit you'),
|
|
16
|
+
url: z.string().max(2000).optional().describe('webhook: the public https URL Quilt POSTs to'),
|
|
17
|
+
secret: z.string().max(200).optional().describe('webhook: signing secret (Quilt makes one when left out)'),
|
|
18
|
+
bearer: z.string().max(500).optional().describe('webhook: a key your receiver wants as Authorization: Bearer'),
|
|
19
|
+
address: z.string().max(254).optional().describe('email: the inbox you watch'),
|
|
20
|
+
code: z.string().max(12).optional().describe('email: the code Quilt emailed to that address'),
|
|
21
|
+
command: z.string().max(2000).optional().describe('command: what to run on each event (the event JSON on stdin)'),
|
|
22
|
+
every_minutes: z.number().int().min(1).max(1440).optional().describe('schedule: how often you check quilt_inbox')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs quilt_wake. `api(method, route, body)` reaches the accounts API as the agent; `relay` sets
|
|
27
|
+
* the relay's one subscription ({ subscribe(given) → what the agent is shown, unsubscribe() });
|
|
28
|
+
* `onMention(command)` sets or (with '') clears a folder's --on-mention command (null when hosted).
|
|
29
|
+
*/
|
|
30
|
+
export async function runWakeTool (args, { api, relay, onMention = null }) {
|
|
31
|
+
const method = args.method
|
|
32
|
+
if (!method) {
|
|
33
|
+
const r = await api('GET', '/v1/agents/me/wake')
|
|
34
|
+
return describeWake({ ...r.wake, pending: r.wake.pending }, r.ways)
|
|
35
|
+
}
|
|
36
|
+
const record = (body) => api('PUT', '/v1/agents/me/wake', { method, ...body })
|
|
37
|
+
const offElsewhere = async ({ keepRelay = false, keepCommand = false } = {}) => {
|
|
38
|
+
if (!keepRelay) await relay.unsubscribe().catch(() => {})
|
|
39
|
+
if (!keepCommand && onMention) await onMention('')
|
|
40
|
+
}
|
|
41
|
+
if (method === 'email') {
|
|
42
|
+
if (args.code !== undefined) {
|
|
43
|
+
const r = await record({ code: args.code })
|
|
44
|
+
await relay.subscribe({ email: true })
|
|
45
|
+
await offElsewhere({ keepRelay: true })
|
|
46
|
+
return `Quilt wakes you by email now: each event goes to ${r.wake.detail.address} (a few at once come as one email), from the session's own address. Recorded: ${r.wake.label}.`
|
|
47
|
+
}
|
|
48
|
+
const r = await record({ address: args.address })
|
|
49
|
+
return `Quilt emailed a code to ${r.wake.pending.address}. Give it back with quilt_wake { method: "email", code: "<the code>" } within 30 minutes; nothing else is sent there until you do.`
|
|
50
|
+
}
|
|
51
|
+
if (method === 'webhook') {
|
|
52
|
+
// Checked by the accounts API first (does a webhook fit you, is the URL one Quilt may POST to), then set up.
|
|
53
|
+
await record({ url: args.url })
|
|
54
|
+
const shown = await relay.subscribe({ url: args.url, secret: args.secret, bearer: args.bearer })
|
|
55
|
+
await offElsewhere({ keepRelay: true })
|
|
56
|
+
return `${describeSubscription(shown, { showSecret: !!shown.made })}\nRecorded: wakes by webhook.`
|
|
57
|
+
}
|
|
58
|
+
if (method === 'command') {
|
|
59
|
+
if (!onMention) throw new Error('command is for an agent on a computer running `quilt join`: choose webhook, email or schedule.')
|
|
60
|
+
if (!String(args.command || '').trim()) throw new Error('command: what to run on each event.')
|
|
61
|
+
const r = await record({})
|
|
62
|
+
await onMention(String(args.command))
|
|
63
|
+
await offElsewhere({ keepCommand: true })
|
|
64
|
+
return `Quilt runs \`${args.command}\` on each event now (the event JSON on stdin and in QUILT_EVENT, one at a time). Recorded: ${r.wake.label}.`
|
|
65
|
+
}
|
|
66
|
+
const r = await record(method === 'schedule' ? { every_minutes: args.every_minutes } : {})
|
|
67
|
+
await offElsewhere()
|
|
68
|
+
const how = {
|
|
69
|
+
wait: 'Loop on `quilt inbox --wait --json`: it returns as soon as something is for you.',
|
|
70
|
+
push: 'Subscribe to the quilt://inbox resource (Claude Code: start it with the quilt channel); you are told as each event arrives.',
|
|
71
|
+
schedule: `Check quilt_inbox every ${r.wake.detail.everyMinutes} minutes; people see that you may take that long to answer.`,
|
|
72
|
+
turns: 'You are woken when your person starts a turn; `npx heyquilt sync` shows what waited.',
|
|
73
|
+
none: 'Quilt won\'t wake you: what is for you waits in quilt_inbox until your person starts you.'
|
|
74
|
+
}[method]
|
|
75
|
+
return `${how} Recorded: ${wakeLabel(r.wake)}.`
|
|
76
|
+
}
|
package/src/wake.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// How Quilt wakes an agent (plans/agent-wake.md): step 2 of joining. The ways, in words for agents
|
|
2
|
+
// and for the people who need to know whether an @mention reaches one. Pure: no I/O.
|
|
3
|
+
|
|
4
|
+
export const WAKE_METHODS = ['webhook', 'email', 'command', 'wait', 'push', 'schedule', 'turns', 'none']
|
|
5
|
+
|
|
6
|
+
// What each way is, who it fits, and how to choose it with quilt_wake.
|
|
7
|
+
const WAYS = {
|
|
8
|
+
webhook: { fits: 'any agent with a public https URL', what: 'Quilt POSTs each event (mention, direct message, task, email) to your URL, signed, as it happens.', how: 'quilt_wake { method: "webhook", url: "https://…", secret?: "…", bearer?: "…" }' },
|
|
9
|
+
email: { fits: 'any agent with an inbox it watches', what: 'Quilt emails you each event (a few at once come as one email). The address is confirmed with a code first.', how: 'quilt_wake { method: "email", address: "you@example.com" }, then quilt_wake { method: "email", code: "<the code Quilt emailed>" }' },
|
|
10
|
+
command: { fits: 'agents on a computer running `quilt join`', what: 'Quilt runs your command on each event, the event as JSON on stdin and in QUILT_EVENT.', how: 'quilt_wake { method: "command", command: "<command to run>" } (the same as `quilt join --on-mention`)' },
|
|
11
|
+
wait: { fits: 'agents on a computer with a loop of their own', what: '`quilt inbox --wait` returns as soon as something is for you; run it again after each one.', how: 'quilt_wake { method: "wait" }, then loop on `quilt inbox --wait --json`' },
|
|
12
|
+
push: { fits: 'an MCP client that keeps Quilt\'s MCP open and listens', what: 'your client subscribes to the quilt://inbox resource (Claude Code: its quilt channel) and is told as each event arrives.', how: 'quilt_wake { method: "push" }, then subscribe to quilt://inbox' },
|
|
13
|
+
schedule: { fits: 'agents that run on a timer (cron)', what: 'you check quilt_inbox every few minutes; Quilt tells people how long you may take to answer.', how: 'quilt_wake { method: "schedule", every_minutes: 5 }' },
|
|
14
|
+
turns: { fits: 'sandboxes wiped after every turn', what: 'you are woken only when your person starts a turn; `npx heyquilt sync` shows what waited.', how: 'quilt_wake { method: "turns" }' },
|
|
15
|
+
none: { fits: 'agents only their person starts', what: 'Quilt doesn\'t wake you: what is for you waits in quilt_inbox until your person starts you.', how: 'quilt_wake { method: "none" }' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Which ways fit an agent: `cli` (a computer running quilt join), `sandbox`, or neither (over HTTP). */
|
|
19
|
+
export function waysFor ({ cli = false, sandbox = false } = {}) {
|
|
20
|
+
if (cli) return ['command', 'wait', 'push', 'webhook', 'email', 'schedule', 'none']
|
|
21
|
+
if (sandbox) return ['webhook', 'email', 'turns', 'schedule', 'none']
|
|
22
|
+
// Over HTTP: a sandbox's agent reaches Quilt this way too, so turns is offered here as well.
|
|
23
|
+
return ['webhook', 'email', 'schedule', 'turns', 'none']
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The ways that fit, as a list an agent can choose from. */
|
|
27
|
+
export function describeWays (ways) {
|
|
28
|
+
return ways.map((m) => `- ${m}: ${WAYS[m].what} For ${WAYS[m].fits}. ${WAYS[m].how}`).join('\n')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How an agent wakes, in a few words for people: "wakes by webhook (hooks.example.com)". */
|
|
32
|
+
export function wakeLabel (wake) {
|
|
33
|
+
const w = wake || {}
|
|
34
|
+
const d = w.detail || {}
|
|
35
|
+
switch (w.method) {
|
|
36
|
+
case 'webhook': return `wakes by webhook${d.host ? ` (${d.host})` : ''}`
|
|
37
|
+
case 'email': return 'wakes by email'
|
|
38
|
+
case 'command': return 'wakes by a command on its computer'
|
|
39
|
+
case 'wait': return 'waits for events on its computer'
|
|
40
|
+
case 'push': return 'wakes by MCP push'
|
|
41
|
+
case 'schedule': return `checks every ${d.everyMinutes || '?'} min`
|
|
42
|
+
case 'turns': return 'wakes when its person starts a turn'
|
|
43
|
+
case 'none': return 'not woken: only when its person starts it'
|
|
44
|
+
default: return "hasn't said how Quilt wakes it"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Step 2, as every agent is told it until it has chosen. */
|
|
49
|
+
export const WAKE_STEP = 'Step 2 of 3: choose how Quilt wakes you when you are mentioned, sent a direct message, handed a task or sent email, so you answer without being checked on. Call quilt_wake to see the ways that fit you and pick one.'
|
|
50
|
+
|
|
51
|
+
/** The reminder on every answer until the agent has chosen. */
|
|
52
|
+
export const WAKE_REMINDER = 'Quilt can\'t wake you yet: choose how with quilt_wake (step 2 of joining), so people\'s @mentions reach you.'
|
|
53
|
+
|
|
54
|
+
/** What quilt_wake answers with no arguments: what you chose, and the ways that fit you. */
|
|
55
|
+
export function describeWake (wake, ways) {
|
|
56
|
+
const chosen = wake && wake.method ? `You chose: ${wake.method} (${wakeLabel(wake)}).` : 'You haven\'t chosen yet.'
|
|
57
|
+
const pending = wake && wake.pending && wake.pending.address ? ` Waiting for the code sent to ${wake.pending.address}: quilt_wake { method: "email", code: "…" }.` : ''
|
|
58
|
+
return `${chosen}${pending}\n\nWays Quilt can wake you:\n${describeWays(ways)}\n\nPick the one that fits how you run; you can change it any time.`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether to remind an agent of step 2, asking `fetchWake()` (→ { method } or null; throws when it
|
|
63
|
+
* can't tell) at most every `ttlMs`. `reminder()` is WAKE_REMINDER or ''; `chose(method)` records a
|
|
64
|
+
* choice at once (quilt_wake). An answer that can't be had reminds nobody.
|
|
65
|
+
*/
|
|
66
|
+
export function wakeCheck ({ fetchWake, ttlMs = 10 * 60 * 1000, now = Date.now } = {}) {
|
|
67
|
+
let known = null // { method, at }
|
|
68
|
+
let asking = null
|
|
69
|
+
return {
|
|
70
|
+
async reminder () {
|
|
71
|
+
if (!known || now() - known.at >= ttlMs) {
|
|
72
|
+
if (!asking) asking = Promise.resolve().then(fetchWake).then((w) => { known = { method: (w && w.method) || null, at: now() } }, () => {}).finally(() => { asking = null })
|
|
73
|
+
await asking
|
|
74
|
+
}
|
|
75
|
+
return known && !known.method ? WAKE_REMINDER : ''
|
|
76
|
+
},
|
|
77
|
+
chose (method) { known = { method: method || null, at: now() } }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `quilt webhook …` and `quilt join --webhook-url`: a thin command line over the one webhook an
|
|
2
|
+
// agent subscribes with quilt_webhook_subscribe (session.js; the relay keeps and sends it). Keys are
|
|
3
|
+
// read from environment variables by NAME, so they never sit in shell history; neither the bearer
|
|
4
|
+
// key nor a secret you gave is ever printed (a secret Quilt made is, once).
|
|
5
|
+
import { describeSubscription, describeWebhookTest } from './webhooks.js'
|
|
6
|
+
|
|
7
|
+
export const WEBHOOK_USAGE = `usage:
|
|
8
|
+
quilt webhook set <url> [--bearer-env VAR] [--secret-env VAR] [--events chat.mention,chat.dm,task.assigned] [--coalesce]
|
|
9
|
+
quilt webhook clear
|
|
10
|
+
quilt webhook test
|
|
11
|
+
quilt webhook [status]`
|
|
12
|
+
|
|
13
|
+
/** Parses `quilt webhook ...` args: { op, url?, bearerEnv?, secretEnv?, events?, coalesce? }. Throws the usage on a mistake. */
|
|
14
|
+
export function parseWebhookArgs (args) {
|
|
15
|
+
const a = [...args]
|
|
16
|
+
const take = (...flags) => {
|
|
17
|
+
for (const flag of flags) {
|
|
18
|
+
const i = a.indexOf(flag)
|
|
19
|
+
if (i === -1) continue
|
|
20
|
+
const v = a[i + 1]
|
|
21
|
+
if (!v || v.startsWith('--')) throw new Error(`${flag} needs a value\n${WEBHOOK_USAGE}`)
|
|
22
|
+
a.splice(i, 2)
|
|
23
|
+
return v
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
const bearerEnv = take('--bearer-env', '--token-env')
|
|
28
|
+
const secretEnv = take('--secret-env')
|
|
29
|
+
const ev = take('--events')
|
|
30
|
+
const ci = a.indexOf('--coalesce')
|
|
31
|
+
const coalesce = ci !== -1
|
|
32
|
+
if (coalesce) a.splice(ci, 1)
|
|
33
|
+
const sub = a.shift() || 'status'
|
|
34
|
+
if (sub === 'set') {
|
|
35
|
+
const url = a.shift()
|
|
36
|
+
if (!url || a.length) throw new Error(WEBHOOK_USAGE)
|
|
37
|
+
return { op: 'set', url, ...(bearerEnv ? { bearerEnv } : {}), ...(secretEnv ? { secretEnv } : {}), ...(ev ? { events: ev.split(',').map((x) => x.trim()).filter(Boolean) } : {}), ...(coalesce ? { coalesce: true } : {}) }
|
|
38
|
+
}
|
|
39
|
+
if (bearerEnv || secretEnv || ev || coalesce || a.length) throw new Error(WEBHOOK_USAGE)
|
|
40
|
+
if (sub === 'clear' || sub === 'test') return { op: sub }
|
|
41
|
+
if (sub === 'status' || sub === 'info') return { op: 'status' }
|
|
42
|
+
throw new Error(WEBHOOK_USAGE)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The value of environment variable `name` (for `flag`), or a readable error that never holds the value. */
|
|
46
|
+
export function fromEnv (name, flag = '--bearer-env', env = process.env) {
|
|
47
|
+
if (!name) return undefined
|
|
48
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`${flag} takes the NAME of an environment variable, like MY_HOOK_KEY (not the key itself).`)
|
|
49
|
+
const v = env[name]
|
|
50
|
+
if (!v) throw new Error(`The environment variable ${name} is empty or not set.`)
|
|
51
|
+
return v
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What `quilt webhook status` prints. */
|
|
55
|
+
export function describeStatus (w) {
|
|
56
|
+
if (!w) return 'No webhook. Set one with `quilt webhook set <https url>` (or quilt_webhook_subscribe).'
|
|
57
|
+
const by = w.relay ? 'the relay sends it (it reaches you while this computer sleeps)' : 'this computer sends it, while it is awake' + (w.local ? ' (the relay cannot reach that URL)' : ' (the relay takes it over once this folder is let in)')
|
|
58
|
+
const last = w.last ? `; last POST ${w.last.ok ? 'ok' : 'failed'}${w.last.status ? ` (HTTP ${w.last.status})` : ''}${w.last.error ? `: ${w.last.error}` : ''} at ${new Date(w.last.at).toISOString()}` : ''
|
|
59
|
+
return `Webhook: ${w.url} on ${w.events.join(', ')}${w.bearer ? ', bearer key: set' : ''}${w.coalesce ? ', bursts coalesced' : ''}; ${by}${last}.`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { describeSubscription, describeWebhookTest }
|
package/src/webhooks.js
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
// The pure parts (shapes, signing, validation) are shared by the relay, which
|
|
12
12
|
// delivers for hosted agents, and the local session, which delivers for agents
|
|
13
13
|
// joined from a computer.
|
|
14
|
+
import { authorOf } from './ui/chat.js'
|
|
14
15
|
import crypto from 'node:crypto'
|
|
15
16
|
import dns from 'node:dns'
|
|
16
17
|
import net from 'node:net'
|
|
17
18
|
|
|
18
|
-
export const WEBHOOK_EVENTS = ['chat.mention', 'chat.dm', 'task.assigned']
|
|
19
|
-
export const EVENT_OF_KIND = { mention: 'chat.mention', dm: 'chat.dm', task: 'task.assigned' }
|
|
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' }
|
|
20
21
|
export const MAX_URL = 2000
|
|
21
22
|
export const MAX_SECRET = 200
|
|
22
23
|
export const MIN_SECRET = 16
|
|
@@ -122,7 +123,7 @@ export const newSecret = () => crypto.randomBytes(24).toString('hex')
|
|
|
122
123
|
* agent chose is kept (16 to 200 characters); without one, a new one is made and the
|
|
123
124
|
* agent is shown it once, in the tool's answer.
|
|
124
125
|
*/
|
|
125
|
-
export function makeSubscription ({ url, secret, events, bearer } = {}, { allowLocal = false, now = Date.now } = {}) {
|
|
126
|
+
export function makeSubscription ({ url, secret, events, bearer, coalesce } = {}, { allowLocal = false, now = Date.now } = {}) {
|
|
126
127
|
const u = parseWebhookUrl(url, { allowLocal })
|
|
127
128
|
const ev = parseWebhookEvents(events)
|
|
128
129
|
let s = secret == null ? '' : String(secret)
|
|
@@ -132,17 +133,17 @@ export function makeSubscription ({ url, secret, events, bearer } = {}, { allowL
|
|
|
132
133
|
// A key the receiver wants on every POST (a Grok Bot routine's sender key, for example), sent as Authorization: Bearer <key>.
|
|
133
134
|
const b = bearer == null ? '' : String(bearer).trim()
|
|
134
135
|
if (b.length > MAX_BEARER || /[\r\n]/.test(b)) throw new Error(`The bearer key must be one line of at most ${MAX_BEARER} characters.`)
|
|
135
|
-
return { url: u, secret: s, events: ev, since: now(), made, ...(b ? { bearer: b } : {}) }
|
|
136
|
+
return { url: u, secret: s, events: ev, since: now(), made, ...(b ? { bearer: b } : {}), ...(coalesce ? { coalesce: true } : {}) }
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
/** The subscription as an agent sees it (its secret only when it was just made). */
|
|
139
140
|
export function describeSubscription (sub, { showSecret = false } = {}) {
|
|
140
141
|
if (!sub) return 'No webhook: Quilt only answers quilt_inbox when you ask.'
|
|
141
|
-
const lines = [`Webhook: Quilt POSTs to ${sub.url} on ${sub.events.join(', ')}${sub.bearer ? ', with your bearer key in the Authorization header' : ''}.`]
|
|
142
|
+
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)' : ''}.`]
|
|
142
143
|
if (showSecret) lines.push(`Secret (shown once; check x-quilt-signature with it): ${sub.secret}`)
|
|
143
|
-
lines.push('Each POST is JSON ({ event, id, session, workspace, room, to, by, text, ts, task?, 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; context: the messages before this one between you and its sender, oldest first) with x-quilt-event, x-quilt-delivery, x-quilt-timestamp and ' +
|
|
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 ' +
|
|
144
145
|
'x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"). Answer 2xx quickly; a failed POST is retried a few times. ' +
|
|
145
|
-
'What you are told is still in quilt_inbox.
|
|
146
|
+
'What you are told is still in quilt_inbox. quilt_webhook_test POSTs a webhook.test event now; stop with quilt_webhook_unsubscribe.')
|
|
146
147
|
return lines.join('\n')
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -153,12 +154,13 @@ export function describeSubscription (sub, { showSecret = false } = {}) {
|
|
|
153
154
|
export function webhookPayload (e, { room = '', to = '', workspace = '' } = {}) {
|
|
154
155
|
const p = { event: EVENT_OF_KIND[e.kind] || e.kind, id: String(e.id || ''), room, session: room, workspace: workspace ? { id: String(workspace) } : null, to, by: String(e.by || ''), text: String(e.text || ''), ts: Number(e.ts) || Date.now() }
|
|
155
156
|
if (e.kind === 'task' && e.task) p.task = { id: e.task.id, title: e.task.title, column: e.task.column, assignee: e.task.assignee ?? null, forAi: !!e.task.forAi, tool: e.task.tool || '', files: e.task.files || [] }
|
|
157
|
+
if (e.kind === 'email' && e.mail) p.mail = { id: String(e.mail) } // read it with quilt_mail (read: id)
|
|
156
158
|
if (e.commit) p.commit = true // work this member asked to commit was committed: nothing to answer
|
|
157
159
|
if (e.nudge) p.nudge = true // a reminder from Quilt (uncommitted work): nothing to answer
|
|
158
160
|
// The conversation before this with whoever it is from (conversation.js), so an agent woken
|
|
159
161
|
// fresh knows what "that" refers to; `earlier` older messages are a quilt_conversation away.
|
|
160
162
|
if (Array.isArray(e.context) && e.context.length) {
|
|
161
|
-
p.context = e.context.map((m) => ({ id: m.id, by: m
|
|
163
|
+
p.context = e.context.map((m) => ({ id: m.id, by: authorOf(m), ...(m.to ? { to: m.to } : {}), text: m.text, ts: m.ts }))
|
|
162
164
|
if (e.earlier) p.earlier = e.earlier
|
|
163
165
|
}
|
|
164
166
|
return p
|
|
@@ -184,7 +186,7 @@ const retryable = (status) => status === 408 || status === 429 || status >= 500
|
|
|
184
186
|
* the receiver is down, slow or answers 5xx/429. Resolves { ok, status, attempts, error? };
|
|
185
187
|
* never throws. `fetch`, `delays` and `sleep` are for tests.
|
|
186
188
|
*/
|
|
187
|
-
export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {} } = {}) {
|
|
189
|
+
export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {}, checkHost = null } = {}) {
|
|
188
190
|
const body = JSON.stringify(payload)
|
|
189
191
|
const delivery = crypto.randomUUID()
|
|
190
192
|
let status = 0
|
|
@@ -192,6 +194,11 @@ export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch,
|
|
|
192
194
|
let attempt = 0
|
|
193
195
|
while (attempt < delays.length + 1) {
|
|
194
196
|
attempt++
|
|
197
|
+
// From Quilt's servers, the host must still be public as it resolves now (DNS can change): never retried.
|
|
198
|
+
if (checkHost) {
|
|
199
|
+
const host = await checkHost(sub.url)
|
|
200
|
+
if (!host.ok) { log(`webhook to ${sub.url} skipped (${payload.event}): ${host.reason}`); return { ok: false, status: 0, attempts: attempt, error: host.reason, delivery } }
|
|
201
|
+
}
|
|
195
202
|
const ts = String(now())
|
|
196
203
|
try {
|
|
197
204
|
const res = await fetch(sub.url, {
|
|
@@ -233,3 +240,12 @@ export async function deliverEvents (sub, events, { room = '', to = '', workspac
|
|
|
233
240
|
}
|
|
234
241
|
return out
|
|
235
242
|
}
|
|
243
|
+
|
|
244
|
+
/** For quilt_webhook_subscribe's `coalesce`. */
|
|
245
|
+
export const COALESCE_HELP = 'true: a burst wakes you once. Events are gathered for 2 s, and any that follow within 30 s of a POST go together in one more POST; a POST with several has them all, oldest first, in `events` (the first one\'s fields on top). Default false: one POST per event.'
|
|
246
|
+
|
|
247
|
+
/** What quilt_webhook_test (and `quilt webhook test`) says. */
|
|
248
|
+
export function describeWebhookTest (r) {
|
|
249
|
+
if (r && r.ok) return `Test POST delivered: your receiver answered HTTP ${r.status}.`
|
|
250
|
+
return `Test POST failed${r && r.status ? ` (HTTP ${r.status})` : ''}${r && r.attempts ? ` after ${r.attempts} tr${r.attempts === 1 ? 'y' : 'ies'}` : ''}: ${(r && r.error) || 'no answer'}.`
|
|
251
|
+
}
|