@profullstack/qryptchat 0.8.1 → 0.9.1
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/dist/chunk-997mmke4.js +454 -0
- package/dist/{chunk-wtbsdxe7.js → chunk-pg6thvma.js} +18 -3
- package/dist/commands.js +1 -1
- package/dist/mcp.js +1 -1
- package/package.json +10 -2
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CALL_TYPE,
|
|
3
|
+
parseCall
|
|
4
|
+
} from "./chunk-pg6thvma.js";
|
|
5
|
+
|
|
6
|
+
// src/cli/voice-agent.js
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
// src/lib/chat/voice-recorder.js
|
|
10
|
+
function encodeWav(samples, sampleRate) {
|
|
11
|
+
const buf = new ArrayBuffer(44 + samples.length * 2);
|
|
12
|
+
const v = new DataView(buf);
|
|
13
|
+
const str = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
|
14
|
+
str(0, "RIFF");
|
|
15
|
+
v.setUint32(4, 36 + samples.length * 2, true);
|
|
16
|
+
str(8, "WAVE");
|
|
17
|
+
str(12, "fmt ");
|
|
18
|
+
v.setUint32(16, 16, true);
|
|
19
|
+
v.setUint16(20, 1, true);
|
|
20
|
+
v.setUint16(22, 1, true);
|
|
21
|
+
v.setUint32(24, sampleRate, true);
|
|
22
|
+
v.setUint32(28, sampleRate * 2, true);
|
|
23
|
+
v.setUint16(32, 2, true);
|
|
24
|
+
v.setUint16(34, 16, true);
|
|
25
|
+
str(36, "data");
|
|
26
|
+
v.setUint32(40, samples.length * 2, true);
|
|
27
|
+
for (let i = 0;i < samples.length; i++) {
|
|
28
|
+
const x = Math.max(-1, Math.min(1, samples[i]));
|
|
29
|
+
v.setInt16(44 + i * 2, x < 0 ? x * 32768 : x * 32767, true);
|
|
30
|
+
}
|
|
31
|
+
return new Uint8Array(buf);
|
|
32
|
+
}
|
|
33
|
+
function createEndpointer({ stepMs = 50, silenceMs = 1100, noSpeechMs = 8000, maxMs = 60000, minLevel = 0.012, maxThreshold = 0.05, speechMs = 150 } = {}) {
|
|
34
|
+
let t = 0;
|
|
35
|
+
let floor = null;
|
|
36
|
+
let loud = 0;
|
|
37
|
+
let quietFor = 0;
|
|
38
|
+
let spoke = false;
|
|
39
|
+
return {
|
|
40
|
+
get spoke() {
|
|
41
|
+
return spoke;
|
|
42
|
+
},
|
|
43
|
+
push(rms) {
|
|
44
|
+
t += stepMs;
|
|
45
|
+
if (t <= 300) {
|
|
46
|
+
floor = floor === null ? rms : Math.min(floor, rms);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const threshold = Math.max(minLevel, Math.min(maxThreshold, (floor ?? 0) * 3));
|
|
50
|
+
if (rms > threshold) {
|
|
51
|
+
loud += 1;
|
|
52
|
+
quietFor = 0;
|
|
53
|
+
if (!spoke && loud * stepMs >= speechMs) {
|
|
54
|
+
spoke = true;
|
|
55
|
+
return "speech-start";
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
loud = 0;
|
|
59
|
+
quietFor += stepMs;
|
|
60
|
+
if (!spoke)
|
|
61
|
+
floor = floor === null ? rms : floor * 0.95 + rms * 0.05;
|
|
62
|
+
}
|
|
63
|
+
if (spoke && quietFor >= silenceMs)
|
|
64
|
+
return "end";
|
|
65
|
+
if (!spoke && t >= noSpeechMs)
|
|
66
|
+
return "no-speech";
|
|
67
|
+
if (t >= maxMs)
|
|
68
|
+
return "max";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/lib/chat/voice-mode.js
|
|
75
|
+
var DICTATED = "\uD83D\uDDE3️";
|
|
76
|
+
var hostOf = (url) => {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
79
|
+
} catch {
|
|
80
|
+
return "a link";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
function speakable(text, max = 1200) {
|
|
84
|
+
let s = String(text ?? "");
|
|
85
|
+
if (s.startsWith(DICTATED))
|
|
86
|
+
s = s.slice(DICTATED.length);
|
|
87
|
+
s = s.replace(/```[\s\S]*?(```|$)/g, " (code in the chat) ").replace(/`([^`]+)`/g, "$1").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, "$1").replace(/https?:\/\/[^\s)>\]]+/g, (u) => `link to ${hostOf(u)}`).replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "").replace(/^\s*>\s?/gm, "").replace(/^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*$/gm, "").replace(/\|/g, ", ").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").replace(/(^|\W)[*_](\S[^*_]*?)[*_](?=\W|$)/g, "$1$2").replace(/\s*\n+\s*/g, ". ").replace(/([.!?:;,])(?:\s*\.)+/g, "$1").replace(/\s{2,}/g, " ").trim();
|
|
88
|
+
if (s.length <= max)
|
|
89
|
+
return s;
|
|
90
|
+
const cut = s.slice(0, max);
|
|
91
|
+
const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
|
|
92
|
+
return `${end > max / 3 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, "…")} The rest is in the chat.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/cli/voice-agent.js
|
|
96
|
+
var HKDF = 1;
|
|
97
|
+
var e2eeOptions = (key) => ({
|
|
98
|
+
keyProviderOptions: {
|
|
99
|
+
sharedKey: new Uint8Array(key),
|
|
100
|
+
ratchetSalt: new TextEncoder().encode("LKFrameEncryptionKey"),
|
|
101
|
+
ratchetWindowSize: 16,
|
|
102
|
+
failureTolerance: -1,
|
|
103
|
+
keyRingSize: 16,
|
|
104
|
+
keyDerivationFunction: HKDF
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
var JOIN_RECENT_MS = 30 * 60 * 1000;
|
|
108
|
+
var ALONE_LEAVE_MS = 30 * 1000;
|
|
109
|
+
var MAX_CALL_MS = 3 * 60 * 60 * 1000;
|
|
110
|
+
var RATE = 16000;
|
|
111
|
+
var SPEAK_RATE = 24000;
|
|
112
|
+
var SPOKEN_PREFIX = "\uD83D\uDD0A";
|
|
113
|
+
var userIdOf = (identity) => String(identity || "").split(":").pop();
|
|
114
|
+
function splitSentences(text, max = 300) {
|
|
115
|
+
const out = [];
|
|
116
|
+
let cur = "";
|
|
117
|
+
for (const s of String(text).match(/[^.!?]+[.!?]+["')\]]*\s*|[^.!?]+$/g) || []) {
|
|
118
|
+
if (cur && (cur + s).length > max) {
|
|
119
|
+
out.push(cur.trim());
|
|
120
|
+
cur = "";
|
|
121
|
+
}
|
|
122
|
+
cur += s;
|
|
123
|
+
}
|
|
124
|
+
if (cur.trim())
|
|
125
|
+
out.push(cur.trim());
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function parseBrain(out) {
|
|
129
|
+
const s = String(out || "");
|
|
130
|
+
const i = s.search(/^\s*SAY:/m);
|
|
131
|
+
return i < 0 ? "" : s.slice(i).replace(/^\s*SAY:\s*/, "").trim();
|
|
132
|
+
}
|
|
133
|
+
function wavSamples(bytes) {
|
|
134
|
+
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
135
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
136
|
+
let o = 12;
|
|
137
|
+
while (o + 8 <= b.length) {
|
|
138
|
+
const id = String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]);
|
|
139
|
+
let size = v.getUint32(o + 4, true);
|
|
140
|
+
if (id === "data") {
|
|
141
|
+
if (!size || size === 4294967295 || o + 8 + size > b.length)
|
|
142
|
+
size = b.length - o - 8;
|
|
143
|
+
const n = Math.floor(size / 2);
|
|
144
|
+
const out = new Int16Array(n);
|
|
145
|
+
for (let i = 0;i < n; i++)
|
|
146
|
+
out[i] = v.getInt16(o + 8 + i * 2, true);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
o += 8 + size + size % 2;
|
|
150
|
+
}
|
|
151
|
+
return new Int16Array(0);
|
|
152
|
+
}
|
|
153
|
+
var rmsOf = (int16) => {
|
|
154
|
+
let s = 0;
|
|
155
|
+
for (let i = 0;i < int16.length; i++)
|
|
156
|
+
s += (int16[i] / 32768) ** 2;
|
|
157
|
+
return Math.sqrt(s / Math.max(1, int16.length));
|
|
158
|
+
};
|
|
159
|
+
function askBrain(cmd, input, { timeoutMs = 90000, env = process.env } = {}) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const child = spawn(cmd, { shell: true, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
162
|
+
let out = "";
|
|
163
|
+
let err = "";
|
|
164
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
165
|
+
child.stdout.on("data", (d) => out += d);
|
|
166
|
+
child.stderr.on("data", (d) => err += d);
|
|
167
|
+
child.on("error", (e) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve({ say: "", error: e.message });
|
|
170
|
+
});
|
|
171
|
+
child.on("close", (code) => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
resolve({ say: parseBrain(out), error: code ? `brain exited ${code}: ${err.trim().slice(-200)}` : "" });
|
|
174
|
+
});
|
|
175
|
+
child.stdin.on("error", () => {});
|
|
176
|
+
child.stdin.end(JSON.stringify(input));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class CallSession {
|
|
181
|
+
constructor({ client, rtc, conversation, call, brain, voice, tts, post, log, isHuman }) {
|
|
182
|
+
Object.assign(this, { client, rtc, conversation, call, brain, voice, ttsUrl: tts, post, log, isHuman });
|
|
183
|
+
this.transcript = [];
|
|
184
|
+
this.queue = Promise.resolve();
|
|
185
|
+
this.speakToken = 0;
|
|
186
|
+
this.speaking = false;
|
|
187
|
+
this.closed = false;
|
|
188
|
+
this.started = Date.now();
|
|
189
|
+
this.lastHuman = Date.now();
|
|
190
|
+
}
|
|
191
|
+
get id() {
|
|
192
|
+
return this.conversation.id;
|
|
193
|
+
}
|
|
194
|
+
async join({ greet = true } = {}) {
|
|
195
|
+
const { Room, RoomEvent, AudioSource, LocalAudioTrack, TrackPublishOptions, TrackSource, TrackKind } = this.rtc;
|
|
196
|
+
const { token, url } = await this.client.request("/api/calls/token", { method: "POST", body: { conversationId: this.id } });
|
|
197
|
+
const room = new Room;
|
|
198
|
+
this.room = room;
|
|
199
|
+
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
|
200
|
+
if (track.kind === TrackKind.KIND_AUDIO && this.isHuman(userIdOf(participant.identity)))
|
|
201
|
+
this.listen(track, participant);
|
|
202
|
+
});
|
|
203
|
+
room.on(RoomEvent.Disconnected, () => this.close("disconnected"));
|
|
204
|
+
await room.connect(url, token, { autoSubscribe: true, e2ee: e2eeOptions(this.call.key) });
|
|
205
|
+
this.source = new AudioSource(SPEAK_RATE, 1);
|
|
206
|
+
const track = LocalAudioTrack.createAudioTrack(`${this.client.me?.username || "agent"}-voice`, this.source);
|
|
207
|
+
const opts = new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE, dtx: false, red: true, audioEncoding: { maxBitrate: 64000n } });
|
|
208
|
+
await room.localParticipant.publishTrack(track, opts);
|
|
209
|
+
this.log(`joined the call in ${this.conversation.title}`);
|
|
210
|
+
this.watchdog = setInterval(() => this.checkAlone(), 5000);
|
|
211
|
+
if (greet)
|
|
212
|
+
this.say(`${this.client.me?.display_name || this.client.me?.username || "Agent"} here.`);
|
|
213
|
+
}
|
|
214
|
+
humansPresent() {
|
|
215
|
+
return [...this.room?.remoteParticipants?.values() ?? []].some((p) => this.isHuman(userIdOf(p.identity)));
|
|
216
|
+
}
|
|
217
|
+
checkAlone() {
|
|
218
|
+
if (this.humansPresent())
|
|
219
|
+
this.lastHuman = Date.now();
|
|
220
|
+
if (Date.now() - this.lastHuman > ALONE_LEAVE_MS)
|
|
221
|
+
return this.close("everyone left");
|
|
222
|
+
if (Date.now() - this.started > MAX_CALL_MS)
|
|
223
|
+
return this.close("call too long");
|
|
224
|
+
}
|
|
225
|
+
async listen(track, participant) {
|
|
226
|
+
const { AudioStream } = this.rtc;
|
|
227
|
+
const name = participant.name || "someone";
|
|
228
|
+
const speakerId = userIdOf(participant.identity);
|
|
229
|
+
const stream = new AudioStream(track, { sampleRate: RATE, numChannels: 1 });
|
|
230
|
+
let ep = createEndpointer({ stepMs: 10, silenceMs: 900, noSpeechMs: Infinity, maxMs: 30000 });
|
|
231
|
+
let frames = [];
|
|
232
|
+
try {
|
|
233
|
+
for await (const frame of stream) {
|
|
234
|
+
if (this.closed)
|
|
235
|
+
break;
|
|
236
|
+
const pcm = Int16Array.from(frame.data);
|
|
237
|
+
frames.push(pcm);
|
|
238
|
+
const ev = ep.push(rmsOf(pcm));
|
|
239
|
+
if (!ep.spoke && frames.length > 50)
|
|
240
|
+
frames = frames.slice(-50);
|
|
241
|
+
if (ev === "speech-start")
|
|
242
|
+
this.bargeIn();
|
|
243
|
+
if (ev === "end" || ev === "max") {
|
|
244
|
+
const all = new Float32Array(frames.reduce((n, f) => n + f.length, 0));
|
|
245
|
+
let o = 0;
|
|
246
|
+
for (const f of frames)
|
|
247
|
+
for (let i = 0;i < f.length; i++)
|
|
248
|
+
all[o++] = f[i] / 32768;
|
|
249
|
+
frames = [];
|
|
250
|
+
ep = createEndpointer({ stepMs: 10, silenceMs: 900, noSpeechMs: Infinity, maxMs: 30000 });
|
|
251
|
+
this.enqueue(() => this.heard(all, name, speakerId));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (!this.closed)
|
|
256
|
+
this.log(`listening to ${name} stopped: ${e.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
enqueue(job) {
|
|
260
|
+
this.queue = this.queue.then(job).catch((e) => this.log(`error: ${e.message}`));
|
|
261
|
+
return this.queue;
|
|
262
|
+
}
|
|
263
|
+
async heard(samples, name, speakerId) {
|
|
264
|
+
const wav = encodeWav(samples, RATE);
|
|
265
|
+
const res = await this.client.fetch(`${this.client.base}/api/voice/transcribe`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { Authorization: `Bearer ${await this.client.token()}`, "Content-Type": "audio/wav", "X-Voice-Lang": "en", "X-Voice-Hint": encodeURIComponent(this.hints().join(",")) },
|
|
268
|
+
body: wav
|
|
269
|
+
});
|
|
270
|
+
const data = await res.json().catch(() => ({}));
|
|
271
|
+
if (!res.ok)
|
|
272
|
+
throw new Error(`transcribe ${res.status}: ${data.error || ""}`);
|
|
273
|
+
const text = String(data.text || "").trim();
|
|
274
|
+
if (!text)
|
|
275
|
+
return;
|
|
276
|
+
this.log(`${name}: ${text}`);
|
|
277
|
+
this.transcript.push({ who: name, text });
|
|
278
|
+
const { say, error } = await askBrain(this.brain, {
|
|
279
|
+
agent: this.client.me?.username,
|
|
280
|
+
conversationId: this.id,
|
|
281
|
+
title: this.conversation.title,
|
|
282
|
+
speaker: name,
|
|
283
|
+
speakerId,
|
|
284
|
+
text,
|
|
285
|
+
at: new Date().toISOString(),
|
|
286
|
+
transcript: this.transcript.slice(-30)
|
|
287
|
+
});
|
|
288
|
+
if (error)
|
|
289
|
+
this.log(error);
|
|
290
|
+
if (!say)
|
|
291
|
+
return;
|
|
292
|
+
this.transcript.push({ who: `${this.client.me?.username} (you)`, text: say });
|
|
293
|
+
if (this.post)
|
|
294
|
+
this.client.send(this.id, `${SPOKEN_PREFIX} ${say}`).catch((e) => this.log(`post failed: ${e.message}`));
|
|
295
|
+
await this.say(say);
|
|
296
|
+
}
|
|
297
|
+
hints() {
|
|
298
|
+
return [...new Set((this.conversation.participants || []).flatMap((p) => [p.user?.username, p.user?.display_name]).filter(Boolean))];
|
|
299
|
+
}
|
|
300
|
+
async say(text) {
|
|
301
|
+
const clean = speakable(text, 1500);
|
|
302
|
+
if (!clean || !this.source)
|
|
303
|
+
return;
|
|
304
|
+
const token = ++this.speakToken;
|
|
305
|
+
const sentences = splitSentences(clean, 120);
|
|
306
|
+
let next = this.tts(sentences[0]);
|
|
307
|
+
this.speaking = true;
|
|
308
|
+
try {
|
|
309
|
+
for (let i = 0;i < sentences.length; i++) {
|
|
310
|
+
const pcm = await next;
|
|
311
|
+
if (i + 1 < sentences.length)
|
|
312
|
+
next = this.tts(sentences[i + 1]);
|
|
313
|
+
if (token !== this.speakToken || this.closed)
|
|
314
|
+
return;
|
|
315
|
+
const { AudioFrame } = this.rtc;
|
|
316
|
+
const step = SPEAK_RATE / 100;
|
|
317
|
+
for (let o = 0;o + step <= pcm.length; o += step) {
|
|
318
|
+
if (token !== this.speakToken || this.closed)
|
|
319
|
+
return;
|
|
320
|
+
await this.source.captureFrame(new AudioFrame(pcm.slice(o, o + step), SPEAK_RATE, 1, step));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await this.source.waitForPlayout?.();
|
|
324
|
+
} finally {
|
|
325
|
+
if (token === this.speakToken)
|
|
326
|
+
this.speaking = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async tts(sentence) {
|
|
330
|
+
if (this.ttsUrl) {
|
|
331
|
+
try {
|
|
332
|
+
const res = await this.client.fetch(this.ttsUrl, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: { "Content-Type": "application/json" },
|
|
335
|
+
body: JSON.stringify({ text: sentence, voice: this.voice })
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok)
|
|
338
|
+
throw new Error(`HTTP ${res.status}`);
|
|
339
|
+
return wavSamples(new Uint8Array(await res.arrayBuffer()));
|
|
340
|
+
} catch (e) {
|
|
341
|
+
this.log(`--tts failed (${e.message}); using qrypt.chat's voice`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const res = await this.client.fetch(`${this.client.base}/api/voice/speak`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { Authorization: `Bearer ${await this.client.token()}`, "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({ text: sentence, voice: this.voice })
|
|
348
|
+
});
|
|
349
|
+
if (!res.ok)
|
|
350
|
+
throw new Error(`speak ${res.status}: ${(await res.json().catch(() => ({}))).error || ""}`);
|
|
351
|
+
return wavSamples(new Uint8Array(await res.arrayBuffer()));
|
|
352
|
+
}
|
|
353
|
+
bargeIn() {
|
|
354
|
+
if (!this.speaking)
|
|
355
|
+
return;
|
|
356
|
+
this.speakToken++;
|
|
357
|
+
this.speaking = false;
|
|
358
|
+
this.source?.clearQueue();
|
|
359
|
+
}
|
|
360
|
+
async close(why) {
|
|
361
|
+
if (this.closed)
|
|
362
|
+
return;
|
|
363
|
+
this.closed = true;
|
|
364
|
+
clearInterval(this.watchdog);
|
|
365
|
+
this.speakToken++;
|
|
366
|
+
this.log(`left the call in ${this.conversation.title} (${why})`);
|
|
367
|
+
await this.room?.disconnect().catch(() => {});
|
|
368
|
+
this.onClose?.();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function loadRtc() {
|
|
372
|
+
try {
|
|
373
|
+
const name = "@livekit/rtc-node";
|
|
374
|
+
return await import(name);
|
|
375
|
+
} catch {
|
|
376
|
+
throw new Error("qc agent call needs @livekit/rtc-node: npm i -g @livekit/rtc-node (or install it next to qc).");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function latestCall(client, conversationId) {
|
|
380
|
+
const { messages = [] } = await client.request("/api/messages/load", { method: "POST", body: { conversationId, limit: 20 } });
|
|
381
|
+
for (const m of [...messages].reverse()) {
|
|
382
|
+
if (m.message_type !== CALL_TYPE || !m.encrypted_content)
|
|
383
|
+
continue;
|
|
384
|
+
const call = parseCall(await client.ring.decrypt(m.encrypted_content));
|
|
385
|
+
if (call)
|
|
386
|
+
return { ...call, messageId: m.id, at: call.at || m.created_at, senderId: m.sender_id };
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
async function runVoiceAgent(client, { brain, voice = "orion", tts, post = false, greet = true, hearAgents = false, log = (s) => process.stderr.write(`qc agent call: ${s}
|
|
391
|
+
`), rtc, signal } = {}) {
|
|
392
|
+
if (!brain)
|
|
393
|
+
throw new Error('Usage: qc agent call --brain "<command>" [--voice orion] [--post] [--no-greet]');
|
|
394
|
+
rtc ??= await loadRtc();
|
|
395
|
+
const sessions = new Map;
|
|
396
|
+
let chats = new Map;
|
|
397
|
+
const loadChats = async () => {
|
|
398
|
+
chats = new Map((await client.conversations()).map((c) => [c.id, c]));
|
|
399
|
+
return chats;
|
|
400
|
+
};
|
|
401
|
+
const agentIds = () => new Set([...chats.values()].flatMap((c) => (c.participants || []).filter((p) => p.user?.account_type === "agent").map((p) => p.user_id ?? p.user?.id)));
|
|
402
|
+
const isHuman = (userId) => userId !== client.me?.id && (hearAgents || !agentIds().has(userId));
|
|
403
|
+
const join = async (conversation, call) => {
|
|
404
|
+
if (sessions.has(conversation.id) || Date.now() - Date.parse(call.at) > MAX_CALL_MS)
|
|
405
|
+
return;
|
|
406
|
+
const session = new CallSession({ client, rtc, conversation, call, brain, voice, tts, post, log, isHuman });
|
|
407
|
+
sessions.set(conversation.id, session);
|
|
408
|
+
session.onClose = () => sessions.delete(conversation.id);
|
|
409
|
+
try {
|
|
410
|
+
await session.join({ greet });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
log(`could not join the call in ${conversation.title}: ${e.message}`);
|
|
413
|
+
await session.close("join failed");
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
await loadChats();
|
|
417
|
+
for (const c of chats.values()) {
|
|
418
|
+
const call = await latestCall(client, c.id).catch(() => null);
|
|
419
|
+
if (call && Date.now() - Date.parse(call.at) < JOIN_RECENT_MS)
|
|
420
|
+
await join(c, call);
|
|
421
|
+
}
|
|
422
|
+
log(`listening for calls in ${chats.size} chat(s)`);
|
|
423
|
+
await client.events(async ({ type, data }) => {
|
|
424
|
+
const m = data?.message;
|
|
425
|
+
if (type !== "NEW_MESSAGE" || !m?.conversation_id)
|
|
426
|
+
return;
|
|
427
|
+
if (!chats.has(m.conversation_id))
|
|
428
|
+
await loadChats();
|
|
429
|
+
const conversation = chats.get(m.conversation_id);
|
|
430
|
+
if (!conversation)
|
|
431
|
+
return;
|
|
432
|
+
if (m.message_type === CALL_TYPE) {
|
|
433
|
+
const call = await latestCall(client, conversation.id);
|
|
434
|
+
if (call && call.messageId === m.id) {
|
|
435
|
+
await sessions.get(conversation.id)?.close("a new call started");
|
|
436
|
+
await join(conversation, call);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const session = sessions.get(conversation.id);
|
|
441
|
+
if (session && m.sender_id === client.me?.id && (m.message_type ?? "text") === "text") {
|
|
442
|
+
const { messages = [] } = await client.request("/api/messages/load", { method: "POST", body: { conversationId: conversation.id, limit: 5 } });
|
|
443
|
+
const raw = messages.find((x) => x.id === m.id);
|
|
444
|
+
const text = raw?.encrypted_content ? await client.ring.decrypt(raw.encrypted_content) : "";
|
|
445
|
+
if (text && !text.startsWith(SPOKEN_PREFIX) && !text.startsWith("\uD83D\uDDE3️"))
|
|
446
|
+
session.enqueue(() => session.say(text));
|
|
447
|
+
}
|
|
448
|
+
}, { signal, onStatus: (s) => log(`events ${s}`) });
|
|
449
|
+
for (const s of sessions.values())
|
|
450
|
+
await s.close("stopped");
|
|
451
|
+
}
|
|
452
|
+
export {
|
|
453
|
+
runVoiceAgent
|
|
454
|
+
};
|
|
@@ -1251,6 +1251,10 @@ Usage:
|
|
|
1251
1251
|
qc open the chat client (signs you in first if needed)
|
|
1252
1252
|
qc fonts install|status|remove our OpenEmoji colour font as the terminal's emoji font
|
|
1253
1253
|
qc agent join <link> join as an AI agent from an invite (makes its own keys) [--name --username]
|
|
1254
|
+
qc agent call --brain "<cmd>" [--voice orion] [--tts <url>] [--post] [--no-greet]
|
|
1255
|
+
as an agent, join voice calls in your chats: hear people, ask <cmd> what
|
|
1256
|
+
to say (JSON on stdin; it prints PASS or SAY: text), speak it
|
|
1257
|
+
(needs: npm i -g @livekit/rtc-node)
|
|
1254
1258
|
qc login [--oob] sign in through your browser; --oob to paste a code (SSH)
|
|
1255
1259
|
qc logout forget this terminal's session and keys
|
|
1256
1260
|
qc update [--check] update qc to the latest release (--check only says whether there is one)
|
|
@@ -1292,7 +1296,7 @@ function parseArgs2(argv) {
|
|
|
1292
1296
|
const [k, v] = a.slice(2).split("=", 2);
|
|
1293
1297
|
if (v !== undefined)
|
|
1294
1298
|
args.flags[k] = v;
|
|
1295
|
-
else if (["url", "n", "limit", "reply", "emoji", "pronouns", "website", "bio", "name", "username"].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--"))
|
|
1299
|
+
else if (["url", "n", "limit", "reply", "emoji", "pronouns", "website", "bio", "name", "username", "brain", "voice", "tts"].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--"))
|
|
1296
1300
|
args.flags[k] = argv[++i];
|
|
1297
1301
|
else
|
|
1298
1302
|
args.flags[k] = true;
|
|
@@ -1364,8 +1368,19 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1364
1368
|
return runTui(c, { initialChat: rest[0] });
|
|
1365
1369
|
}
|
|
1366
1370
|
case "agent": {
|
|
1371
|
+
if (rest[0] === "call") {
|
|
1372
|
+
const { runVoiceAgent } = await import("./chunk-997mmke4.js");
|
|
1373
|
+
return runVoiceAgent(await client(flags), {
|
|
1374
|
+
brain: typeof flags.brain === "string" ? flags.brain : undefined,
|
|
1375
|
+
voice: typeof flags.voice === "string" ? flags.voice : undefined,
|
|
1376
|
+
tts: typeof flags.tts === "string" ? flags.tts : undefined,
|
|
1377
|
+
post: Boolean(flags.post),
|
|
1378
|
+
greet: !flags["no-greet"],
|
|
1379
|
+
hearAgents: Boolean(flags["hear-agents"])
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1367
1382
|
if (rest[0] !== "join" || !rest[1])
|
|
1368
|
-
throw new Error(
|
|
1383
|
+
throw new Error('Usage: qc agent join <invite link> [--name NAME] [--username NAME] | qc agent call --brain "<cmd>"');
|
|
1369
1384
|
const { session, conversationId, operator } = await joinAsAgent(rest[1], {
|
|
1370
1385
|
name: typeof flags.name === "string" ? flags.name : undefined,
|
|
1371
1386
|
username: typeof flags.username === "string" ? flags.username : undefined
|
|
@@ -1623,4 +1638,4 @@ async function voiceCommand(argv, { out, json }) {
|
|
|
1623
1638
|
}
|
|
1624
1639
|
}
|
|
1625
1640
|
|
|
1626
|
-
export { exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
|
1641
|
+
export { CALL_TYPE, parseCall, exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
package/dist/commands.js
CHANGED
package/dist/mcp.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,5 +38,13 @@
|
|
|
38
38
|
"directory": "packages/qryptchat"
|
|
39
39
|
},
|
|
40
40
|
"license": "MIT",
|
|
41
|
-
"author": "Profullstack, Inc."
|
|
41
|
+
"author": "Profullstack, Inc.",
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@livekit/rtc-node": ">=1.2.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@livekit/rtc-node": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
}
|