@profullstack/qryptchat 0.8.0 → 0.9.0

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 CHANGED
@@ -3,10 +3,15 @@
3
3
  [qrypt.chat](https://qrypt.chat) in your terminal: a full-screen, end-to-end encrypted chat client, a scriptable CLI and an MCP server, all in one command.
4
4
 
5
5
  ```sh
6
- npm install -g @profullstack/qryptchat # or: bun add -g @profullstack/qryptchat
6
+ curl -fsSL https://qrypt.chat/install.sh | sh
7
7
  qc
8
8
  ```
9
9
 
10
+ The installer puts `qc` (and its alias `qryptchat`) under `~/.local`, never asks for sudo, and
11
+ fetches a private official Node if this machine has no Node 22+. Run it again to update; remove
12
+ with `sh ~/.local/share/qryptchat/uninstall.sh`. Or install it with a package manager:
13
+ `npm install -g @profullstack/qryptchat` (or `bun add -g @profullstack/qryptchat`).
14
+
10
15
  The first run signs you in through your browser, then opens your chats.
11
16
 
12
17
  ## The client
@@ -235,4 +235,33 @@ function parseSlashCommand(text, names) {
235
235
  return { name: m[1], args: (m[2] ?? "").trim() };
236
236
  }
237
237
 
238
- export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify, PAIRUX_URL, newVoiceRoomLink, voiceMessage, parseSlashCommand };
238
+ // src/lib/chat/irc.js
239
+ var nameOf = (p) => {
240
+ const u = p.user || p;
241
+ return u.username || u.display_name || "unknown";
242
+ };
243
+ function namesLines(conversation, title = conversation?.name || "this chat") {
244
+ const people = [...conversation?.participants || []].sort((a, b) => (b.role === "admin") - (a.role === "admin") || nameOf(a).localeCompare(nameOf(b)));
245
+ const names = people.map((p) => {
246
+ const u = p.user || p;
247
+ const op = p.role === "admin" ? "@" : p.role === "moderator" ? "+" : "";
248
+ const agent = u.account_type === "agent" ? " \uD83E\uDD16" : "";
249
+ return `${op}${nameOf(p)}${agent}`;
250
+ });
251
+ const n = names.length;
252
+ return [`${title}: ${n} ${n === 1 ? "person" : "people"}`, names.join(" ") || "(nobody)"];
253
+ }
254
+ function listLines(conversations, titleOf = (c) => c.name || "Chat") {
255
+ const live = (conversations || []).filter((c) => !c.is_archived);
256
+ const rank = { group: 0, direct: 1, note_to_self: 2 };
257
+ const sorted = [...live].sort((a, b) => (rank[a.type] ?? 1) - (rank[b.type] ?? 1) || titleOf(a).localeCompare(titleOf(b)));
258
+ const lines = sorted.map((c) => {
259
+ const n = c.participants?.length ?? 0;
260
+ const kind = c.type === "group" ? "group" : c.type === "note_to_self" ? "note" : "direct";
261
+ return `${c.type === "group" ? "#" : " "}${titleOf(c)} ${n} ${n === 1 ? "person" : "people"} · ${kind}`;
262
+ });
263
+ const archived = (conversations || []).length - live.length;
264
+ return [`${live.length} ${live.length === 1 ? "chat" : "chats"}${archived ? ` (+${archived} archived)` : ""}`, ...lines];
265
+ }
266
+
267
+ export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify, PAIRUX_URL, newVoiceRoomLink, voiceMessage, parseSlashCommand, namesLines, listLines };
@@ -9,8 +9,9 @@ import {
9
9
  notify,
10
10
  PAIRUX_URL,
11
11
  newVoiceRoomLink,
12
- voiceMessage
13
- } from "./chunk-zc4n789c.js";
12
+ voiceMessage,
13
+ namesLines
14
+ } from "./chunk-8gpysrd6.js";
14
15
 
15
16
  // src/cli/crypto.js
16
17
  import { MlKem1024 as MlKem10242 } from "mlkem";
@@ -1250,6 +1251,10 @@ Usage:
1250
1251
  qc open the chat client (signs you in first if needed)
1251
1252
  qc fonts install|status|remove our OpenEmoji colour font as the terminal's emoji font
1252
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] [--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)
1253
1258
  qc login [--oob] sign in through your browser; --oob to paste a code (SSH)
1254
1259
  qc logout forget this terminal's session and keys
1255
1260
  qc update [--check] update qc to the latest release (--check only says whether there is one)
@@ -1257,7 +1262,8 @@ Usage:
1257
1262
  qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1258
1263
  show or set your public profile (an empty value clears it);
1259
1264
  --openprofile prints it as OpenProfile.md
