@gravity-rail/cli 0.5.1 → 0.7.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 +28 -0
- package/dist/account-verify-phone-4LRJZT5I.js +36 -0
- package/dist/account-verify-phone-4LRJZT5I.js.map +1 -0
- package/dist/chunk-3EO4COHB.js +14202 -0
- package/dist/chunk-3EO4COHB.js.map +1 -0
- package/dist/chunk-QVG4L6BE.js +2199 -0
- package/dist/chunk-QVG4L6BE.js.map +1 -0
- package/dist/concierge-ZOG7YFWQ.js +6 -0
- package/dist/concierge-ZOG7YFWQ.js.map +1 -0
- package/dist/gr.js +7183 -22914
- package/dist/gr.js.map +1 -1
- package/dist/lib.d.ts +52 -11
- package/dist/lib.js +599 -11
- package/dist/lib.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,2199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { AGUIClient, clearScreen, printMessage, requireAuth, originateConciergeCall, originateConciergeSms, getConciergeChatMessages, getConciergeChat, createConciergeChat, listConciergeChats, selectFromList, formatTimestamp, getTextDelta, getErrorMessage } from './chunk-3EO4COHB.js';
|
|
3
|
+
import { CLIError } from './chunk-XRBVAFCB.js';
|
|
4
|
+
import path3 from 'path';
|
|
5
|
+
import * as readline from 'readline';
|
|
6
|
+
import readline__default from 'readline';
|
|
7
|
+
import fs, { createWriteStream } from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { EventEmitter } from 'events';
|
|
10
|
+
import { execSync, spawn, spawnSync } from 'child_process';
|
|
11
|
+
|
|
12
|
+
function buildCliConciergeAppContext() {
|
|
13
|
+
const shell = process.env.SHELL ? path3.basename(process.env.SHELL) : void 0;
|
|
14
|
+
return {
|
|
15
|
+
clientSurface: "cli",
|
|
16
|
+
channel: "text",
|
|
17
|
+
cliContext: {
|
|
18
|
+
shell,
|
|
19
|
+
os: process.platform,
|
|
20
|
+
cwdBasename: path3.basename(process.cwd())
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
var DEFAULT_MAX_READ_BYTES = 5e4;
|
|
25
|
+
var ABSOLUTE_MAX_READ_BYTES = 1e6;
|
|
26
|
+
function resolveSafePath(root, relativePath) {
|
|
27
|
+
const rootResolved = path3.resolve(root);
|
|
28
|
+
const target = path3.resolve(rootResolved, relativePath || ".");
|
|
29
|
+
if (target !== rootResolved && !target.startsWith(rootResolved + path3.sep)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
let cursor = rootResolved;
|
|
33
|
+
const rest = path3.relative(rootResolved, target);
|
|
34
|
+
if (rest && rest !== ".") {
|
|
35
|
+
for (const segment of rest.split(path3.sep)) {
|
|
36
|
+
cursor = path3.join(cursor, segment);
|
|
37
|
+
try {
|
|
38
|
+
const lstat = fs.lstatSync(cursor);
|
|
39
|
+
if (lstat.isSymbolicLink()) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
function executeCliLocalFileAction(root, interrupt, opts) {
|
|
50
|
+
const safe = resolveSafePath(root, interrupt.path);
|
|
51
|
+
if (!safe) {
|
|
52
|
+
return { ok: false, error: "Path escapes trusted directory" };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
switch (interrupt.action) {
|
|
56
|
+
case "read_file": {
|
|
57
|
+
if (!fs.existsSync(safe)) {
|
|
58
|
+
return { ok: false, error: "File not found" };
|
|
59
|
+
}
|
|
60
|
+
const stat = fs.lstatSync(safe);
|
|
61
|
+
if (stat.isSymbolicLink()) {
|
|
62
|
+
return { ok: false, error: "Path escapes trusted directory" };
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isFile()) {
|
|
65
|
+
return { ok: false, error: "Not a file" };
|
|
66
|
+
}
|
|
67
|
+
const requested = Number.isFinite(interrupt.max_bytes) ? Math.max(1, Math.min(Number(interrupt.max_bytes), ABSOLUTE_MAX_READ_BYTES)) : DEFAULT_MAX_READ_BYTES;
|
|
68
|
+
if (stat.size > requested) {
|
|
69
|
+
const buf = fs.readFileSync(safe, { encoding: "utf8" });
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
content: buf.slice(0, requested) + `
|
|
73
|
+
|
|
74
|
+
[truncated at ${requested} bytes]`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, content: fs.readFileSync(safe, "utf8") };
|
|
78
|
+
}
|
|
79
|
+
case "list_dir": {
|
|
80
|
+
if (!fs.existsSync(safe)) {
|
|
81
|
+
return { ok: false, error: "Directory not found" };
|
|
82
|
+
}
|
|
83
|
+
const entries = fs.readdirSync(safe, { withFileTypes: true });
|
|
84
|
+
const lines = entries.slice(0, 200).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
85
|
+
if (entries.length > 200) {
|
|
86
|
+
lines.push(`... and ${entries.length - 200} more`);
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, content: lines.join("\n") };
|
|
89
|
+
}
|
|
90
|
+
case "write_file": {
|
|
91
|
+
if (!opts.allowWrite) {
|
|
92
|
+
return { ok: false, error: "Write requires --allow-write" };
|
|
93
|
+
}
|
|
94
|
+
if (interrupt.content === void 0) {
|
|
95
|
+
return { ok: false, error: "Missing content for write_file" };
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(path3.dirname(safe), { recursive: true });
|
|
98
|
+
fs.writeFileSync(safe, interrupt.content, "utf8");
|
|
99
|
+
return { ok: true, content: "written" };
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return { ok: false, error: `Unknown action: ${interrupt.action}` };
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { ok: false, error: sanitizeLocalFileError(e) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function sanitizeLocalFileError(e) {
|
|
109
|
+
if (e && typeof e === "object" && "code" in e) {
|
|
110
|
+
const code = e.code;
|
|
111
|
+
if (code === "ENOENT") return "File or directory not found";
|
|
112
|
+
if (code === "EACCES" || code === "EPERM") return "Permission denied";
|
|
113
|
+
if (code === "EISDIR") return "Not a file";
|
|
114
|
+
if (code === "ENOTDIR") return "Not a directory";
|
|
115
|
+
}
|
|
116
|
+
return "Local file operation failed";
|
|
117
|
+
}
|
|
118
|
+
function isCliLocalFileInterrupt(value) {
|
|
119
|
+
if (typeof value !== "object" || value === null) return false;
|
|
120
|
+
const v = value;
|
|
121
|
+
return v.type === "cli_local_file" && typeof v.action === "string" && typeof v.path === "string";
|
|
122
|
+
}
|
|
123
|
+
var STORE_VERSION = 1;
|
|
124
|
+
function storePath() {
|
|
125
|
+
const base = process.env.XDG_CONFIG_HOME ?? (process.platform === "win32" ? path3.join(process.env.APPDATA ?? os.homedir(), "gravityrail") : path3.join(os.homedir(), ".config", "gravityrail"));
|
|
126
|
+
return path3.join(base, "trusted-dirs.json");
|
|
127
|
+
}
|
|
128
|
+
function loadTrustedDirsStore() {
|
|
129
|
+
const file = storePath();
|
|
130
|
+
try {
|
|
131
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
132
|
+
if (raw?.version === STORE_VERSION && Array.isArray(raw.always)) {
|
|
133
|
+
return { version: STORE_VERSION, always: raw.always.map((p) => path3.resolve(p)) };
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
return { version: STORE_VERSION, always: [] };
|
|
138
|
+
}
|
|
139
|
+
function saveTrustedDirsStore(store) {
|
|
140
|
+
const file = storePath();
|
|
141
|
+
const dir = path3.dirname(file);
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
143
|
+
fs.writeFileSync(file, JSON.stringify(store, null, 2) + "\n", { mode: 384 });
|
|
144
|
+
}
|
|
145
|
+
function isDirectoryTrusted(dirPath, opts) {
|
|
146
|
+
if (opts?.trustCwdFlag) return true;
|
|
147
|
+
const resolved = path3.resolve(dirPath);
|
|
148
|
+
const store = loadTrustedDirsStore();
|
|
149
|
+
return store.always.includes(resolved);
|
|
150
|
+
}
|
|
151
|
+
async function promptTrustInteractive(dirPath) {
|
|
152
|
+
const rl = readline__default.createInterface({ input: process.stdin, output: process.stderr });
|
|
153
|
+
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
`
|
|
156
|
+
Gravity Rail may read files in:
|
|
157
|
+
${dirPath}
|
|
158
|
+
|
|
159
|
+
File contents can influence the assistant. Do you trust the files in this directory?
|
|
160
|
+
[1] Trust once (this session)
|
|
161
|
+
[2] Trust always (remember for this path)
|
|
162
|
+
[3] No \u2014 do not read local files
|
|
163
|
+
|
|
164
|
+
Choice [1/2/3] (default 3): `
|
|
165
|
+
);
|
|
166
|
+
const answer = (await question("")).trim() || "3";
|
|
167
|
+
rl.close();
|
|
168
|
+
if (answer === "1") return "once";
|
|
169
|
+
if (answer === "2") return "always";
|
|
170
|
+
return "denied";
|
|
171
|
+
}
|
|
172
|
+
var sessionTrusted = /* @__PURE__ */ new Set();
|
|
173
|
+
async function resolveDirectoryTrust(opts) {
|
|
174
|
+
const dir = path3.resolve(opts.trustDir ?? opts.cwd ?? process.cwd());
|
|
175
|
+
if (isDirectoryTrusted(dir, { trustCwdFlag: opts.trustCwd })) return "trusted";
|
|
176
|
+
if (sessionTrusted.has(dir)) return "trusted";
|
|
177
|
+
if (opts.trustDir && path3.resolve(opts.trustDir) === dir) {
|
|
178
|
+
sessionTrusted.add(dir);
|
|
179
|
+
return "trusted";
|
|
180
|
+
}
|
|
181
|
+
if (opts.trustCwd) {
|
|
182
|
+
sessionTrusted.add(dir);
|
|
183
|
+
return "trusted";
|
|
184
|
+
}
|
|
185
|
+
if (!process.stdin.isTTY || opts.nonInteractive) {
|
|
186
|
+
return "denied";
|
|
187
|
+
}
|
|
188
|
+
const decision = await promptTrustInteractive(dir);
|
|
189
|
+
if (decision === "denied") {
|
|
190
|
+
return "denied";
|
|
191
|
+
}
|
|
192
|
+
if (decision === "always") {
|
|
193
|
+
const store = loadTrustedDirsStore();
|
|
194
|
+
if (!store.always.includes(dir)) {
|
|
195
|
+
store.always.push(dir);
|
|
196
|
+
saveTrustedDirsStore(store);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
sessionTrusted.add(dir);
|
|
200
|
+
return "trusted";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/lib/cli-hitl.ts
|
|
204
|
+
function extractHumanInputRequest(event) {
|
|
205
|
+
const type = String(event.type ?? "").toUpperCase();
|
|
206
|
+
if (type === "CUSTOM" && event.name === "human_input_request") {
|
|
207
|
+
return event.value;
|
|
208
|
+
}
|
|
209
|
+
if (event.name === "human_input_request") {
|
|
210
|
+
return event.value;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
async function handleCliHitlInterrupt(value, opts) {
|
|
215
|
+
if (!value || typeof value !== "object") return null;
|
|
216
|
+
const payload = value;
|
|
217
|
+
if (isCliLocalFileInterrupt(payload)) {
|
|
218
|
+
const interrupt = payload;
|
|
219
|
+
if (typeof payload.interrupt_id === "string") {
|
|
220
|
+
interrupt.interrupt_id = payload.interrupt_id;
|
|
221
|
+
}
|
|
222
|
+
return handleCliLocalFile(interrupt, opts);
|
|
223
|
+
}
|
|
224
|
+
if (payload.type === "present_choice") {
|
|
225
|
+
return handlePresentChoiceTerminal(payload);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
async function handleCliLocalFile(interrupt, opts) {
|
|
230
|
+
const trust = await resolveDirectoryTrust({
|
|
231
|
+
cwd: process.cwd(),
|
|
232
|
+
trustCwd: opts.trustCwd,
|
|
233
|
+
trustDir: opts.trustDir,
|
|
234
|
+
// Interactive sessions: prompt on first local-file use (review feedback).
|
|
235
|
+
nonInteractive: opts.nonInteractive ?? process.stdin.isTTY !== true
|
|
236
|
+
});
|
|
237
|
+
if (trust !== "trusted") {
|
|
238
|
+
return {
|
|
239
|
+
resumeValue: JSON.stringify({
|
|
240
|
+
ok: false,
|
|
241
|
+
error: "Directory not trusted for local file access. Re-run with --trust-cwd or --trust-dir <path>, or approve trust interactively when prompted."
|
|
242
|
+
}),
|
|
243
|
+
resumeInterruptId: interrupt.interrupt_id
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
process.stderr.write(`
|
|
247
|
+
[local ${interrupt.action}] ${interrupt.path}
|
|
248
|
+
`);
|
|
249
|
+
const result = executeCliLocalFileAction(process.cwd(), interrupt, {
|
|
250
|
+
allowWrite: opts.allowWrite
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
resumeValue: JSON.stringify(result),
|
|
254
|
+
resumeInterruptId: interrupt.interrupt_id
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async function handlePresentChoiceTerminal(payload) {
|
|
258
|
+
const prompt = typeof payload.user_prompt === "string" && payload.user_prompt || typeof payload.description === "string" && payload.description || "Choose an option";
|
|
259
|
+
const markup = typeof payload.openui_markup === "string" ? payload.openui_markup : "";
|
|
260
|
+
process.stderr.write(`
|
|
261
|
+
${prompt}
|
|
262
|
+
`);
|
|
263
|
+
if (markup) {
|
|
264
|
+
process.stderr.write(`${markup.slice(0, 2e3)}
|
|
265
|
+
`);
|
|
266
|
+
}
|
|
267
|
+
if (!process.stdin.isTTY) {
|
|
268
|
+
return {
|
|
269
|
+
resumeValue: "",
|
|
270
|
+
resumeInterruptId: typeof payload.interrupt_id === "string" ? payload.interrupt_id : void 0
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const rl = readline__default.createInterface({ input: process.stdin, output: process.stderr });
|
|
274
|
+
const answer = await new Promise((resolve) => {
|
|
275
|
+
rl.question("Your choice (value): ", resolve);
|
|
276
|
+
});
|
|
277
|
+
rl.close();
|
|
278
|
+
return {
|
|
279
|
+
resumeValue: answer.trim(),
|
|
280
|
+
resumeInterruptId: typeof payload.interrupt_id === "string" ? payload.interrupt_id : void 0
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/lib/concierge-agui-loop.ts
|
|
285
|
+
var MAX_HITL_ROUNDS = 25;
|
|
286
|
+
async function runConciergeAguiLoop(client, initialMessages, onEvent, opts) {
|
|
287
|
+
let streamText = "";
|
|
288
|
+
let hasToolCalls = false;
|
|
289
|
+
let resume;
|
|
290
|
+
for (let round = 0; round < MAX_HITL_ROUNDS; round++) {
|
|
291
|
+
let hitlPayload = null;
|
|
292
|
+
await client.streamRequest(
|
|
293
|
+
resume ? {
|
|
294
|
+
messages: [],
|
|
295
|
+
resumeValue: resume.resumeValue,
|
|
296
|
+
resumeInterruptId: resume.resumeInterruptId
|
|
297
|
+
} : { messages: initialMessages },
|
|
298
|
+
(event) => {
|
|
299
|
+
onEvent(event);
|
|
300
|
+
const delta = getTextDelta(event);
|
|
301
|
+
if (delta) streamText += delta;
|
|
302
|
+
const type = event.type?.toLowerCase() ?? "";
|
|
303
|
+
if (type === "tool_call_start") hasToolCalls = true;
|
|
304
|
+
const errorMsg = getErrorMessage(event);
|
|
305
|
+
if (errorMsg) process.stderr.write(`\x1B[31mError: ${errorMsg}\x1B[0m
|
|
306
|
+
`);
|
|
307
|
+
const hitl = extractHumanInputRequest(event);
|
|
308
|
+
if (hitl) hitlPayload = hitl;
|
|
309
|
+
},
|
|
310
|
+
{ autoApprove: opts.autoApprove }
|
|
311
|
+
);
|
|
312
|
+
if (!hitlPayload) {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
const next = await handleCliHitlInterrupt(hitlPayload, {
|
|
316
|
+
allowWrite: opts.allowWrite,
|
|
317
|
+
autoApprove: opts.autoApprove,
|
|
318
|
+
trustCwd: opts.trustCwd,
|
|
319
|
+
trustDir: opts.trustDir,
|
|
320
|
+
nonInteractive: opts.nonInteractive
|
|
321
|
+
});
|
|
322
|
+
if (!next) {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
" [HITL] Unsupported interrupt in CLI \u2014 open app.gravityrail.com/setup to continue.\n"
|
|
325
|
+
);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
resume = next;
|
|
329
|
+
streamText = "";
|
|
330
|
+
}
|
|
331
|
+
return { streamText, hasToolCalls };
|
|
332
|
+
}
|
|
333
|
+
var RealtimeClient = class extends EventEmitter {
|
|
334
|
+
ws = null;
|
|
335
|
+
connected = false;
|
|
336
|
+
options;
|
|
337
|
+
constructor(options) {
|
|
338
|
+
super();
|
|
339
|
+
this.options = {
|
|
340
|
+
vadMode: "server",
|
|
341
|
+
debug: false,
|
|
342
|
+
...options
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** Connect to the realtime WebSocket endpoint */
|
|
346
|
+
connect() {
|
|
347
|
+
if (typeof globalThis.WebSocket === "undefined") {
|
|
348
|
+
return Promise.reject(
|
|
349
|
+
new Error("WebSocket not available. Node.js 22+ is required for realtime mode.")
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
const params = new URLSearchParams();
|
|
354
|
+
if (this.options.vadMode) {
|
|
355
|
+
params.append("vad", this.options.vadMode);
|
|
356
|
+
}
|
|
357
|
+
const url = params.toString() ? `${this.options.url}?${params.toString()}` : this.options.url;
|
|
358
|
+
if (this.options.debug) {
|
|
359
|
+
process.stderr.write(`[debug] Connecting to ${url}
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
this.ws = new WebSocket(url);
|
|
363
|
+
this.ws.onerror = (event) => {
|
|
364
|
+
const msg = event instanceof ErrorEvent ? event.message : "WebSocket connection failed";
|
|
365
|
+
if (!this.connected) {
|
|
366
|
+
reject(new Error(msg));
|
|
367
|
+
} else {
|
|
368
|
+
this.emit("error", new Error(msg));
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
this.ws.onclose = (event) => {
|
|
372
|
+
const reason = event.code === 1006 ? "Connection lost" : event.reason || "Closed";
|
|
373
|
+
this.connected = false;
|
|
374
|
+
this.emit("closed", { reason, code: event.code });
|
|
375
|
+
};
|
|
376
|
+
this.ws.onmessage = (event) => {
|
|
377
|
+
try {
|
|
378
|
+
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
379
|
+
const parsed = JSON.parse(data);
|
|
380
|
+
this.emit("ws_recv", parsed);
|
|
381
|
+
this.handleEvent(parsed);
|
|
382
|
+
} catch {
|
|
383
|
+
if (this.options.debug) {
|
|
384
|
+
process.stderr.write(`[debug] Failed to parse message
|
|
385
|
+
`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
this.ws.onopen = () => {
|
|
390
|
+
if (this.options.debug) {
|
|
391
|
+
process.stderr.write("[debug] WebSocket connected, sending login\n");
|
|
392
|
+
}
|
|
393
|
+
this.sendCustom("voice.login", {
|
|
394
|
+
token: this.options.token,
|
|
395
|
+
chat_id: this.options.chatId,
|
|
396
|
+
vad: this.options.vadMode
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
const timeout = setTimeout(() => {
|
|
400
|
+
if (!this.connected) {
|
|
401
|
+
this.close();
|
|
402
|
+
reject(new Error("Connection timeout - no login response within 10s"));
|
|
403
|
+
}
|
|
404
|
+
}, 1e4);
|
|
405
|
+
this.once("connected", () => {
|
|
406
|
+
clearTimeout(timeout);
|
|
407
|
+
resolve();
|
|
408
|
+
});
|
|
409
|
+
this.once("error", () => {
|
|
410
|
+
clearTimeout(timeout);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
handleEvent(event) {
|
|
415
|
+
switch (event.type) {
|
|
416
|
+
case "CUSTOM":
|
|
417
|
+
this.handleCustomEvent(event.name || "", event.value);
|
|
418
|
+
break;
|
|
419
|
+
case "TEXT_MESSAGE_START":
|
|
420
|
+
this.emit("text_start", { messageId: event.messageId || "" });
|
|
421
|
+
break;
|
|
422
|
+
case "TEXT_MESSAGE_CONTENT":
|
|
423
|
+
this.emit("text_delta", {
|
|
424
|
+
messageId: event.messageId || "",
|
|
425
|
+
delta: event.delta || ""
|
|
426
|
+
});
|
|
427
|
+
break;
|
|
428
|
+
case "TEXT_MESSAGE_END":
|
|
429
|
+
this.emit("text_end", { messageId: event.messageId || "" });
|
|
430
|
+
break;
|
|
431
|
+
case "TOOL_CALL_START":
|
|
432
|
+
this.emit("tool_call_start", {
|
|
433
|
+
toolCallId: event.toolCallId || "",
|
|
434
|
+
toolCallName: event.toolCallName || ""
|
|
435
|
+
});
|
|
436
|
+
break;
|
|
437
|
+
case "TOOL_CALL_ARGS":
|
|
438
|
+
this.emit("tool_call_args", {
|
|
439
|
+
toolCallId: event.toolCallId || "",
|
|
440
|
+
delta: event.delta || ""
|
|
441
|
+
});
|
|
442
|
+
break;
|
|
443
|
+
case "TOOL_CALL_END":
|
|
444
|
+
this.emit("tool_call_end", { toolCallId: event.toolCallId || "" });
|
|
445
|
+
break;
|
|
446
|
+
case "TOOL_CALL_RESULT":
|
|
447
|
+
this.emit("tool_call_result", {
|
|
448
|
+
toolCallId: event.toolCallId || "",
|
|
449
|
+
result: event.result
|
|
450
|
+
});
|
|
451
|
+
break;
|
|
452
|
+
case "RUN_STARTED":
|
|
453
|
+
this.emit("run_started", { runId: event.runId });
|
|
454
|
+
break;
|
|
455
|
+
case "RUN_FINISHED":
|
|
456
|
+
this.emit("run_finished", { runId: event.runId });
|
|
457
|
+
break;
|
|
458
|
+
case "RUN_ERROR":
|
|
459
|
+
this.emit("run_error", {
|
|
460
|
+
message: event.message || "Unknown error",
|
|
461
|
+
code: event.code
|
|
462
|
+
});
|
|
463
|
+
break;
|
|
464
|
+
default:
|
|
465
|
+
if (this.options.debug) {
|
|
466
|
+
process.stderr.write(`[debug] Unknown event type: ${event.type}
|
|
467
|
+
`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
handleCustomEvent(name, value) {
|
|
472
|
+
switch (name) {
|
|
473
|
+
case "voice.login_success":
|
|
474
|
+
this.connected = true;
|
|
475
|
+
this.emit("connected", {
|
|
476
|
+
chatId: value?.chat_id,
|
|
477
|
+
memberId: value?.member_id,
|
|
478
|
+
assistantId: value?.assistant_id
|
|
479
|
+
});
|
|
480
|
+
break;
|
|
481
|
+
case "voice.audio":
|
|
482
|
+
if (value?.data) {
|
|
483
|
+
const pcm16 = Buffer.from(value.data, "base64");
|
|
484
|
+
this.emit("audio", { pcm16, messageId: value.message_id });
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
case "voice.audio_begin":
|
|
488
|
+
this.emit("audio_begin", { messageId: value?.message_id });
|
|
489
|
+
break;
|
|
490
|
+
case "voice.audio_end":
|
|
491
|
+
this.emit("audio_end", { messageId: value?.message_id });
|
|
492
|
+
break;
|
|
493
|
+
case "voice.interrupt":
|
|
494
|
+
this.emit("interrupt", { messageId: value?.message_id });
|
|
495
|
+
break;
|
|
496
|
+
case "voice.response_message":
|
|
497
|
+
if (value) {
|
|
498
|
+
this.emit("response_message", value);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "voice.transcription":
|
|
502
|
+
if (value) {
|
|
503
|
+
this.emit("transcription", { text: value.text, role: value.role });
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
case "voice.truncate":
|
|
507
|
+
if (value) {
|
|
508
|
+
this.emit("truncate", {
|
|
509
|
+
messageId: value.message_id,
|
|
510
|
+
content: value.content,
|
|
511
|
+
originalContent: value.original_content,
|
|
512
|
+
truncationReason: value.truncation_reason || "barge_in",
|
|
513
|
+
truncatedAtMs: value.audio_end_ms
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
break;
|
|
517
|
+
case "app.data_update":
|
|
518
|
+
case "app.navigation":
|
|
519
|
+
case "app.notification":
|
|
520
|
+
break;
|
|
521
|
+
default:
|
|
522
|
+
if (this.options.debug) {
|
|
523
|
+
process.stderr.write(`[debug] Unknown CUSTOM event: ${name}
|
|
524
|
+
`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
sendCustom(name, value) {
|
|
529
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const event = { type: "CUSTOM", name };
|
|
533
|
+
if (value !== void 0) {
|
|
534
|
+
event.value = value;
|
|
535
|
+
}
|
|
536
|
+
this.emit("ws_send", event);
|
|
537
|
+
this.ws.send(JSON.stringify(event));
|
|
538
|
+
}
|
|
539
|
+
/** Send raw PCM16 audio data (will be base64-encoded) */
|
|
540
|
+
sendAudio(pcm16) {
|
|
541
|
+
this.sendCustom("voice.audio", {
|
|
542
|
+
data: pcm16.toString("base64"),
|
|
543
|
+
format: "pcm16",
|
|
544
|
+
sample_rate: 24e3
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/** Signal the start of an audio utterance */
|
|
548
|
+
sendAudioBegin(messageId) {
|
|
549
|
+
this.sendCustom("voice.audio_begin", { message_id: messageId });
|
|
550
|
+
}
|
|
551
|
+
/** Signal the end of an audio utterance */
|
|
552
|
+
sendAudioEnd() {
|
|
553
|
+
this.sendCustom("voice.audio_end");
|
|
554
|
+
}
|
|
555
|
+
/** Send a text message (triggers AI inference) */
|
|
556
|
+
sendTextMessage(content) {
|
|
557
|
+
this.sendCustom("voice.user_message", {
|
|
558
|
+
message: { role: "user", content }
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/** Send interrupt signal with playback position */
|
|
562
|
+
sendInterrupt(audioEndMs, messageId) {
|
|
563
|
+
this.sendCustom("voice.interrupt", {
|
|
564
|
+
audio_end_ms: audioEndMs,
|
|
565
|
+
message_id: messageId
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** Check if the client is connected */
|
|
569
|
+
isConnected() {
|
|
570
|
+
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
571
|
+
}
|
|
572
|
+
/** Close the connection */
|
|
573
|
+
close() {
|
|
574
|
+
this.connected = false;
|
|
575
|
+
if (this.ws) {
|
|
576
|
+
this.ws.close();
|
|
577
|
+
this.ws = null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
function isToolCall(item) {
|
|
582
|
+
return "kind" in item && item.kind === "tool_call";
|
|
583
|
+
}
|
|
584
|
+
function isToolResult(item) {
|
|
585
|
+
return "kind" in item && item.kind === "tool_result";
|
|
586
|
+
}
|
|
587
|
+
function getTruncationLabel(reason, truncatedAtMs) {
|
|
588
|
+
const timeStr = truncatedAtMs ? ` at ${(truncatedAtMs / 1e3).toFixed(1)}s` : "";
|
|
589
|
+
switch (reason) {
|
|
590
|
+
case "barge_in":
|
|
591
|
+
return `interrupted: user spoke${timeStr}`;
|
|
592
|
+
case "client_interrupt":
|
|
593
|
+
return `interrupted${timeStr}`;
|
|
594
|
+
case "user_cancel":
|
|
595
|
+
return `cancelled by user${timeStr}`;
|
|
596
|
+
case "error":
|
|
597
|
+
return `error${timeStr}`;
|
|
598
|
+
default:
|
|
599
|
+
return `interrupted${timeStr}`;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function getExtraContent(content, originalContent) {
|
|
603
|
+
if (!content || !originalContent) return null;
|
|
604
|
+
const stripped = content.replace(/—interrupted$/, "");
|
|
605
|
+
if (originalContent.startsWith(stripped)) {
|
|
606
|
+
const extra = originalContent.slice(stripped.length);
|
|
607
|
+
return extra.length > 0 ? extra : null;
|
|
608
|
+
}
|
|
609
|
+
return originalContent !== content ? originalContent : null;
|
|
610
|
+
}
|
|
611
|
+
var CSI = "\x1B[";
|
|
612
|
+
var HIDE_CURSOR = `${CSI}?25l`;
|
|
613
|
+
var SHOW_CURSOR = `${CSI}?25h`;
|
|
614
|
+
var ALT_SCREEN_ON = `${CSI}?1049h`;
|
|
615
|
+
var ALT_SCREEN_OFF = `${CSI}?1049l`;
|
|
616
|
+
var CLEAR_EOL = `${CSI}K`;
|
|
617
|
+
var RESET = `${CSI}0m`;
|
|
618
|
+
var MOUSE_ON = `${CSI}?1000h${CSI}?1006h`;
|
|
619
|
+
var MOUSE_OFF = `${CSI}?1000l${CSI}?1006l`;
|
|
620
|
+
var BLUE = `${CSI}34m`;
|
|
621
|
+
var GREEN = `${CSI}32m`;
|
|
622
|
+
var GRAY = `${CSI}90m`;
|
|
623
|
+
var YELLOW = `${CSI}33m`;
|
|
624
|
+
var ORANGE = `${CSI}38;5;208m`;
|
|
625
|
+
var LIGHT_BLUE = `${CSI}38;5;117m`;
|
|
626
|
+
var DIM = `${CSI}2m`;
|
|
627
|
+
var DIM_ITALIC_GRAY = `${CSI}2;3;90m`;
|
|
628
|
+
function moveTo(row, col) {
|
|
629
|
+
return `${CSI}${row};${col}H`;
|
|
630
|
+
}
|
|
631
|
+
var ChatTUI = class {
|
|
632
|
+
items = [];
|
|
633
|
+
inputBuffer = "";
|
|
634
|
+
isStreaming = false;
|
|
635
|
+
streamContent = "";
|
|
636
|
+
scrollOffset = 0;
|
|
637
|
+
expandDetails = false;
|
|
638
|
+
rows = 24;
|
|
639
|
+
cols = 80;
|
|
640
|
+
started = false;
|
|
641
|
+
renderTimer = null;
|
|
642
|
+
displayLinesCache = null;
|
|
643
|
+
debugLinesCache = null;
|
|
644
|
+
options;
|
|
645
|
+
// Debug panel state
|
|
646
|
+
debugEntries = [];
|
|
647
|
+
debugPanelVisible = false;
|
|
648
|
+
debugLogStream = null;
|
|
649
|
+
// Escape sequence accumulator for raw stdin parsing
|
|
650
|
+
escBuf = "";
|
|
651
|
+
escTimer = null;
|
|
652
|
+
constructor(options) {
|
|
653
|
+
this.options = options;
|
|
654
|
+
this.debugPanelVisible = !!options.debug;
|
|
655
|
+
}
|
|
656
|
+
/** Whether the debug panel is currently being rendered */
|
|
657
|
+
get debugVisible() {
|
|
658
|
+
return this.debugPanelVisible;
|
|
659
|
+
}
|
|
660
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────
|
|
661
|
+
start() {
|
|
662
|
+
this.updateDimensions();
|
|
663
|
+
process.stdout.write(ALT_SCREEN_ON + MOUSE_ON);
|
|
664
|
+
process.stdout.write(`${CSI}1;${Math.max(1, this.rows - 2)}r`);
|
|
665
|
+
process.stdin.setEncoding("utf8");
|
|
666
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
667
|
+
process.stdin.on("data", this.onData);
|
|
668
|
+
process.stdin.resume();
|
|
669
|
+
process.stdout.on("resize", this.onResize);
|
|
670
|
+
process.on("exit", this.restoreTerminal);
|
|
671
|
+
if (this.options.debug && this.options.env !== "prod") {
|
|
672
|
+
this.debugLogStream = createWriteStream("gr-cli-realtime-debug.log", { flags: "a" });
|
|
673
|
+
this.debugLogStream.write(`
|
|
674
|
+
--- session ${(/* @__PURE__ */ new Date()).toISOString()} ---
|
|
675
|
+
`);
|
|
676
|
+
}
|
|
677
|
+
this.started = true;
|
|
678
|
+
this.render();
|
|
679
|
+
}
|
|
680
|
+
stop() {
|
|
681
|
+
if (!this.started) return;
|
|
682
|
+
this.started = false;
|
|
683
|
+
if (this.renderTimer) {
|
|
684
|
+
clearTimeout(this.renderTimer);
|
|
685
|
+
this.renderTimer = null;
|
|
686
|
+
}
|
|
687
|
+
this.clearEsc();
|
|
688
|
+
if (this.debugLogStream) {
|
|
689
|
+
this.debugLogStream.end();
|
|
690
|
+
this.debugLogStream = null;
|
|
691
|
+
}
|
|
692
|
+
this.restoreTerminal();
|
|
693
|
+
process.stdout.removeListener("resize", this.onResize);
|
|
694
|
+
process.stdin.removeListener("data", this.onData);
|
|
695
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
696
|
+
process.stdin.pause();
|
|
697
|
+
process.removeListener("exit", this.restoreTerminal);
|
|
698
|
+
}
|
|
699
|
+
// ─── Chat state ────────────────────────────────────────────────────
|
|
700
|
+
loadHistory(items) {
|
|
701
|
+
this.items = items.filter((item) => isToolCall(item) ? true : item.content?.trim());
|
|
702
|
+
this.dirty();
|
|
703
|
+
}
|
|
704
|
+
streamStart() {
|
|
705
|
+
this.isStreaming = true;
|
|
706
|
+
this.streamContent = "";
|
|
707
|
+
this.scrollOffset = 0;
|
|
708
|
+
this.dirty();
|
|
709
|
+
}
|
|
710
|
+
streamDelta(delta) {
|
|
711
|
+
this.streamContent += delta;
|
|
712
|
+
this.dirty();
|
|
713
|
+
}
|
|
714
|
+
streamEnd() {
|
|
715
|
+
if (this.streamContent.trim()) {
|
|
716
|
+
this.items.push({ role: "assistant", content: this.streamContent });
|
|
717
|
+
}
|
|
718
|
+
this.isStreaming = false;
|
|
719
|
+
this.streamContent = "";
|
|
720
|
+
this.dirty();
|
|
721
|
+
}
|
|
722
|
+
toolCallStart(id, name) {
|
|
723
|
+
this.items.push({ kind: "tool_call", id, name, args: "", done: false });
|
|
724
|
+
this.dirty();
|
|
725
|
+
}
|
|
726
|
+
toolCallArgs(id, delta) {
|
|
727
|
+
const tc = this.findToolCall(id);
|
|
728
|
+
if (tc) {
|
|
729
|
+
tc.args += delta;
|
|
730
|
+
this.dirty();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
toolCallEnd(id) {
|
|
734
|
+
const tc = this.findToolCall(id);
|
|
735
|
+
if (tc) {
|
|
736
|
+
tc.done = true;
|
|
737
|
+
this.dirty();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** Add a transcription of the user's spoken words (voice mode) */
|
|
741
|
+
addTranscription(text) {
|
|
742
|
+
this.items.push({ role: "user", content: text });
|
|
743
|
+
this.scrollOffset = 0;
|
|
744
|
+
this.dirty();
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Truncate the most recent assistant message (voice interruption).
|
|
748
|
+
* Replaces its content with the spoken portion and stores the original.
|
|
749
|
+
*/
|
|
750
|
+
truncateAssistant(content, originalContent, reason, truncatedAtMs) {
|
|
751
|
+
if (this.isStreaming) {
|
|
752
|
+
this.isStreaming = false;
|
|
753
|
+
this.streamContent = "";
|
|
754
|
+
}
|
|
755
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
756
|
+
const item = this.items[i];
|
|
757
|
+
if ("role" in item && item.role === "assistant") {
|
|
758
|
+
item.content = content;
|
|
759
|
+
item.truncationReason = reason;
|
|
760
|
+
item.truncatedAtMs = truncatedAtMs;
|
|
761
|
+
item.originalContent = originalContent;
|
|
762
|
+
this.dirty();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
this.items.push({
|
|
767
|
+
role: "assistant",
|
|
768
|
+
content,
|
|
769
|
+
truncationReason: reason,
|
|
770
|
+
truncatedAtMs,
|
|
771
|
+
originalContent
|
|
772
|
+
});
|
|
773
|
+
this.dirty();
|
|
774
|
+
}
|
|
775
|
+
toolCallResult(toolCallId, toolName, content) {
|
|
776
|
+
this.items.push({ kind: "tool_result", toolCallId, toolName, content });
|
|
777
|
+
this.dirty();
|
|
778
|
+
}
|
|
779
|
+
/** Find the tool name for a given tool call ID */
|
|
780
|
+
findToolCallName(toolCallId) {
|
|
781
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
782
|
+
const item = this.items[i];
|
|
783
|
+
if (isToolCall(item) && item.id === toolCallId) return item.name;
|
|
784
|
+
}
|
|
785
|
+
return "tool";
|
|
786
|
+
}
|
|
787
|
+
showError(msg) {
|
|
788
|
+
this.items.push({ role: "system", content: `Error: ${msg}` });
|
|
789
|
+
this.dirty();
|
|
790
|
+
}
|
|
791
|
+
// ─── Debug panel ──────────────────────────────────────────────────
|
|
792
|
+
/** Log a WebSocket message to the debug panel and (optionally) log file */
|
|
793
|
+
logDebug(direction, raw) {
|
|
794
|
+
const redacted = redactAudio(raw);
|
|
795
|
+
const now = /* @__PURE__ */ new Date();
|
|
796
|
+
const ts = `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
|
|
797
|
+
if (this.debugLogStream) {
|
|
798
|
+
const logLine = JSON.stringify({ ts: now.toISOString(), dir: direction, ...redacted });
|
|
799
|
+
this.debugLogStream.write(logLine + "\n");
|
|
800
|
+
}
|
|
801
|
+
const summary = buildDebugSummary(direction, redacted);
|
|
802
|
+
let detail;
|
|
803
|
+
try {
|
|
804
|
+
detail = JSON.stringify(redacted, null, 2);
|
|
805
|
+
} catch {
|
|
806
|
+
detail = String(redacted);
|
|
807
|
+
}
|
|
808
|
+
this.debugEntries.push({ direction, summary, detail, ts });
|
|
809
|
+
if (this.debugEntries.length > 500) {
|
|
810
|
+
this.debugEntries = this.debugEntries.slice(-300);
|
|
811
|
+
}
|
|
812
|
+
this.debugLinesCache = null;
|
|
813
|
+
this.dirty();
|
|
814
|
+
}
|
|
815
|
+
// ─── Private helpers ──────────────────────────────────────────────
|
|
816
|
+
findToolCall(id) {
|
|
817
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
818
|
+
const item = this.items[i];
|
|
819
|
+
if (isToolCall(item) && item.id === id) return item;
|
|
820
|
+
}
|
|
821
|
+
return void 0;
|
|
822
|
+
}
|
|
823
|
+
dirty() {
|
|
824
|
+
this.displayLinesCache = null;
|
|
825
|
+
this.debugLinesCache = null;
|
|
826
|
+
this.scheduleRender();
|
|
827
|
+
}
|
|
828
|
+
restoreTerminal = () => {
|
|
829
|
+
process.stdout.write(`${MOUSE_OFF}${CSI}r${ALT_SCREEN_OFF}${SHOW_CURSOR}`);
|
|
830
|
+
};
|
|
831
|
+
updateDimensions() {
|
|
832
|
+
this.rows = process.stdout.rows || 24;
|
|
833
|
+
this.cols = process.stdout.columns || 80;
|
|
834
|
+
}
|
|
835
|
+
/** Width of the chat (left) panel */
|
|
836
|
+
chatWidth() {
|
|
837
|
+
if (!this.debugVisible) return this.cols;
|
|
838
|
+
return Math.max(30, Math.floor(this.cols * 0.6) - 1);
|
|
839
|
+
}
|
|
840
|
+
/** Width of the debug (right) panel */
|
|
841
|
+
debugWidth() {
|
|
842
|
+
if (!this.debugVisible) return 0;
|
|
843
|
+
return this.cols - this.chatWidth() - 1;
|
|
844
|
+
}
|
|
845
|
+
scrollHeight() {
|
|
846
|
+
return Math.max(1, this.rows - 2);
|
|
847
|
+
}
|
|
848
|
+
pageSize() {
|
|
849
|
+
return Math.max(1, this.rows - 4);
|
|
850
|
+
}
|
|
851
|
+
// ─── Scroll ───────────────────────────────────────────────────────
|
|
852
|
+
scrollUp(lines) {
|
|
853
|
+
const total = this.getDisplayLines().length;
|
|
854
|
+
const max = Math.max(0, total - this.scrollHeight());
|
|
855
|
+
this.scrollOffset = Math.min(max, this.scrollOffset + lines);
|
|
856
|
+
this.render();
|
|
857
|
+
}
|
|
858
|
+
scrollDown(lines) {
|
|
859
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - lines);
|
|
860
|
+
this.render();
|
|
861
|
+
}
|
|
862
|
+
scrollToTop() {
|
|
863
|
+
const total = this.getDisplayLines().length;
|
|
864
|
+
this.scrollOffset = Math.max(0, total - this.scrollHeight());
|
|
865
|
+
this.render();
|
|
866
|
+
}
|
|
867
|
+
scrollToBottom() {
|
|
868
|
+
this.scrollOffset = 0;
|
|
869
|
+
this.render();
|
|
870
|
+
}
|
|
871
|
+
// ─── Raw stdin parsing ────────────────────────────────────────────
|
|
872
|
+
//
|
|
873
|
+
// We parse stdin directly instead of using readline.emitKeypressEvents
|
|
874
|
+
// because readline doesn't understand SGR mouse sequences and leaks
|
|
875
|
+
// their contents into keypress events as regular characters.
|
|
876
|
+
onData = (data) => {
|
|
877
|
+
for (let i = 0; i < data.length; i++) {
|
|
878
|
+
const ch = data[i];
|
|
879
|
+
if (this.escBuf) {
|
|
880
|
+
this.escBuf += ch;
|
|
881
|
+
if (this.escBuf.length === 2) {
|
|
882
|
+
if (ch !== "[") {
|
|
883
|
+
this.clearEsc();
|
|
884
|
+
}
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (/[A-Za-z~]/.test(ch)) {
|
|
888
|
+
this.handleCSI(this.escBuf.slice(2, -1), ch);
|
|
889
|
+
this.clearEsc();
|
|
890
|
+
} else if (this.escBuf.length > 32) {
|
|
891
|
+
this.clearEsc();
|
|
892
|
+
}
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (ch === "\x1B") {
|
|
896
|
+
this.escBuf = "\x1B";
|
|
897
|
+
this.escTimer = setTimeout(() => {
|
|
898
|
+
this.escBuf = "";
|
|
899
|
+
this.escTimer = null;
|
|
900
|
+
}, 100);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
this.handleByte(ch);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
clearEsc() {
|
|
907
|
+
this.escBuf = "";
|
|
908
|
+
if (this.escTimer) {
|
|
909
|
+
clearTimeout(this.escTimer);
|
|
910
|
+
this.escTimer = null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** Handle a parsed CSI sequence: params is everything between \x1B[ and the final char */
|
|
914
|
+
handleCSI(params, code) {
|
|
915
|
+
if (params.startsWith("<") && (code === "M" || code === "m")) {
|
|
916
|
+
if (code === "m") return;
|
|
917
|
+
const parts = params.slice(1).split(";");
|
|
918
|
+
if (parts.length >= 1) {
|
|
919
|
+
const button = parseInt(parts[0], 10);
|
|
920
|
+
if (button === 64) this.scrollUp(3);
|
|
921
|
+
else if (button === 65) this.scrollDown(3);
|
|
922
|
+
}
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (!params) {
|
|
926
|
+
if (code === "A") {
|
|
927
|
+
this.scrollUp(1);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (code === "B") {
|
|
931
|
+
this.scrollDown(1);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (code === "H") {
|
|
935
|
+
this.scrollToTop();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (code === "F") {
|
|
939
|
+
this.scrollToBottom();
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (code === "~") {
|
|
944
|
+
if (params === "5") {
|
|
945
|
+
this.scrollUp(this.pageSize());
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (params === "6") {
|
|
949
|
+
this.scrollDown(this.pageSize());
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (params === "1") {
|
|
953
|
+
this.scrollToTop();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (params === "4") {
|
|
957
|
+
this.scrollToBottom();
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/** Handle a single non-escape byte */
|
|
963
|
+
handleByte(ch) {
|
|
964
|
+
const code = ch.charCodeAt(0);
|
|
965
|
+
if (code === 3) {
|
|
966
|
+
this.stop();
|
|
967
|
+
this.options.onExit();
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (code === 2) {
|
|
971
|
+
this.scrollUp(this.pageSize());
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (code === 6) {
|
|
975
|
+
this.scrollDown(this.pageSize());
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (code === 4) {
|
|
979
|
+
this.debugPanelVisible = !this.debugPanelVisible;
|
|
980
|
+
this.displayLinesCache = null;
|
|
981
|
+
this.debugLinesCache = null;
|
|
982
|
+
this.render();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (code === 15) {
|
|
986
|
+
this.expandDetails = !this.expandDetails;
|
|
987
|
+
this.displayLinesCache = null;
|
|
988
|
+
this.debugLinesCache = null;
|
|
989
|
+
this.render();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (code === 13) {
|
|
993
|
+
this.handleEnter();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (code === 127 || code === 8) {
|
|
997
|
+
if (this.inputBuffer.length > 0) {
|
|
998
|
+
this.inputBuffer = this.inputBuffer.slice(0, -1);
|
|
999
|
+
this.renderInputLine();
|
|
1000
|
+
}
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (code < 32) return;
|
|
1004
|
+
this.inputBuffer += ch;
|
|
1005
|
+
this.renderInputLine();
|
|
1006
|
+
}
|
|
1007
|
+
handleEnter() {
|
|
1008
|
+
const text = this.inputBuffer.trim();
|
|
1009
|
+
this.inputBuffer = "";
|
|
1010
|
+
if (!text) {
|
|
1011
|
+
this.renderInputLine();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (text.toLowerCase() === "exit" || text.toLowerCase() === "quit") {
|
|
1015
|
+
this.stop();
|
|
1016
|
+
this.options.onExit();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.items.push({ role: "user", content: text });
|
|
1020
|
+
this.scrollOffset = 0;
|
|
1021
|
+
this.displayLinesCache = null;
|
|
1022
|
+
this.render();
|
|
1023
|
+
this.options.onSend(text);
|
|
1024
|
+
}
|
|
1025
|
+
// ─── Events ───────────────────────────────────────────────────────
|
|
1026
|
+
onResize = () => {
|
|
1027
|
+
this.updateDimensions();
|
|
1028
|
+
process.stdout.write(`${CSI}1;${Math.max(1, this.rows - 2)}r`);
|
|
1029
|
+
this.displayLinesCache = null;
|
|
1030
|
+
this.debugLinesCache = null;
|
|
1031
|
+
this.render();
|
|
1032
|
+
};
|
|
1033
|
+
// ─── Rendering ────────────────────────────────────────────────────
|
|
1034
|
+
scheduleRender() {
|
|
1035
|
+
if (!this.started || this.renderTimer) return;
|
|
1036
|
+
this.renderTimer = setTimeout(() => {
|
|
1037
|
+
this.renderTimer = null;
|
|
1038
|
+
this.render();
|
|
1039
|
+
}, 30);
|
|
1040
|
+
}
|
|
1041
|
+
getDisplayLines() {
|
|
1042
|
+
if (!this.displayLinesCache) {
|
|
1043
|
+
this.displayLinesCache = this.buildDisplayLines();
|
|
1044
|
+
}
|
|
1045
|
+
return this.displayLinesCache;
|
|
1046
|
+
}
|
|
1047
|
+
getDebugLines() {
|
|
1048
|
+
if (!this.debugLinesCache) {
|
|
1049
|
+
this.debugLinesCache = this.buildDebugLines();
|
|
1050
|
+
}
|
|
1051
|
+
return this.debugLinesCache;
|
|
1052
|
+
}
|
|
1053
|
+
render() {
|
|
1054
|
+
if (!this.started) return;
|
|
1055
|
+
this.updateDimensions();
|
|
1056
|
+
const sh = this.scrollHeight();
|
|
1057
|
+
const displayLines = this.getDisplayLines();
|
|
1058
|
+
const total = displayLines.length;
|
|
1059
|
+
const maxOff = Math.max(0, total - sh);
|
|
1060
|
+
if (this.scrollOffset > maxOff) this.scrollOffset = maxOff;
|
|
1061
|
+
let visible;
|
|
1062
|
+
if (total <= sh) {
|
|
1063
|
+
visible = [...new Array(sh - total).fill(""), ...displayLines];
|
|
1064
|
+
} else {
|
|
1065
|
+
const end = total - this.scrollOffset;
|
|
1066
|
+
const start = end - sh;
|
|
1067
|
+
visible = displayLines.slice(start, end);
|
|
1068
|
+
}
|
|
1069
|
+
const buf = [HIDE_CURSOR];
|
|
1070
|
+
if (this.debugVisible) {
|
|
1071
|
+
this.renderSplitScreen(buf, visible, sh);
|
|
1072
|
+
} else {
|
|
1073
|
+
for (let i = 0; i < sh; i++) {
|
|
1074
|
+
buf.push(moveTo(i + 1, 1), visible[i] || "", CLEAR_EOL);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
buf.push(moveTo(this.rows - 1, 1), this.buildSeparator(), CLEAR_EOL);
|
|
1078
|
+
buf.push(moveTo(this.rows, 1), `${BLUE}You:${RESET} ${this.inputBuffer}${CLEAR_EOL}`);
|
|
1079
|
+
buf.push(SHOW_CURSOR);
|
|
1080
|
+
process.stdout.write(buf.join(""));
|
|
1081
|
+
}
|
|
1082
|
+
renderSplitScreen(buf, chatVisible, sh) {
|
|
1083
|
+
const cw = this.chatWidth();
|
|
1084
|
+
const dw = this.debugWidth();
|
|
1085
|
+
const divCol = cw + 1;
|
|
1086
|
+
const debugLines = this.getDebugLines();
|
|
1087
|
+
const debugTotal = debugLines.length;
|
|
1088
|
+
let debugVisible;
|
|
1089
|
+
if (debugTotal <= sh) {
|
|
1090
|
+
debugVisible = [...new Array(sh - debugTotal).fill(""), ...debugLines];
|
|
1091
|
+
} else {
|
|
1092
|
+
debugVisible = debugLines.slice(debugTotal - sh);
|
|
1093
|
+
}
|
|
1094
|
+
for (let i = 0; i < sh; i++) {
|
|
1095
|
+
const chatLine = truncateAnsi(chatVisible[i] || "", cw);
|
|
1096
|
+
const debugLine = truncateAnsi(debugVisible[i] || "", dw);
|
|
1097
|
+
buf.push(
|
|
1098
|
+
moveTo(i + 1, 1),
|
|
1099
|
+
chatLine,
|
|
1100
|
+
CLEAR_EOL,
|
|
1101
|
+
// clear to end handles rest, but we need to position divider
|
|
1102
|
+
moveTo(i + 1, divCol),
|
|
1103
|
+
`${DIM}\u2502${RESET}`,
|
|
1104
|
+
debugLine
|
|
1105
|
+
);
|
|
1106
|
+
buf.push(CLEAR_EOL);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
renderInputLine() {
|
|
1110
|
+
process.stdout.write(
|
|
1111
|
+
`${moveTo(this.rows, 1)}${BLUE}You:${RESET} ${this.inputBuffer}${CLEAR_EOL}`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
// ─── Separator ────────────────────────────────────────────────────
|
|
1115
|
+
buildSeparator() {
|
|
1116
|
+
const hints = [];
|
|
1117
|
+
if (this.scrollOffset > 0) {
|
|
1118
|
+
hints.push(`\u2191${this.scrollOffset}`);
|
|
1119
|
+
}
|
|
1120
|
+
const hasDetails = this.items.some((i) => isToolCall(i) || isToolResult(i)) || this.options.debug;
|
|
1121
|
+
if (hasDetails) {
|
|
1122
|
+
hints.push(`^O ${this.expandDetails ? "collapse" : "details"}`);
|
|
1123
|
+
}
|
|
1124
|
+
if (this.options.debug) {
|
|
1125
|
+
hints.push(`^D ${this.debugPanelVisible ? "hide" : "show"} debug`);
|
|
1126
|
+
}
|
|
1127
|
+
const lineWidth = Math.min(this.cols, 60);
|
|
1128
|
+
if (!hints.length) {
|
|
1129
|
+
return `${GRAY}${"\u2500".repeat(lineWidth)}${RESET}`;
|
|
1130
|
+
}
|
|
1131
|
+
const hintStr = ` ${hints.join(" ")} `;
|
|
1132
|
+
const dashes = Math.max(0, lineWidth - hintStr.length);
|
|
1133
|
+
const left = Math.max(2, Math.floor(dashes / 2));
|
|
1134
|
+
const right = Math.max(2, dashes - left);
|
|
1135
|
+
return `${GRAY}${"\u2500".repeat(left)}${hintStr}${"\u2500".repeat(right)}${RESET}`;
|
|
1136
|
+
}
|
|
1137
|
+
// ─── Chat display line building ───────────────────────────────────
|
|
1138
|
+
buildDisplayLines() {
|
|
1139
|
+
const lines = [];
|
|
1140
|
+
const w = this.chatWidth();
|
|
1141
|
+
for (const item of this.items) {
|
|
1142
|
+
if (isToolCall(item)) {
|
|
1143
|
+
this.appendToolCallLines(lines, item, w);
|
|
1144
|
+
} else if (isToolResult(item)) {
|
|
1145
|
+
this.appendToolResultLines(lines, item, w);
|
|
1146
|
+
} else {
|
|
1147
|
+
this.appendMessageLines(lines, item, w);
|
|
1148
|
+
}
|
|
1149
|
+
lines.push("");
|
|
1150
|
+
}
|
|
1151
|
+
if (this.isStreaming) {
|
|
1152
|
+
this.appendStreamLines(lines, w);
|
|
1153
|
+
}
|
|
1154
|
+
return lines;
|
|
1155
|
+
}
|
|
1156
|
+
appendToolCallLines(lines, tc, width) {
|
|
1157
|
+
const status = tc.done ? "Called" : "Calling";
|
|
1158
|
+
const summary = ` ${GRAY}${status} ${tc.name}${tc.done ? "" : "..."}${RESET}`;
|
|
1159
|
+
if (!this.expandDetails) {
|
|
1160
|
+
lines.push(summary);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
lines.push(summary);
|
|
1164
|
+
if (tc.args) {
|
|
1165
|
+
let formatted = tc.args;
|
|
1166
|
+
try {
|
|
1167
|
+
formatted = JSON.stringify(JSON.parse(tc.args), null, 2);
|
|
1168
|
+
} catch {
|
|
1169
|
+
}
|
|
1170
|
+
const indent = " ";
|
|
1171
|
+
for (const argLine of formatted.split("\n")) {
|
|
1172
|
+
for (const wl of this.wrapText(argLine, width - indent.length)) {
|
|
1173
|
+
lines.push(`${indent}${GRAY}${wl}${RESET}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
appendToolResultLines(lines, tr, width) {
|
|
1179
|
+
if (!this.expandDetails) {
|
|
1180
|
+
const maxLen = width - 10;
|
|
1181
|
+
const preview = tr.content.replace(/\n/g, " ").slice(0, maxLen);
|
|
1182
|
+
const truncated = preview.length < tr.content.length ? "..." : "";
|
|
1183
|
+
lines.push(` ${GRAY}\u2192 ${tr.toolName}: ${preview}${truncated}${RESET}`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
lines.push(` ${GRAY}\u2192 ${tr.toolName} result:${RESET}`);
|
|
1187
|
+
let formatted = tr.content;
|
|
1188
|
+
try {
|
|
1189
|
+
formatted = JSON.stringify(JSON.parse(tr.content), null, 2);
|
|
1190
|
+
} catch {
|
|
1191
|
+
}
|
|
1192
|
+
const indent = " ";
|
|
1193
|
+
for (const resultLine of formatted.split("\n")) {
|
|
1194
|
+
for (const wl of this.wrapText(resultLine, width - indent.length)) {
|
|
1195
|
+
lines.push(`${indent}${GRAY}${wl}${RESET}`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
appendMessageLines(lines, msg, width) {
|
|
1200
|
+
if (msg.role === "system") {
|
|
1201
|
+
for (const para of msg.content.split("\n")) {
|
|
1202
|
+
for (const wl of this.wrapText(para, width - 2)) {
|
|
1203
|
+
lines.push(` ${GRAY}${wl}${RESET}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const isUser = msg.role === "user";
|
|
1209
|
+
const label = isUser ? `${BLUE}You:${RESET} ` : `${GREEN}Assistant:${RESET} `;
|
|
1210
|
+
const labelWidth = isUser ? 5 : 12;
|
|
1211
|
+
const indent = " ".repeat(labelWidth);
|
|
1212
|
+
let first = true;
|
|
1213
|
+
const displayContent = msg.truncationReason ? msg.content.replace(/—interrupted$/, "") : msg.content;
|
|
1214
|
+
for (const para of displayContent.split("\n")) {
|
|
1215
|
+
for (const wl of this.wrapText(para, width - labelWidth)) {
|
|
1216
|
+
lines.push(first ? label + wl : indent + wl);
|
|
1217
|
+
first = false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (msg.truncationReason) {
|
|
1221
|
+
const reasonLabel = getTruncationLabel(msg.truncationReason, msg.truncatedAtMs);
|
|
1222
|
+
lines.push(`${indent}${YELLOW}[${reasonLabel}]${RESET}`);
|
|
1223
|
+
const extra = getExtraContent(msg.content, msg.originalContent);
|
|
1224
|
+
if (extra) {
|
|
1225
|
+
for (const para of extra.split("\n")) {
|
|
1226
|
+
for (const wl of this.wrapText(para, width - labelWidth)) {
|
|
1227
|
+
lines.push(`${indent}${DIM_ITALIC_GRAY}${wl}${RESET}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
appendStreamLines(lines, width) {
|
|
1234
|
+
const label = `${GREEN}Assistant:${RESET} `;
|
|
1235
|
+
const labelWidth = 12;
|
|
1236
|
+
const indent = " ".repeat(labelWidth);
|
|
1237
|
+
if (!this.streamContent) {
|
|
1238
|
+
lines.push(label);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
let first = true;
|
|
1242
|
+
for (const para of this.streamContent.split("\n")) {
|
|
1243
|
+
for (const wl of this.wrapText(para, width - labelWidth)) {
|
|
1244
|
+
lines.push(first ? label + wl : indent + wl);
|
|
1245
|
+
first = false;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// ─── Debug display line building ──────────────────────────────────
|
|
1250
|
+
buildDebugLines() {
|
|
1251
|
+
const lines = [];
|
|
1252
|
+
const dw = this.debugWidth();
|
|
1253
|
+
if (dw <= 0) return lines;
|
|
1254
|
+
for (const entry of this.debugEntries) {
|
|
1255
|
+
const color = entry.direction === "in" ? ORANGE : LIGHT_BLUE;
|
|
1256
|
+
const arrow = entry.direction === "in" ? "\u25BC" : "\u25B2";
|
|
1257
|
+
const prefix = `${DIM}${entry.ts}${RESET} ${color}${arrow}${RESET} `;
|
|
1258
|
+
const prefixWidth = entry.ts.length + 3;
|
|
1259
|
+
if (!this.expandDetails) {
|
|
1260
|
+
const maxSummary = dw - prefixWidth;
|
|
1261
|
+
const summaryText = entry.summary.slice(0, maxSummary);
|
|
1262
|
+
lines.push(`${prefix}${color}${summaryText}${RESET}`);
|
|
1263
|
+
} else {
|
|
1264
|
+
lines.push(`${prefix}${color}${entry.summary}${RESET}`);
|
|
1265
|
+
const indent = " ".repeat(prefixWidth);
|
|
1266
|
+
for (const detailLine of entry.detail.split("\n")) {
|
|
1267
|
+
for (const wl of this.wrapText(detailLine, dw - prefixWidth)) {
|
|
1268
|
+
lines.push(`${indent}${color}${DIM}${wl}${RESET}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
lines.push("");
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return lines;
|
|
1275
|
+
}
|
|
1276
|
+
// ─── Text utilities ───────────────────────────────────────────────
|
|
1277
|
+
wrapText(text, maxWidth) {
|
|
1278
|
+
if (maxWidth <= 0) maxWidth = 1;
|
|
1279
|
+
if (!text) return [""];
|
|
1280
|
+
if (text.length <= maxWidth) return [text];
|
|
1281
|
+
const words = text.split(" ");
|
|
1282
|
+
const result = [];
|
|
1283
|
+
let current = "";
|
|
1284
|
+
for (const word of words) {
|
|
1285
|
+
if (!current) {
|
|
1286
|
+
current = word;
|
|
1287
|
+
} else if (current.length + 1 + word.length <= maxWidth) {
|
|
1288
|
+
current += " " + word;
|
|
1289
|
+
} else {
|
|
1290
|
+
result.push(current);
|
|
1291
|
+
current = word;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (current) result.push(current);
|
|
1295
|
+
return result.length ? result : [""];
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
function pad2(n) {
|
|
1299
|
+
return n < 10 ? `0${n}` : String(n);
|
|
1300
|
+
}
|
|
1301
|
+
function truncateAnsi(s, maxCols) {
|
|
1302
|
+
if (maxCols <= 0) return "";
|
|
1303
|
+
let visible = 0;
|
|
1304
|
+
let i = 0;
|
|
1305
|
+
while (i < s.length && visible < maxCols) {
|
|
1306
|
+
if (s[i] === "\x1B") {
|
|
1307
|
+
const end = s.indexOf("m", i);
|
|
1308
|
+
if (end !== -1) {
|
|
1309
|
+
i = end + 1;
|
|
1310
|
+
} else {
|
|
1311
|
+
i++;
|
|
1312
|
+
}
|
|
1313
|
+
} else {
|
|
1314
|
+
visible++;
|
|
1315
|
+
i++;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return s.slice(0, i) + RESET;
|
|
1319
|
+
}
|
|
1320
|
+
function redactAudio(msg) {
|
|
1321
|
+
if (!msg || typeof msg !== "object") return msg;
|
|
1322
|
+
const clone = { ...msg };
|
|
1323
|
+
if (clone.type === "CUSTOM" && clone.value && typeof clone.value === "object") {
|
|
1324
|
+
const name = clone.name || "";
|
|
1325
|
+
if (name === "voice.audio" && clone.value.data) {
|
|
1326
|
+
clone.value = { ...clone.value, data: "[audio]" };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return clone;
|
|
1330
|
+
}
|
|
1331
|
+
function buildDebugSummary(_direction, msg) {
|
|
1332
|
+
if (!msg || typeof msg !== "object") return String(msg);
|
|
1333
|
+
if (msg.type === "CUSTOM") {
|
|
1334
|
+
const name = msg.name || "?";
|
|
1335
|
+
if (name === "voice.audio") return name;
|
|
1336
|
+
if (msg.value && typeof msg.value === "object") {
|
|
1337
|
+
const keys = Object.keys(msg.value);
|
|
1338
|
+
if (keys.length <= 3) {
|
|
1339
|
+
return `${name} {${keys.join(", ")}}`;
|
|
1340
|
+
}
|
|
1341
|
+
return `${name} {${keys.length} keys}`;
|
|
1342
|
+
}
|
|
1343
|
+
return name;
|
|
1344
|
+
}
|
|
1345
|
+
const type = msg.type || "?";
|
|
1346
|
+
if (msg.delta) {
|
|
1347
|
+
const preview = msg.delta.length > 30 ? msg.delta.slice(0, 30) + "..." : msg.delta;
|
|
1348
|
+
return `${type} "${preview}"`;
|
|
1349
|
+
}
|
|
1350
|
+
if (msg.toolCallName) {
|
|
1351
|
+
return `${type} ${msg.toolCallName}`;
|
|
1352
|
+
}
|
|
1353
|
+
return type;
|
|
1354
|
+
}
|
|
1355
|
+
var SAMPLE_RATE = 24e3;
|
|
1356
|
+
var MIC_CHUNK_BYTES = 4800;
|
|
1357
|
+
function checkSoxAvailable() {
|
|
1358
|
+
try {
|
|
1359
|
+
execSync("which sox", { stdio: "pipe" });
|
|
1360
|
+
return true;
|
|
1361
|
+
} catch {
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
async function listInputDevices() {
|
|
1366
|
+
return listDevices("rec");
|
|
1367
|
+
}
|
|
1368
|
+
async function listOutputDevices() {
|
|
1369
|
+
return listDevices("play");
|
|
1370
|
+
}
|
|
1371
|
+
function listDevices(command) {
|
|
1372
|
+
return new Promise((resolve) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const proc = spawnSync(command, ["--list-devices"], {
|
|
1375
|
+
encoding: "utf-8",
|
|
1376
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1377
|
+
timeout: 5e3
|
|
1378
|
+
});
|
|
1379
|
+
const output = (proc.stdout ?? "") + (proc.stderr ?? "");
|
|
1380
|
+
const devices = [];
|
|
1381
|
+
const lines = output.split("\n");
|
|
1382
|
+
for (const line of lines) {
|
|
1383
|
+
const match = line.match(/(?:Input|Output)\s+Device\s+(\d+)\s*:\s*(.+)/i);
|
|
1384
|
+
if (match && match[1] && match[2]) {
|
|
1385
|
+
devices.push({
|
|
1386
|
+
id: match[1],
|
|
1387
|
+
name: match[2].trim(),
|
|
1388
|
+
isDefault: devices.length === 0
|
|
1389
|
+
// First device is typically the default
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (devices.length === 0) {
|
|
1394
|
+
devices.push({ id: "default", name: "System Default", isDefault: true });
|
|
1395
|
+
}
|
|
1396
|
+
resolve(devices);
|
|
1397
|
+
} catch {
|
|
1398
|
+
resolve([{ id: "default", name: "System Default", isDefault: true }]);
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
async function selectDevice(devices, label) {
|
|
1403
|
+
if (devices.length <= 1) {
|
|
1404
|
+
return devices[0];
|
|
1405
|
+
}
|
|
1406
|
+
const rl = readline.createInterface({
|
|
1407
|
+
input: process.stdin,
|
|
1408
|
+
output: process.stderr,
|
|
1409
|
+
terminal: true
|
|
1410
|
+
});
|
|
1411
|
+
process.stderr.write(`
|
|
1412
|
+
${label}:
|
|
1413
|
+
`);
|
|
1414
|
+
for (let i = 0; i < devices.length; i++) {
|
|
1415
|
+
const dev = devices[i];
|
|
1416
|
+
const marker = dev.isDefault ? " (default)" : "";
|
|
1417
|
+
process.stderr.write(` ${i + 1}. ${dev.name}${marker}
|
|
1418
|
+
`);
|
|
1419
|
+
}
|
|
1420
|
+
return new Promise((resolve) => {
|
|
1421
|
+
const defaultIdx = devices.findIndex((d) => d.isDefault);
|
|
1422
|
+
const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : 1;
|
|
1423
|
+
rl.question(` Select [${defaultNum}]: `, (answer) => {
|
|
1424
|
+
rl.close();
|
|
1425
|
+
const num = answer.trim() ? parseInt(answer.trim(), 10) : defaultNum;
|
|
1426
|
+
const idx = Math.max(0, Math.min(devices.length - 1, num - 1));
|
|
1427
|
+
resolve(devices[idx]);
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
var MicCapture = class {
|
|
1432
|
+
process = null;
|
|
1433
|
+
onData;
|
|
1434
|
+
buffer = Buffer.alloc(0);
|
|
1435
|
+
constructor(onData) {
|
|
1436
|
+
this.onData = onData;
|
|
1437
|
+
}
|
|
1438
|
+
/** Start recording from the microphone */
|
|
1439
|
+
start(deviceId) {
|
|
1440
|
+
const args = [
|
|
1441
|
+
"-q",
|
|
1442
|
+
// quiet
|
|
1443
|
+
"-t",
|
|
1444
|
+
"raw",
|
|
1445
|
+
// raw PCM output
|
|
1446
|
+
"-r",
|
|
1447
|
+
String(SAMPLE_RATE),
|
|
1448
|
+
"-e",
|
|
1449
|
+
"signed-integer",
|
|
1450
|
+
"-b",
|
|
1451
|
+
"16",
|
|
1452
|
+
// 16-bit
|
|
1453
|
+
"-c",
|
|
1454
|
+
"1",
|
|
1455
|
+
// mono
|
|
1456
|
+
"-L",
|
|
1457
|
+
// little-endian
|
|
1458
|
+
"-"
|
|
1459
|
+
// stdout
|
|
1460
|
+
];
|
|
1461
|
+
const env = { ...process.env };
|
|
1462
|
+
if (deviceId && deviceId !== "default") {
|
|
1463
|
+
env.AUDIODEV = deviceId;
|
|
1464
|
+
}
|
|
1465
|
+
this.process = spawn("rec", args, {
|
|
1466
|
+
env,
|
|
1467
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1468
|
+
});
|
|
1469
|
+
this.process.stdout?.on("data", (data) => {
|
|
1470
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
1471
|
+
while (this.buffer.length >= MIC_CHUNK_BYTES) {
|
|
1472
|
+
const chunk = this.buffer.subarray(0, MIC_CHUNK_BYTES);
|
|
1473
|
+
this.buffer = this.buffer.subarray(MIC_CHUNK_BYTES);
|
|
1474
|
+
this.onData(Buffer.from(chunk));
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
this.process.stderr?.on("data", (data) => {
|
|
1478
|
+
const msg = data.toString().trim();
|
|
1479
|
+
if (msg && !msg.includes("WARN")) {
|
|
1480
|
+
process.stderr.write(`[mic] ${msg}
|
|
1481
|
+
`);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
this.process.on("error", (err) => {
|
|
1485
|
+
process.stderr.write(`[mic] Process error: ${err.message}
|
|
1486
|
+
`);
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
/** Stop recording */
|
|
1490
|
+
stop() {
|
|
1491
|
+
if (this.process) {
|
|
1492
|
+
this.process.kill("SIGTERM");
|
|
1493
|
+
this.process = null;
|
|
1494
|
+
}
|
|
1495
|
+
this.buffer = Buffer.alloc(0);
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
var AudioPlayer = class extends EventEmitter {
|
|
1499
|
+
process = null;
|
|
1500
|
+
deviceId;
|
|
1501
|
+
// When true, we own the SoX process's stdin and are actively streaming to it.
|
|
1502
|
+
// When false with a process, the stdin has been closed and SoX is draining.
|
|
1503
|
+
streaming = false;
|
|
1504
|
+
// Buffer chunks when another response is already playing
|
|
1505
|
+
pendingChunks = [];
|
|
1506
|
+
pendingBytes = 0;
|
|
1507
|
+
// Queue of complete response buffers waiting to play
|
|
1508
|
+
queue = [];
|
|
1509
|
+
// Playback position tracking for barge-in
|
|
1510
|
+
currentMessageId;
|
|
1511
|
+
playbackStartTime = 0;
|
|
1512
|
+
// Date.now() when SoX started
|
|
1513
|
+
bytesWritten = 0;
|
|
1514
|
+
// total bytes piped to SoX for duration estimation
|
|
1515
|
+
constructor(deviceId) {
|
|
1516
|
+
super();
|
|
1517
|
+
this.deviceId = deviceId;
|
|
1518
|
+
}
|
|
1519
|
+
/** Check if audio is currently playing or queued */
|
|
1520
|
+
get isPlaying() {
|
|
1521
|
+
return this.process !== null || this.queue.length > 0;
|
|
1522
|
+
}
|
|
1523
|
+
/** Get the message ID of the currently playing audio */
|
|
1524
|
+
get playingMessageId() {
|
|
1525
|
+
return this.currentMessageId;
|
|
1526
|
+
}
|
|
1527
|
+
/** Duration of audio written so far in ms */
|
|
1528
|
+
get currentDurationMs() {
|
|
1529
|
+
return this.bytesWritten / (SAMPLE_RATE * 2) * 1e3;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Estimate current playback position in milliseconds.
|
|
1533
|
+
* Uses wall-clock time since SoX started playing.
|
|
1534
|
+
*/
|
|
1535
|
+
getPlaybackTimeMs() {
|
|
1536
|
+
if (!this.process || this.playbackStartTime === 0) return null;
|
|
1537
|
+
const elapsed = Date.now() - this.playbackStartTime;
|
|
1538
|
+
return Math.min(elapsed, this.currentDurationMs);
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Stream PCM16 audio data for playback.
|
|
1542
|
+
* On the first chunk, spawns SoX and begins playback immediately.
|
|
1543
|
+
*/
|
|
1544
|
+
write(pcm16) {
|
|
1545
|
+
if (this.process && !this.streaming) {
|
|
1546
|
+
this.pendingChunks.push(pcm16);
|
|
1547
|
+
this.pendingBytes += pcm16.length;
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (!this.process) {
|
|
1551
|
+
this.spawnSoxProcess();
|
|
1552
|
+
}
|
|
1553
|
+
this.process.stdin.write(pcm16);
|
|
1554
|
+
this.bytesWritten += pcm16.length;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* End the current audio response.
|
|
1558
|
+
* If streaming, closes stdin to let SoX finish playing.
|
|
1559
|
+
* If buffering behind another response, adds to the queue.
|
|
1560
|
+
*/
|
|
1561
|
+
endResponse(messageId) {
|
|
1562
|
+
if (this.streaming && this.process) {
|
|
1563
|
+
this.currentMessageId = messageId;
|
|
1564
|
+
this.streaming = false;
|
|
1565
|
+
this.process.stdin.end();
|
|
1566
|
+
} else if (this.pendingChunks.length > 0) {
|
|
1567
|
+
const audio = Buffer.concat(this.pendingChunks);
|
|
1568
|
+
this.pendingChunks = [];
|
|
1569
|
+
this.pendingBytes = 0;
|
|
1570
|
+
this.queue.push({ audio, messageId });
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/** Stop current playback, discard all queued audio */
|
|
1574
|
+
stop() {
|
|
1575
|
+
this.pendingChunks = [];
|
|
1576
|
+
this.pendingBytes = 0;
|
|
1577
|
+
this.queue = [];
|
|
1578
|
+
this.streaming = false;
|
|
1579
|
+
const wasPlaying = this.process !== null;
|
|
1580
|
+
if (this.process) {
|
|
1581
|
+
try {
|
|
1582
|
+
this.process.stdin?.destroy();
|
|
1583
|
+
this.process.kill("SIGTERM");
|
|
1584
|
+
} catch {
|
|
1585
|
+
}
|
|
1586
|
+
this.process = null;
|
|
1587
|
+
}
|
|
1588
|
+
this.currentMessageId = void 0;
|
|
1589
|
+
this.playbackStartTime = 0;
|
|
1590
|
+
this.bytesWritten = 0;
|
|
1591
|
+
if (wasPlaying) {
|
|
1592
|
+
this.emit("done");
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/** Spawn a SoX `play` process reading raw PCM from stdin */
|
|
1596
|
+
spawnSoxProcess() {
|
|
1597
|
+
this.bytesWritten = 0;
|
|
1598
|
+
this.currentMessageId = void 0;
|
|
1599
|
+
const args = [
|
|
1600
|
+
"-q",
|
|
1601
|
+
// quiet
|
|
1602
|
+
"-t",
|
|
1603
|
+
"raw",
|
|
1604
|
+
"-r",
|
|
1605
|
+
String(SAMPLE_RATE),
|
|
1606
|
+
"-e",
|
|
1607
|
+
"signed-integer",
|
|
1608
|
+
"-b",
|
|
1609
|
+
"16",
|
|
1610
|
+
"-c",
|
|
1611
|
+
"1",
|
|
1612
|
+
"-L",
|
|
1613
|
+
// little-endian
|
|
1614
|
+
"-"
|
|
1615
|
+
// read from stdin
|
|
1616
|
+
];
|
|
1617
|
+
const env = { ...process.env };
|
|
1618
|
+
if (this.deviceId && this.deviceId !== "default") {
|
|
1619
|
+
env.AUDIODEV = this.deviceId;
|
|
1620
|
+
}
|
|
1621
|
+
const proc = spawn("play", args, {
|
|
1622
|
+
env,
|
|
1623
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1624
|
+
});
|
|
1625
|
+
proc.on("error", (err) => {
|
|
1626
|
+
process.stderr.write(`[player] Process error: ${err.message}
|
|
1627
|
+
`);
|
|
1628
|
+
});
|
|
1629
|
+
proc.on("exit", (code, _signal) => {
|
|
1630
|
+
if (code !== null && code !== 0 && code !== 143) {
|
|
1631
|
+
process.stderr.write(`[player] SoX exited with code ${code}
|
|
1632
|
+
`);
|
|
1633
|
+
}
|
|
1634
|
+
if (this.process === proc) {
|
|
1635
|
+
this.process = null;
|
|
1636
|
+
this.streaming = false;
|
|
1637
|
+
if (this.queue.length > 0) {
|
|
1638
|
+
const next = this.queue.shift();
|
|
1639
|
+
this.playQueued(next.audio, next.messageId);
|
|
1640
|
+
} else {
|
|
1641
|
+
this.emit("done");
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
proc.stderr?.on("data", (data) => {
|
|
1646
|
+
const msg = data.toString().trim();
|
|
1647
|
+
if (msg) {
|
|
1648
|
+
process.stderr.write(`[player:sox] ${msg}
|
|
1649
|
+
`);
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
proc.stdout?.resume();
|
|
1653
|
+
this.process = proc;
|
|
1654
|
+
this.streaming = true;
|
|
1655
|
+
this.playbackStartTime = Date.now();
|
|
1656
|
+
this.emit("playing");
|
|
1657
|
+
}
|
|
1658
|
+
/** Play a complete pre-buffered response (from the queue) */
|
|
1659
|
+
playQueued(audio, messageId) {
|
|
1660
|
+
this.spawnSoxProcess();
|
|
1661
|
+
this.currentMessageId = messageId;
|
|
1662
|
+
this.process.stdin.write(audio);
|
|
1663
|
+
this.bytesWritten = audio.length;
|
|
1664
|
+
this.process.stdin.end();
|
|
1665
|
+
this.streaming = false;
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
// src/lib/chat-session.ts
|
|
1670
|
+
async function runChatSession(session, args) {
|
|
1671
|
+
const debug = !!args.debug;
|
|
1672
|
+
const isVoice = !!args.voice;
|
|
1673
|
+
const isRealtime = !!args.realtime || isVoice;
|
|
1674
|
+
const message = typeof args.message === "string" ? args.message : void 0;
|
|
1675
|
+
const autoApprove = !!args["auto-approve"];
|
|
1676
|
+
if (args["list-devices"]) {
|
|
1677
|
+
await listAudioDevices();
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
if (!message && !process.stdin.isTTY) {
|
|
1681
|
+
throw new CLIError(
|
|
1682
|
+
"Interactive mode requires a TTY. Use --message for non-interactive mode."
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
const allowWrite = !!args["allow-write"] || !!session.allowWrite;
|
|
1686
|
+
if (message) {
|
|
1687
|
+
await singleTurnChatSession(session, message, debug, autoApprove, allowWrite);
|
|
1688
|
+
} else if (isVoice) {
|
|
1689
|
+
await voiceChatSession(session, vadMode(args), args, debug);
|
|
1690
|
+
} else if (isRealtime) {
|
|
1691
|
+
await realtimeChatSession(session, vadMode(args), debug);
|
|
1692
|
+
} else {
|
|
1693
|
+
await interactiveChatSession(session, debug, autoApprove, allowWrite);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
function vadMode(args) {
|
|
1697
|
+
return typeof args.vad === "string" ? args.vad : "server";
|
|
1698
|
+
}
|
|
1699
|
+
async function singleTurnChatSession(session, message, debug, autoApprove = false, allowWrite = false) {
|
|
1700
|
+
const aguiClient = new AGUIClient({
|
|
1701
|
+
baseUrl: session.ctx.envConfig.apiUrl,
|
|
1702
|
+
workspaceUuid: session.workspaceUuid,
|
|
1703
|
+
chatId: session.chatId,
|
|
1704
|
+
endpointUrl: session.aguiEndpointUrl,
|
|
1705
|
+
accessToken: session.ctx.accessToken,
|
|
1706
|
+
debug,
|
|
1707
|
+
model: session.model,
|
|
1708
|
+
appContext: session.appContext
|
|
1709
|
+
});
|
|
1710
|
+
const onEvent = (event) => {
|
|
1711
|
+
const type = event.type?.toLowerCase() ?? "";
|
|
1712
|
+
if (type === "tool_call_start") {
|
|
1713
|
+
const name = event.toolCallName ?? event.name ?? "";
|
|
1714
|
+
process.stderr.write(` [${name}]
|
|
1715
|
+
`);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
const { streamText, hasToolCalls } = await runConciergeAguiLoop(
|
|
1719
|
+
aguiClient,
|
|
1720
|
+
[{ role: "user", content: message }],
|
|
1721
|
+
onEvent,
|
|
1722
|
+
{
|
|
1723
|
+
autoApprove,
|
|
1724
|
+
allowWrite: allowWrite || !!session.allowWrite,
|
|
1725
|
+
trustCwd: session.trustCwd,
|
|
1726
|
+
trustDir: session.trustDir,
|
|
1727
|
+
nonInteractive: true
|
|
1728
|
+
}
|
|
1729
|
+
);
|
|
1730
|
+
if (streamText) {
|
|
1731
|
+
process.stdout.write(`\x1B[32mAssistant:\x1B[0m ${streamText}
|
|
1732
|
+
`);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (hasToolCalls) {
|
|
1736
|
+
process.stderr.write(" (waiting for completion...)\n");
|
|
1737
|
+
const maxWaitMs = 18e4;
|
|
1738
|
+
const pollIntervalMs = 2e3;
|
|
1739
|
+
const startTime = Date.now();
|
|
1740
|
+
let finalText = "";
|
|
1741
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
1742
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
1743
|
+
try {
|
|
1744
|
+
const msgs = await session.loadMessages();
|
|
1745
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
1746
|
+
const m = msgs[i];
|
|
1747
|
+
if (m.role === "assistant" && m.content && !m.tool_calls?.length) {
|
|
1748
|
+
finalText = m.content;
|
|
1749
|
+
break;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
if (finalText) break;
|
|
1753
|
+
} catch {
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
process.stdout.write(`\x1B[32mAssistant:\x1B[0m ${finalText || "(no text response)"}
|
|
1757
|
+
`);
|
|
1758
|
+
} else {
|
|
1759
|
+
process.stdout.write(`\x1B[32mAssistant:\x1B[0m (no response)
|
|
1760
|
+
`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
async function interactiveChatSession(session, debug, autoApprove, allowWrite) {
|
|
1764
|
+
const aguiClient = new AGUIClient({
|
|
1765
|
+
baseUrl: session.ctx.envConfig.apiUrl,
|
|
1766
|
+
workspaceUuid: session.workspaceUuid,
|
|
1767
|
+
chatId: session.chatId,
|
|
1768
|
+
endpointUrl: session.aguiEndpointUrl,
|
|
1769
|
+
accessToken: session.ctx.accessToken,
|
|
1770
|
+
debug,
|
|
1771
|
+
model: session.model,
|
|
1772
|
+
appContext: session.appContext
|
|
1773
|
+
});
|
|
1774
|
+
clearScreen();
|
|
1775
|
+
console.log("=".repeat(60));
|
|
1776
|
+
console.log(session.label);
|
|
1777
|
+
console.log("=".repeat(60));
|
|
1778
|
+
console.log(`Chat: ${session.chatTitle}`);
|
|
1779
|
+
console.log("Type your message and press Enter. Type 'exit' or Ctrl+C to quit.");
|
|
1780
|
+
console.log("=".repeat(60));
|
|
1781
|
+
console.log("");
|
|
1782
|
+
const existingMessages = await session.loadMessages();
|
|
1783
|
+
if (existingMessages.length > 0) {
|
|
1784
|
+
console.log("\x1B[90m--- Previous messages ---\x1B[0m\n");
|
|
1785
|
+
for (const msg of existingMessages.slice(-10)) {
|
|
1786
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
1787
|
+
if (text && text.trim()) {
|
|
1788
|
+
printMessage(text, msg.role);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
console.log("\x1B[90m--- End of history ---\x1B[0m\n");
|
|
1792
|
+
}
|
|
1793
|
+
const conversationMessages = [];
|
|
1794
|
+
for (const msg of existingMessages.slice(-10)) {
|
|
1795
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
1796
|
+
if (text && text.trim() && (msg.role === "user" || msg.role === "assistant")) {
|
|
1797
|
+
conversationMessages.push({ role: msg.role, content: text });
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1801
|
+
return new Promise((resolve) => {
|
|
1802
|
+
const prompt = () => {
|
|
1803
|
+
rl.question("\x1B[34mYou:\x1B[0m ", async (input) => {
|
|
1804
|
+
const trimmed = input.trim();
|
|
1805
|
+
if (!trimmed) {
|
|
1806
|
+
prompt();
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
if (trimmed.toLowerCase() === "exit" || trimmed.toLowerCase() === "quit") {
|
|
1810
|
+
console.log("\nGoodbye!");
|
|
1811
|
+
rl.close();
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
conversationMessages.push({ role: "user", content: trimmed });
|
|
1815
|
+
process.stdout.write("\n\x1B[32mAssistant:\x1B[0m ");
|
|
1816
|
+
let assistantContent = "";
|
|
1817
|
+
try {
|
|
1818
|
+
const { streamText } = await runConciergeAguiLoop(
|
|
1819
|
+
aguiClient,
|
|
1820
|
+
conversationMessages,
|
|
1821
|
+
(event) => {
|
|
1822
|
+
const delta = getTextDelta(event);
|
|
1823
|
+
if (delta) {
|
|
1824
|
+
process.stdout.write(delta);
|
|
1825
|
+
assistantContent += delta;
|
|
1826
|
+
}
|
|
1827
|
+
const errorMsg = getErrorMessage(event);
|
|
1828
|
+
if (errorMsg) console.log(`
|
|
1829
|
+
\x1B[31mError: ${errorMsg}\x1B[0m`);
|
|
1830
|
+
},
|
|
1831
|
+
{
|
|
1832
|
+
autoApprove,
|
|
1833
|
+
allowWrite: allowWrite || !!session.allowWrite,
|
|
1834
|
+
debug,
|
|
1835
|
+
trustCwd: session.trustCwd,
|
|
1836
|
+
trustDir: session.trustDir
|
|
1837
|
+
}
|
|
1838
|
+
);
|
|
1839
|
+
if (streamText && !assistantContent) {
|
|
1840
|
+
assistantContent = streamText;
|
|
1841
|
+
process.stdout.write(streamText);
|
|
1842
|
+
}
|
|
1843
|
+
if (assistantContent) {
|
|
1844
|
+
conversationMessages.push({ role: "assistant", content: assistantContent });
|
|
1845
|
+
}
|
|
1846
|
+
console.log("\n");
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
console.log(
|
|
1849
|
+
`
|
|
1850
|
+
\x1B[31mError: ${error instanceof Error ? error.message : error}\x1B[0m
|
|
1851
|
+
`
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
prompt();
|
|
1855
|
+
});
|
|
1856
|
+
};
|
|
1857
|
+
rl.on("close", () => {
|
|
1858
|
+
console.log("\nGoodbye!");
|
|
1859
|
+
resolve();
|
|
1860
|
+
});
|
|
1861
|
+
prompt();
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
function buildHistory(messages) {
|
|
1865
|
+
const history = [];
|
|
1866
|
+
for (const msg of messages.slice(-30)) {
|
|
1867
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
1868
|
+
if (msg.role === "user" || msg.role === "assistant") {
|
|
1869
|
+
if (text.trim()) {
|
|
1870
|
+
history.push({ role: msg.role, content: text });
|
|
1871
|
+
}
|
|
1872
|
+
if (msg.role === "assistant" && msg.tool_calls) {
|
|
1873
|
+
for (const tc of msg.tool_calls) {
|
|
1874
|
+
history.push({
|
|
1875
|
+
kind: "tool_call",
|
|
1876
|
+
id: tc.id,
|
|
1877
|
+
name: tc.function.name,
|
|
1878
|
+
args: tc.function.arguments || "",
|
|
1879
|
+
done: true
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
} else if (msg.role === "tool") {
|
|
1884
|
+
const toolName = msg.name || "tool";
|
|
1885
|
+
history.push({
|
|
1886
|
+
kind: "tool_result",
|
|
1887
|
+
toolCallId: msg.tool_call_id || "",
|
|
1888
|
+
toolName,
|
|
1889
|
+
content: text
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return history;
|
|
1894
|
+
}
|
|
1895
|
+
function wireRealtimeEvents(client, tui) {
|
|
1896
|
+
client.on("text_start", () => tui.streamStart());
|
|
1897
|
+
client.on("text_delta", (data) => tui.streamDelta(data.delta));
|
|
1898
|
+
client.on("text_end", () => tui.streamEnd());
|
|
1899
|
+
client.on("tool_call_start", (data) => {
|
|
1900
|
+
tui.toolCallStart(data.toolCallId, data.toolCallName);
|
|
1901
|
+
});
|
|
1902
|
+
client.on("tool_call_args", (data) => {
|
|
1903
|
+
tui.toolCallArgs(data.toolCallId, data.delta);
|
|
1904
|
+
});
|
|
1905
|
+
client.on("tool_call_end", (data) => {
|
|
1906
|
+
tui.toolCallEnd(data.toolCallId);
|
|
1907
|
+
});
|
|
1908
|
+
client.on("tool_call_result", (data) => {
|
|
1909
|
+
const toolName = tui.findToolCallName(data.toolCallId);
|
|
1910
|
+
const content = typeof data.result === "string" ? data.result : JSON.stringify(data.result);
|
|
1911
|
+
tui.toolCallResult(data.toolCallId, toolName, content);
|
|
1912
|
+
});
|
|
1913
|
+
client.on("run_error", (data) => {
|
|
1914
|
+
tui.showError(data.message);
|
|
1915
|
+
});
|
|
1916
|
+
client.on("error", (err) => {
|
|
1917
|
+
tui.showError(`Connection: ${err.message}`);
|
|
1918
|
+
});
|
|
1919
|
+
client.on("ws_recv", (msg) => tui.logDebug("in", msg));
|
|
1920
|
+
client.on("ws_send", (msg) => tui.logDebug("out", msg));
|
|
1921
|
+
}
|
|
1922
|
+
async function realtimeChatSession(session, vad, debug) {
|
|
1923
|
+
const client = new RealtimeClient({
|
|
1924
|
+
url: session.wsUrl,
|
|
1925
|
+
token: session.ctx.accessToken,
|
|
1926
|
+
chatId: session.chatId,
|
|
1927
|
+
vadMode: vad,
|
|
1928
|
+
debug
|
|
1929
|
+
});
|
|
1930
|
+
process.stderr.write(`Connecting to ${session.label.toLowerCase()}...
|
|
1931
|
+
`);
|
|
1932
|
+
try {
|
|
1933
|
+
await client.connect();
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
throw new CLIError(`Failed to connect: ${err instanceof Error ? err.message : err}`);
|
|
1936
|
+
}
|
|
1937
|
+
const existingMessages = await session.loadMessages();
|
|
1938
|
+
const history = buildHistory(existingMessages);
|
|
1939
|
+
const tui = new ChatTUI({
|
|
1940
|
+
onSend: (text) => {
|
|
1941
|
+
client.sendTextMessage(text);
|
|
1942
|
+
},
|
|
1943
|
+
onExit: () => {
|
|
1944
|
+
client.close();
|
|
1945
|
+
},
|
|
1946
|
+
debug,
|
|
1947
|
+
env: session.ctx.env
|
|
1948
|
+
});
|
|
1949
|
+
wireRealtimeEvents(client, tui);
|
|
1950
|
+
client.on("closed", () => {
|
|
1951
|
+
tui.stop();
|
|
1952
|
+
console.log("Disconnected.");
|
|
1953
|
+
});
|
|
1954
|
+
tui.loadHistory(history);
|
|
1955
|
+
tui.start();
|
|
1956
|
+
await new Promise((resolve) => {
|
|
1957
|
+
client.once("closed", resolve);
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
async function voiceChatSession(session, vad, args, debug) {
|
|
1961
|
+
if (!checkSoxAvailable()) {
|
|
1962
|
+
throw new CLIError(
|
|
1963
|
+
"Voice mode requires SoX. Install it:\n macOS: brew install sox\n Linux: apt install sox\n Windows: choco install sox"
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
const inputDeviceName = typeof args["input-device"] === "string" ? args["input-device"] : void 0;
|
|
1967
|
+
const outputDeviceName = typeof args["output-device"] === "string" ? args["output-device"] : void 0;
|
|
1968
|
+
let inputDeviceId;
|
|
1969
|
+
let outputDeviceId;
|
|
1970
|
+
if (!inputDeviceName || !outputDeviceName) {
|
|
1971
|
+
process.stderr.write("\n Audio Devices\n");
|
|
1972
|
+
}
|
|
1973
|
+
if (inputDeviceName) {
|
|
1974
|
+
inputDeviceId = inputDeviceName;
|
|
1975
|
+
} else {
|
|
1976
|
+
const inputDevices = await listInputDevices();
|
|
1977
|
+
const selected = await selectDevice(inputDevices, "Input (microphone)");
|
|
1978
|
+
inputDeviceId = selected.id;
|
|
1979
|
+
}
|
|
1980
|
+
if (outputDeviceName) {
|
|
1981
|
+
outputDeviceId = outputDeviceName;
|
|
1982
|
+
} else {
|
|
1983
|
+
const outputDevices = await listOutputDevices();
|
|
1984
|
+
const selected = await selectDevice(outputDevices, "Output (speaker)");
|
|
1985
|
+
outputDeviceId = selected.id;
|
|
1986
|
+
}
|
|
1987
|
+
const client = new RealtimeClient({
|
|
1988
|
+
url: session.wsUrl,
|
|
1989
|
+
token: session.ctx.accessToken,
|
|
1990
|
+
chatId: session.chatId,
|
|
1991
|
+
vadMode: vad,
|
|
1992
|
+
debug
|
|
1993
|
+
});
|
|
1994
|
+
process.stderr.write(`
|
|
1995
|
+
Connecting to ${session.label.toLowerCase()}...
|
|
1996
|
+
`);
|
|
1997
|
+
try {
|
|
1998
|
+
await client.connect();
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
throw new CLIError(`Failed to connect: ${err instanceof Error ? err.message : err}`);
|
|
2001
|
+
}
|
|
2002
|
+
const existingMessages = await session.loadMessages();
|
|
2003
|
+
const history = buildHistory(existingMessages);
|
|
2004
|
+
const mic = new MicCapture((pcm16) => {
|
|
2005
|
+
if (!client.isConnected()) return;
|
|
2006
|
+
client.sendAudio(pcm16);
|
|
2007
|
+
});
|
|
2008
|
+
const player = new AudioPlayer(outputDeviceId);
|
|
2009
|
+
function cleanup() {
|
|
2010
|
+
mic.stop();
|
|
2011
|
+
player.stop();
|
|
2012
|
+
client.close();
|
|
2013
|
+
}
|
|
2014
|
+
const tui = new ChatTUI({
|
|
2015
|
+
onSend: (text) => {
|
|
2016
|
+
client.sendTextMessage(text);
|
|
2017
|
+
},
|
|
2018
|
+
onExit: () => {
|
|
2019
|
+
cleanup();
|
|
2020
|
+
},
|
|
2021
|
+
debug,
|
|
2022
|
+
env: session.ctx.env
|
|
2023
|
+
});
|
|
2024
|
+
wireRealtimeEvents(client, tui);
|
|
2025
|
+
client.on("audio", (data) => {
|
|
2026
|
+
player.write(data.pcm16);
|
|
2027
|
+
});
|
|
2028
|
+
client.on("audio_end", (data) => {
|
|
2029
|
+
player.endResponse(data?.messageId);
|
|
2030
|
+
});
|
|
2031
|
+
client.on("interrupt", () => {
|
|
2032
|
+
const playbackMs = player.getPlaybackTimeMs();
|
|
2033
|
+
const messageId = player.playingMessageId;
|
|
2034
|
+
if (playbackMs !== null && playbackMs > 0 && player.isPlaying) {
|
|
2035
|
+
client.sendInterrupt(playbackMs, messageId);
|
|
2036
|
+
player.stop();
|
|
2037
|
+
if (debug) {
|
|
2038
|
+
tui.logDebug("in", {
|
|
2039
|
+
type: "CUSTOM",
|
|
2040
|
+
name: "voice.interrupt",
|
|
2041
|
+
value: { action: "echoed", audioEndMs: playbackMs, messageId }
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
} else if (debug) {
|
|
2045
|
+
tui.logDebug("in", {
|
|
2046
|
+
type: "CUSTOM",
|
|
2047
|
+
name: "voice.interrupt",
|
|
2048
|
+
value: { action: "ignored", wasPlaying: player.isPlaying }
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
client.on("truncate", (data) => {
|
|
2053
|
+
player.stop();
|
|
2054
|
+
tui.truncateAssistant(
|
|
2055
|
+
data.content ?? "",
|
|
2056
|
+
data.originalContent,
|
|
2057
|
+
data.truncationReason ?? "barge_in",
|
|
2058
|
+
data.truncatedAtMs
|
|
2059
|
+
);
|
|
2060
|
+
});
|
|
2061
|
+
client.on("response_message", (data) => {
|
|
2062
|
+
if (data.role === "user" && data.content) {
|
|
2063
|
+
tui.addTranscription(data.content);
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
client.on("closed", () => {
|
|
2067
|
+
tui.stop();
|
|
2068
|
+
cleanup();
|
|
2069
|
+
console.log("Disconnected.");
|
|
2070
|
+
});
|
|
2071
|
+
tui.loadHistory(history);
|
|
2072
|
+
tui.start();
|
|
2073
|
+
mic.start(inputDeviceId);
|
|
2074
|
+
await new Promise((resolve) => {
|
|
2075
|
+
client.once("closed", resolve);
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
async function listAudioDevices() {
|
|
2079
|
+
if (!checkSoxAvailable()) {
|
|
2080
|
+
throw new CLIError(
|
|
2081
|
+
"Device listing requires SoX. Install it:\n macOS: brew install sox\n Linux: apt install sox\n Windows: choco install sox"
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
const inputs = await listInputDevices();
|
|
2085
|
+
const outputs = await listOutputDevices();
|
|
2086
|
+
console.log("Input devices (microphone):");
|
|
2087
|
+
for (const dev of inputs) {
|
|
2088
|
+
const marker = dev.isDefault ? " (default)" : "";
|
|
2089
|
+
console.log(` ${dev.id}: ${dev.name}${marker}`);
|
|
2090
|
+
}
|
|
2091
|
+
console.log("\nOutput devices (speaker):");
|
|
2092
|
+
for (const dev of outputs) {
|
|
2093
|
+
const marker = dev.isDefault ? " (default)" : "";
|
|
2094
|
+
console.log(` ${dev.id}: ${dev.name}${marker}`);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// src/commands/concierge.ts
|
|
2099
|
+
async function conciergeOutboundCommand(hctx) {
|
|
2100
|
+
const ctx = requireAuth(hctx.ctx);
|
|
2101
|
+
const { args } = hctx;
|
|
2102
|
+
if (args.call && args.sms) {
|
|
2103
|
+
throw new CLIError("--call and --sms are mutually exclusive");
|
|
2104
|
+
}
|
|
2105
|
+
if (args.call) {
|
|
2106
|
+
const result = await originateConciergeCall(ctx);
|
|
2107
|
+
process.stderr.write(
|
|
2108
|
+
`Concierge call started. callSid=${result.callSid} startedAt=${result.startedAt}
|
|
2109
|
+
`
|
|
2110
|
+
);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (args.sms) {
|
|
2114
|
+
const result = await originateConciergeSms(ctx);
|
|
2115
|
+
process.stderr.write(
|
|
2116
|
+
`Concierge SMS started. messageSid=${result.messageSid} conversationId=${result.conversationId}
|
|
2117
|
+
`
|
|
2118
|
+
);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
throw new CLIError(
|
|
2122
|
+
"Use `gr concierge chat --call`, `gr concierge chat --sms`, or `gr concierge chat` for interactive mode."
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
async function conciergeCommand(hctx) {
|
|
2126
|
+
const ctx = requireAuth(hctx.ctx);
|
|
2127
|
+
const { args } = hctx;
|
|
2128
|
+
if (args.call || args.sms) {
|
|
2129
|
+
if (args.call && args.sms) {
|
|
2130
|
+
throw new CLIError("--call and --sms are mutually exclusive");
|
|
2131
|
+
}
|
|
2132
|
+
if (args.voice || args.realtime) {
|
|
2133
|
+
throw new CLIError("--call/--sms cannot be combined with --voice or --realtime");
|
|
2134
|
+
}
|
|
2135
|
+
await conciergeOutboundCommand(hctx);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const chatIdArg = typeof args["chat-id"] === "string" ? args["chat-id"] : void 0;
|
|
2139
|
+
const message = typeof args.message === "string" ? args.message : void 0;
|
|
2140
|
+
const chat = await getOrCreateConciergeChat(ctx, chatIdArg, !!message);
|
|
2141
|
+
const session = buildConciergeSession(ctx, chat, args);
|
|
2142
|
+
await runChatSession(session, args);
|
|
2143
|
+
}
|
|
2144
|
+
function buildConciergeSession(ctx, chat, args) {
|
|
2145
|
+
const wsBase = ctx.envConfig.apiUrl.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
|
|
2146
|
+
return {
|
|
2147
|
+
ctx,
|
|
2148
|
+
workspaceUuid: "",
|
|
2149
|
+
chatId: chat.id,
|
|
2150
|
+
chatUuid: chat.uuid,
|
|
2151
|
+
chatTitle: chat.title || chat.uuid,
|
|
2152
|
+
aguiEndpointUrl: `${ctx.envConfig.apiUrl}/api/v2/concierge/chats/${chat.id}/chat/agui`,
|
|
2153
|
+
wsUrl: `${wsBase}/api/v2/concierge/voice/stream-ws`,
|
|
2154
|
+
loadMessages: () => getConciergeChatMessages(ctx, chat.id).catch(() => []),
|
|
2155
|
+
label: "Concierge",
|
|
2156
|
+
model: void 0,
|
|
2157
|
+
// concierge doesn't send model field
|
|
2158
|
+
appContext: buildCliConciergeAppContext(),
|
|
2159
|
+
allowWrite: true,
|
|
2160
|
+
trustCwd: !!args["trust-cwd"],
|
|
2161
|
+
trustDir: typeof args["trust-dir"] === "string" ? args["trust-dir"] : void 0
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
async function getOrCreateConciergeChat(ctx, chatIdArg, skipSelection) {
|
|
2165
|
+
if (chatIdArg) {
|
|
2166
|
+
const id = Number(chatIdArg);
|
|
2167
|
+
if (isNaN(id)) throw new CLIError("--chat-id must be a number");
|
|
2168
|
+
return getConciergeChat(ctx, id);
|
|
2169
|
+
}
|
|
2170
|
+
if (skipSelection) {
|
|
2171
|
+
const chat = await createConciergeChat(ctx);
|
|
2172
|
+
process.stderr.write(`Created concierge chat #${chat.id}
|
|
2173
|
+
`);
|
|
2174
|
+
return chat;
|
|
2175
|
+
}
|
|
2176
|
+
const chats = await listConciergeChats(ctx);
|
|
2177
|
+
const { item: selectedChat, isNew } = await selectFromList(chats, {
|
|
2178
|
+
title: "Select a concierge chat or create a new one",
|
|
2179
|
+
labelFn: (c) => c.title || `Chat ${c.uuid.slice(0, 8)}...`,
|
|
2180
|
+
detailFn: (c) => formatTimestamp(c.updatedAt),
|
|
2181
|
+
emptyMessage: "No existing concierge chats.",
|
|
2182
|
+
allowNew: true,
|
|
2183
|
+
newLabel: "+ Start new chat"
|
|
2184
|
+
});
|
|
2185
|
+
if (!selectedChat && !isNew) {
|
|
2186
|
+
throw new CLIError("No chat selected.");
|
|
2187
|
+
}
|
|
2188
|
+
if (isNew) {
|
|
2189
|
+
const chat = await createConciergeChat(ctx);
|
|
2190
|
+
process.stderr.write(`Created concierge chat #${chat.id}
|
|
2191
|
+
`);
|
|
2192
|
+
return chat;
|
|
2193
|
+
}
|
|
2194
|
+
return selectedChat;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
export { buildCliConciergeAppContext, conciergeCommand, conciergeOutboundCommand, runChatSession };
|
|
2198
|
+
//# sourceMappingURL=chunk-QVG4L6BE.js.map
|
|
2199
|
+
//# sourceMappingURL=chunk-QVG4L6BE.js.map
|