@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 +6 -1
- package/dist/{chunk-zc4n789c.js → chunk-8gpysrd6.js} +30 -1
- package/dist/{chunk-nrv1k5q2.js → chunk-e39k2cyf.js} +34 -6
- package/dist/chunk-s094t64g.js +440 -0
- package/dist/commands.js +2 -2
- package/dist/mcp.js +2 -2
- package/dist/tui.js +15 -5
- package/package.json +10 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
package/dist/mcp.js
CHANGED
package/dist/tui.js
CHANGED
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
PAIRUX_URL,
|
|
5
5
|
newVoiceRoomLink,
|
|
6
6
|
voiceMessage,
|
|
7
|
-
parseSlashCommand
|
|
8
|
-
|
|
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]
|
|
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 === "
|
|
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.
|
|
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
|
}
|