1260
- qc chats [--archived] list your chats (archived ones only with --archived)
1265
+ qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same
1266
+ qc names <chat> who is in a chat (@ = admin, \uD83E\uDD16 = agent), like IRC /names
1261
1267
  qc archive <chat> archive a chat for you (qc unarchive <chat> brings it back)
1262
1268
  qc voice [--send <chat>] a link to a fresh end-to-end encrypted PairUX voice room (no account
1263
1269
  needed to join); qc voice login|join|... runs the pairux CLI (qc voice help)
@@ -1290,7 +1296,7 @@ function parseArgs2(argv) {
1290
1296
  const [k, v] = a.slice(2).split("=", 2);
1291
1297
  if (v !== undefined)
1292
1298
  args.flags[k] = v;
1293
- 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"].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--"))
1294
1300
  args.flags[k] = argv[++i];
1295
1301
  else
1296
1302
  args.flags[k] = true;
@@ -1362,8 +1368,18 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1362
1368
  return runTui(c, { initialChat: rest[0] });
1363
1369
  }
1364
1370
  case "agent": {
1371
+ if (rest[0] === "call") {
1372
+ const { runVoiceAgent } = await import("./chunk-s094t64g.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
+ post: Boolean(flags.post),
1377
+ greet: !flags["no-greet"],
1378
+ hearAgents: Boolean(flags["hear-agents"])
1379
+ });
1380
+ }
1365
1381
  if (rest[0] !== "join" || !rest[1])
1366
- throw new Error("Usage: qc agent join <invite link> [--name NAME] [--username NAME]");
1382
+ throw new Error('Usage: qc agent join <invite link> [--name NAME] [--username NAME] | qc agent call --brain "<cmd>"');
1367
1383
  const { session, conversationId, operator } = await joinAsAgent(rest[1], {
1368
1384
  name: typeof flags.name === "string" ? flags.name : undefined,
1369
1385
  username: typeof flags.username === "string" ? flags.username : undefined
@@ -1446,6 +1462,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1446
1462
  return;
1447
1463
  }
1448
1464
  case "chats":
1465
+ case "list":
1449
1466
  case "ls": {
1450
1467
  const all = await (await client(flags)).conversations();
1451
1468
  const chats = all.filter((c) => !!c.is_archived === !!flags.archived);
@@ -1466,6 +1483,17 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1466
1483
  return json({ id: chat.id, archived: cmd === "archive" });
1467
1484
  return out(ok(cmd === "archive" ? `Archived ${chat.title}. qc chats --archived lists it; qc unarchive brings it back.` : `${chat.title} is back in your chats.`));
1468
1485
  }
1486
+ case "names": {
1487
+ if (!rest[0])
1488
+ throw new Error("Usage: qc names <chat>");
1489
+ const chat = findChat2(await (await client(flags)).conversations(), rest[0]);
1490
+ if (flags.json) {
1491
+ return json((chat.participants || []).map((p) => ({ id: p.user_id, username: p.user?.username, display_name: p.user?.display_name, role: p.role, agent: p.user?.account_type === "agent" })));
1492
+ }
1493
+ for (const line of namesLines(chat, chat.title))
1494
+ out(line);
1495
+ return;
1496
+ }
1469
1497
  case "read": {
1470
1498
  if (!rest[0])
1471
1499
  throw new Error("Usage: qc read <chat> [-n 20]");
@@ -1609,4 +1637,4 @@ async function voiceCommand(argv, { out, json }) {
1609
1637
  }
1610
1638
  }
1611
1639
 
1612
- export { exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
1640
+ export { CALL_TYPE, parseCall, exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
@@ -0,0 +1,440 @@
1
+ import {
2
+ CALL_TYPE,
3
+ parseCall
4
+ } from "./chunk-e39k2cyf.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.end(JSON.stringify(input));
176
+ });
177
+ }
178
+
179
+ class CallSession {
180
+ constructor({ client, rtc, conversation, call, brain, voice, post, log, isHuman }) {
181
+ Object.assign(this, { client, rtc, conversation, call, brain, voice, post, log, isHuman });
182
+ this.transcript = [];
183
+ this.queue = Promise.resolve();
184
+ this.speakToken = 0;
185
+ this.speaking = false;
186
+ this.closed = false;
187
+ this.started = Date.now();
188
+ this.lastHuman = Date.now();
189
+ }
190
+ get id() {
191
+ return this.conversation.id;
192
+ }
193
+ async join({ greet = true } = {}) {
194
+ const { Room, RoomEvent, AudioSource, LocalAudioTrack, TrackPublishOptions, TrackSource, TrackKind } = this.rtc;
195
+ const { token, url } = await this.client.request("/api/calls/token", { method: "POST", body: { conversationId: this.id } });
196
+ const room = new Room;
197
+ this.room = room;
198
+ room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
199
+ if (track.kind === TrackKind.KIND_AUDIO && this.isHuman(userIdOf(participant.identity)))
200
+ this.listen(track, participant);
201
+ });
202
+ room.on(RoomEvent.Disconnected, () => this.close("disconnected"));
203
+ await room.connect(url, token, { autoSubscribe: true, e2ee: e2eeOptions(this.call.key) });
204
+ this.source = new AudioSource(SPEAK_RATE, 1);
205
+ const track = LocalAudioTrack.createAudioTrack(`${this.client.me?.username || "agent"}-voice`, this.source);
206
+ const opts = new TrackPublishOptions;
207
+ opts.source = TrackSource.SOURCE_MICROPHONE;
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
+ const res = await this.client.fetch(`${this.client.base}/api/voice/speak`, {
331
+ method: "POST",
332
+ headers: { Authorization: `Bearer ${await this.client.token()}`, "Content-Type": "application/json" },
333
+ body: JSON.stringify({ text: sentence, voice: this.voice })
334
+ });
335
+ if (!res.ok)
336
+ throw new Error(`speak ${res.status}: ${(await res.json().catch(() => ({}))).error || ""}`);
337
+ return wavSamples(new Uint8Array(await res.arrayBuffer()));
338
+ }
339
+ bargeIn() {
340
+ if (!this.speaking)
341
+ return;
342
+ this.speakToken++;
343
+ this.speaking = false;
344
+ this.source?.clearQueue();
345
+ }
346
+ async close(why) {
347
+ if (this.closed)
348
+ return;
349
+ this.closed = true;
350
+ clearInterval(this.watchdog);
351
+ this.speakToken++;
352
+ this.log(`left the call in ${this.conversation.title} (${why})`);
353
+ await this.room?.disconnect().catch(() => {});
354
+ this.onClose?.();
355
+ }
356
+ }
357
+ async function loadRtc() {
358
+ try {
359
+ const name = "@livekit/rtc-node";
360
+ return await import(name);
361
+ } catch {
362
+ throw new Error("qc agent call needs @livekit/rtc-node: npm i -g @livekit/rtc-node (or install it next to qc).");
363
+ }
364
+ }
365
+ async function latestCall(client, conversationId) {
366
+ const { messages = [] } = await client.request("/api/messages/load", { method: "POST", body: { conversationId, limit: 20 } });
367
+ for (const m of [...messages].reverse()) {
368
+ if (m.message_type !== CALL_TYPE || !m.encrypted_content)
369
+ continue;
370
+ const call = parseCall(await client.ring.decrypt(m.encrypted_content));
371
+ if (call)
372
+ return { ...call, messageId: m.id, at: call.at || m.created_at, senderId: m.sender_id };
373
+ }
374
+ return null;
375
+ }
376
+ async function runVoiceAgent(client, { brain, voice = "orion", post = false, greet = true, hearAgents = false, log = (s) => process.stderr.write(`qc agent call: ${s}
377
+ `), rtc, signal } = {}) {
378
+ if (!brain)
379
+ throw new Error('Usage: qc agent call --brain "<command>" [--voice orion] [--post] [--no-greet]');
380
+ rtc ??= await loadRtc();
381
+ const sessions = new Map;
382
+ let chats = new Map;
383
+ const loadChats = async () => {
384
+ chats = new Map((await client.conversations()).map((c) => [c.id, c]));
385
+ return chats;
386
+ };
387
+ 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)));
388
+ const isHuman = (userId) => userId !== client.me?.id && (hearAgents || !agentIds().has(userId));
389
+ const join = async (conversation, call) => {
390
+ if (sessions.has(conversation.id) || Date.now() - Date.parse(call.at) > MAX_CALL_MS)
391
+ return;
392
+ const session = new CallSession({ client, rtc, conversation, call, brain, voice, post, log, isHuman });
393
+ sessions.set(conversation.id, session);
394
+ session.onClose = () => sessions.delete(conversation.id);
395
+ try {
396
+ await session.join({ greet });
397
+ } catch (e) {
398
+ log(`could not join the call in ${conversation.title}: ${e.message}`);
399
+ await session.close("join failed");
400
+ }
401
+ };
402
+ await loadChats();
403
+ for (const c of chats.values()) {
404
+ const call = await latestCall(client, c.id).catch(() => null);
405
+ if (call && Date.now() - Date.parse(call.at) < JOIN_RECENT_MS)
406
+ await join(c, call);
407
+ }
408
+ log(`listening for calls in ${chats.size} chat(s)`);
409
+ await client.events(async ({ type, data }) => {
410
+ const m = data?.message;
411
+ if (type !== "NEW_MESSAGE" || !m?.conversation_id)
412
+ return;
413
+ if (!chats.has(m.conversation_id))
414
+ await loadChats();
415
+ const conversation = chats.get(m.conversation_id);
416
+ if (!conversation)
417
+ return;
418
+ if (m.message_type === CALL_TYPE) {
419
+ const call = await latestCall(client, conversation.id);
420
+ if (call && call.messageId === m.id) {
421
+ await sessions.get(conversation.id)?.close("a new call started");
422
+ await join(conversation, call);
423
+ }
424
+ return;
425
+ }
426
+ const session = sessions.get(conversation.id);
427
+ if (session && m.sender_id === client.me?.id && (m.message_type ?? "text") === "text") {
428
+ const { messages = [] } = await client.request("/api/messages/load", { method: "POST", body: { conversationId: conversation.id, limit: 5 } });
429
+ const raw = messages.find((x) => x.id === m.id);
430
+ const text = raw?.encrypted_content ? await client.ring.decrypt(raw.encrypted_content) : "";
431
+ if (text && !text.startsWith(SPOKEN_PREFIX) && !text.startsWith("\uD83D\uDDE3️"))
432
+ session.enqueue(() => session.say(text));
433
+ }
434
+ }, { signal, onStatus: (s) => log(`events ${s}`) });
435
+ for (const s of sessions.values())
436
+ await s.close("stopped");
437
+ }
438
+ export {
439
+ runVoiceAgent
440
+ };
package/dist/commands.js CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  parseArgs2,
6
6
  findChat2,
7
7
  main2
8
- } from "./chunk-nrv1k5q2.js";
9
- import"./chunk-zc4n789c.js";
8
+ } from "./chunk-e39k2cyf.js";
9
+ import"./chunk-8gpysrd6.js";
10
10
  export {
11
11
  HELP2 as HELP,
12
12
  errorLine2 as errorLine,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
- import"./chunk-zc4n789c.js";
1
+ import"./chunk-8gpysrd6.js";
2
2
  import {
3
3
  findChat2
4
- } from "./chunk-nrv1k5q2.js";
4
+ } from "./chunk-e39k2cyf.js";
5
5
 
6
6
  // src/cli/mcp.js
7
7
  import { createInterface } from "node:readline";
package/dist/tui.js CHANGED
@@ -4,8 +4,10 @@ import {
4
4
  PAIRUX_URL,
5
5
  newVoiceRoomLink,
6
6
  voiceMessage,
7
- parseSlashCommand
8
- } from "./chunk-zc4n789c.js";
7
+ parseSlashCommand,
8
+ namesLines,
9
+ listLines
10
+ } from "./chunk-8gpysrd6.js";
9
11
 
10
12
  // src/cli/tui.js
11
13
  import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
@@ -48,7 +50,7 @@ function chatsWithNews(prev, next) {
48
50
  const seen = new Map(prev.map((c) => [c.id, c.updated_at ?? ""]));
49
51
  return next.filter((c) => !seen.has(c.id) || (c.updated_at ?? "") > seen.get(c.id)).map((c) => c.id);
50
52
  }
51
- var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived"];
53
+ var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived", "names", "list"];
52
54
  var pad2 = (n) => String(n).padStart(2, "0");
53
55
  var timeOf = (iso) => {
54
56
  const d = new Date(iso);
@@ -261,7 +263,8 @@ async function runTui(client, { initialChat } = {}) {
261
263
  async function loadMessages(id) {
262
264
  try {
263
265
  const { messages } = await client.messages(id, { limit: 100 });
264
- state.messages[id] = messages;
266
+ const local = (state.messages[id] || []).filter((m) => m.local);
267
+ state.messages[id] = local.length ? [...messages, ...local].sort((a, b) => String(a.at).localeCompare(String(b.at))) : messages;
265
268
  redraw();
266
269
  } catch (err) {
267
270
  note(`Could not load messages: ${err.message}`);
@@ -363,7 +366,14 @@ async function runTui(client, { initialChat } = {}) {
363
366
  toggleArchivedView();
364
367
  else if (!active)
365
368
  note("Pick a chat first");
366
- else if (cmd.name === "voice") {
369
+ else if (cmd.name === "names" || cmd.name === "list") {
370
+ const lines = cmd.name === "names" ? namesLines(active, active.title) : listLines(state.conversations, (c) => c.title);
371
+ const at = new Date().toISOString();
372
+ const local = { id: `local-${Date.now()}`, senderId: "qc", sender: `qc /${cmd.name}`, local: true, text: lines.join(`
373
+ `), at };
374
+ state.messages[active.id] = [...state.messages[active.id] || [], local];
375
+ state.scrollBack = 0;
376
+ } else if (cmd.name === "voice") {
367
377
  const url = newVoiceRoomLink(process.env.PAIRUX_API_URL || PAIRUX_URL);
368
378
  state.field = { value: voiceMessage(url), cursor: 0 };
369
379
  send({ raw: true }).then(() => note("Voice room posted; open the link in a browser to join", { sticky: false }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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
  }