@leg3ndy/otto-bridge 0.9.1 → 1.0.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.
@@ -0,0 +1,490 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { defaultDeviceName, getBridgeConfigPath, loadBridgeConfig, resolveApiBaseUrl, resolveExecutorConfig, } from "./config.js";
5
+ import { formatManagedBridgeExtensionStatus, isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, } from "./extensions.js";
6
+ import { pairDevice } from "./pairing.js";
7
+ import { BridgeRuntime } from "./runtime.js";
8
+ import { cancelRuntimeCliJob, confirmRuntimeCliJob, getRuntimeCliJob, submitRuntimeCliAssistantPrompt, } from "./runtime_cli_client.js";
9
+ import { BRIDGE_PACKAGE_NAME, BRIDGE_VERSION, DEFAULT_API_BASE_URL, } from "./types.js";
10
+ const ANSI = {
11
+ reset: "\u001b[0m",
12
+ dim: "\u001b[2m",
13
+ bold: "\u001b[1m",
14
+ coral: "\u001b[38;5;216m",
15
+ blue: "\u001b[38;5;111m",
16
+ teal: "\u001b[38;5;80m",
17
+ amber: "\u001b[38;5;221m",
18
+ red: "\u001b[38;5;203m",
19
+ green: "\u001b[38;5;114m",
20
+ white: "\u001b[38;5;255m",
21
+ };
22
+ const OTTOAI_BANNER = [
23
+ " ██████╗ ████████╗████████╗ ██████╗ █████╗ ██╗",
24
+ "██╔═══██╗╚══██╔══╝╚══██╔══╝██╔═══██╗ ██╔══██╗██║",
25
+ "██║ ██║ ██║ ██║ ██║ ██║ ███████║██║",
26
+ "██║ ██║ ██║ ██║ ██║ ██║ ██╔══██║██║",
27
+ "╚██████╔╝ ██║ ██║ ╚██████╔╝ ██║ ██║██║",
28
+ " ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝",
29
+ ];
30
+ function style(text, color, enabled = true) {
31
+ if (!enabled) {
32
+ return text;
33
+ }
34
+ return `${color}${text}${ANSI.reset}`;
35
+ }
36
+ function supportsAnsi() {
37
+ return Boolean(output.isTTY);
38
+ }
39
+ function renderBanner() {
40
+ const enabled = supportsAnsi();
41
+ const lines = OTTOAI_BANNER.map((line) => style(line, ANSI.coral, enabled));
42
+ const title = `${BRIDGE_PACKAGE_NAME} v${BRIDGE_VERSION}`;
43
+ const subtitle = "Terminal bridge, pairing wizard and local Otto console";
44
+ return [
45
+ lines.join("\n"),
46
+ "",
47
+ `${style("OTTO BRIDGE", ANSI.blue, enabled)} ${style(title, ANSI.white, enabled)}`,
48
+ `${style(subtitle, ANSI.dim, enabled)}`,
49
+ ].join("\n");
50
+ }
51
+ function printSection(title) {
52
+ const enabled = supportsAnsi();
53
+ console.log(`\n${style(title, ANSI.blue, enabled)}`);
54
+ }
55
+ function printMuted(message) {
56
+ console.log(style(message, ANSI.dim, supportsAnsi()));
57
+ }
58
+ function printSuccess(message) {
59
+ console.log(style(message, ANSI.green, supportsAnsi()));
60
+ }
61
+ function printWarning(message) {
62
+ console.log(style(message, ANSI.amber, supportsAnsi()));
63
+ }
64
+ function printError(message) {
65
+ console.log(style(message, ANSI.red, supportsAnsi()));
66
+ }
67
+ function normalizeText(value) {
68
+ return String(value || "").trim();
69
+ }
70
+ function truncate(text, max = 180) {
71
+ const value = normalizeText(text);
72
+ if (value.length <= max) {
73
+ return value;
74
+ }
75
+ return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
76
+ }
77
+ function delay(ms) {
78
+ return new Promise((resolve) => setTimeout(resolve, ms));
79
+ }
80
+ async function createPromptInterface() {
81
+ return createInterface({
82
+ input,
83
+ output,
84
+ terminal: true,
85
+ });
86
+ }
87
+ async function ask(rl, label, options) {
88
+ const defaultValue = normalizeText(options?.defaultValue);
89
+ const suffix = defaultValue ? ` ${style(`[${defaultValue}]`, ANSI.dim, supportsAnsi())}` : "";
90
+ const answer = normalizeText(await rl.question(`${style("›", ANSI.coral, supportsAnsi())} ${label}${suffix}: `));
91
+ if (answer) {
92
+ return answer;
93
+ }
94
+ if (defaultValue) {
95
+ return defaultValue;
96
+ }
97
+ if (options?.allowEmpty) {
98
+ return "";
99
+ }
100
+ return await ask(rl, label, options);
101
+ }
102
+ async function askYesNo(rl, question, defaultValue = true) {
103
+ const answer = normalizeText(await rl.question(`${style("?", ANSI.blue, supportsAnsi())} ${question} ${style(defaultValue ? "[Y/n]" : "[y/N]", ANSI.dim, supportsAnsi())}: `)).toLowerCase();
104
+ if (!answer) {
105
+ return defaultValue;
106
+ }
107
+ return ["y", "yes", "s", "sim"].includes(answer);
108
+ }
109
+ async function pauseForEnter(rl, message = "Pressione Enter para continuar") {
110
+ await rl.question(`${style("↵", ANSI.dim, supportsAnsi())} ${message}`);
111
+ }
112
+ async function chooseExecutor(rl, current) {
113
+ const defaultType = current?.type || resolveExecutorConfig().type;
114
+ console.log([
115
+ `${style("1.", ANSI.coral, supportsAnsi())} native-macos ${style("(Mac real, runtime local)", ANSI.dim, supportsAnsi())}`,
116
+ `${style("2.", ANSI.coral, supportsAnsi())} mock ${style("(ambiente de teste)", ANSI.dim, supportsAnsi())}`,
117
+ ].join("\n"));
118
+ const selection = await ask(rl, "Executor", {
119
+ defaultValue: defaultType === "mock" ? "2" : "1",
120
+ });
121
+ if (selection === "2" || selection.toLowerCase() === "mock") {
122
+ return { type: "mock" };
123
+ }
124
+ return { type: "native-macos" };
125
+ }
126
+ function extractResponseSummary(response) {
127
+ const narrationContext = response.narration_context && typeof response.narration_context === "object"
128
+ ? response.narration_context
129
+ : {};
130
+ const plan = response.plan && typeof response.plan === "object"
131
+ ? response.plan
132
+ : {};
133
+ return normalizeText(narrationContext.summary
134
+ || plan.summary
135
+ || plan.assistant_message);
136
+ }
137
+ function extractJobStepId(job) {
138
+ return normalizeText(job.runtime_current_step_id
139
+ || (job.payload && typeof job.payload === "object" ? job.payload.runtime_current_step_id : "")
140
+ || ((job.payload && typeof job.payload === "object")
141
+ ? (job.payload.runtime_state?.current_step_id)
142
+ : "")
143
+ || "");
144
+ }
145
+ function extractJobSummary(job) {
146
+ const payload = job.payload && typeof job.payload === "object"
147
+ ? job.payload
148
+ : {};
149
+ const result = job.result && typeof job.result === "object"
150
+ ? job.result
151
+ : {};
152
+ const outcome = result.outcome && typeof result.outcome === "object"
153
+ ? result.outcome
154
+ : {};
155
+ return normalizeText(result.summary
156
+ || outcome.summary
157
+ || payload.kernel_intro_summary
158
+ || payload.kernel_confirmation_summary
159
+ || payload.planner_summary);
160
+ }
161
+ function extractConfirmationPrompt(job) {
162
+ const confirmationContext = job.confirmation_context && typeof job.confirmation_context === "object"
163
+ ? job.confirmation_context
164
+ : {};
165
+ const payload = job.payload && typeof job.payload === "object"
166
+ ? job.payload
167
+ : {};
168
+ return normalizeText(confirmationContext.message
169
+ || payload.kernel_confirmation_summary
170
+ || payload.confirmation_message) || "O Otto está aguardando sua confirmação para continuar.";
171
+ }
172
+ function renderStatusOverview(config) {
173
+ return [
174
+ `${style("Device", ANSI.blue, supportsAnsi())}: ${config.deviceName}`,
175
+ `${style("Device ID", ANSI.blue, supportsAnsi())}: ${config.deviceId}`,
176
+ `${style("API", ANSI.blue, supportsAnsi())}: ${config.apiBaseUrl}`,
177
+ `${style("Executor", ANSI.blue, supportsAnsi())}: ${config.executor.type}`,
178
+ `${style("Approval", ANSI.blue, supportsAnsi())}: ${config.approvalMode}`,
179
+ `${style("Config", ANSI.blue, supportsAnsi())}: ${getBridgeConfigPath()}`,
180
+ ];
181
+ }
182
+ async function printExtensionsOverview(config) {
183
+ printSection("Extensions");
184
+ if (!config.installedExtensions.length) {
185
+ printMuted("Nenhuma extensão instalada neste bridge.");
186
+ return;
187
+ }
188
+ for (const extension of config.installedExtensions) {
189
+ if (!isManagedBridgeExtensionSlug(extension)) {
190
+ console.log(`- ${extension}`);
191
+ continue;
192
+ }
193
+ const state = await loadManagedBridgeExtensionState(extension);
194
+ const status = state ? formatManagedBridgeExtensionStatus(state.status) : "sem estado salvo";
195
+ console.log(`- ${extension}: ${status}`);
196
+ if (state?.notes) {
197
+ printMuted(` ${truncate(state.notes, 140)}`);
198
+ }
199
+ }
200
+ }
201
+ async function runSetupWizard(rl, options) {
202
+ printSection(options?.postinstall ? "Setup Inicial" : "Pairing Setup");
203
+ printMuted("Cole o pairing code gerado na interface web do Otto.");
204
+ const existingConfig = await loadBridgeConfig();
205
+ if (existingConfig) {
206
+ const keepExisting = await askYesNo(rl, `Já existe um pairing salvo para ${existingConfig.deviceName}. Quer substituir esse vínculo`, false);
207
+ if (!keepExisting) {
208
+ return { config: existingConfig, openConsole: false };
209
+ }
210
+ }
211
+ const apiBaseUrl = resolveApiBaseUrl(await ask(rl, "API base URL", {
212
+ defaultValue: existingConfig?.apiBaseUrl || DEFAULT_API_BASE_URL,
213
+ }));
214
+ const pairingCode = await ask(rl, "Pairing code", {
215
+ allowEmpty: Boolean(options?.postinstall),
216
+ });
217
+ if (!pairingCode) {
218
+ printMuted("Setup adiado. Quando quiser, rode `otto-bridge setup`.");
219
+ return { config: existingConfig || null, openConsole: false };
220
+ }
221
+ const deviceName = await ask(rl, "Nome do dispositivo", {
222
+ defaultValue: existingConfig?.deviceName || defaultDeviceName(),
223
+ });
224
+ const executor = await chooseExecutor(rl, existingConfig?.executor);
225
+ printMuted("Solicitando pairing ao backend e aguardando aprovação...");
226
+ const config = await pairDevice({
227
+ apiBaseUrl,
228
+ pairingCode,
229
+ deviceName,
230
+ executor,
231
+ });
232
+ printSuccess(`Bridge pareado com sucesso como ${config.deviceName}.`);
233
+ printMuted(`Config salvo em ${getBridgeConfigPath()}`);
234
+ const openConsole = await askYesNo(rl, "Abrir o Otto Console agora", true);
235
+ return { config, openConsole };
236
+ }
237
+ async function followConsoleJob(rl, config, jobId) {
238
+ let lastStatus = "";
239
+ let lastStepId = "";
240
+ let awaitingDecision = false;
241
+ for (;;) {
242
+ const envelope = await getRuntimeCliJob(config, jobId);
243
+ const job = envelope.job || {};
244
+ const status = normalizeText(job.status).toLowerCase() || "unknown";
245
+ const stepId = extractJobStepId(job);
246
+ if (status !== lastStatus || stepId !== lastStepId) {
247
+ const statusLabel = `${status}${stepId ? ` · ${stepId}` : ""}`;
248
+ console.log(`${style("runtime", ANSI.teal, supportsAnsi())} ${statusLabel}`);
249
+ lastStatus = status;
250
+ lastStepId = stepId;
251
+ }
252
+ if (status === "confirm_required" && !awaitingDecision) {
253
+ awaitingDecision = true;
254
+ console.log(style(extractConfirmationPrompt(job), ANSI.amber, supportsAnsi()));
255
+ const approve = await askYesNo(rl, "Aprovar este passo", true);
256
+ if (approve) {
257
+ await confirmRuntimeCliJob(config, jobId, "approve");
258
+ }
259
+ else {
260
+ const reject = await askYesNo(rl, "Rejeitar explicitamente este passo", true);
261
+ if (reject) {
262
+ await confirmRuntimeCliJob(config, jobId, "reject");
263
+ }
264
+ else {
265
+ await cancelRuntimeCliJob(config, jobId, "Cancelado no Otto Console");
266
+ }
267
+ }
268
+ awaitingDecision = false;
269
+ continue;
270
+ }
271
+ if (status === "completed" || status === "failed" || status === "cancelled") {
272
+ const summary = extractJobSummary(job)
273
+ || (status === "completed"
274
+ ? "Execução local concluída."
275
+ : status === "failed"
276
+ ? "Execução local falhou."
277
+ : "Execução local cancelada.");
278
+ console.log(`${style("otto", ANSI.coral, supportsAnsi())} ${summary}`);
279
+ return summary;
280
+ }
281
+ await delay(1400);
282
+ }
283
+ }
284
+ async function runOttoConsole(rl, config, options) {
285
+ printSection("Otto Console");
286
+ printMuted("O runtime local será mantido em background enquanto este console estiver aberto.");
287
+ const runtime = new BridgeRuntime(config);
288
+ let runtimeFailure = null;
289
+ const runtimeTask = runtime.start().catch((error) => {
290
+ runtimeFailure = error instanceof Error ? error.message : String(error);
291
+ });
292
+ await delay(600);
293
+ const sessionId = randomUUID();
294
+ const conversation = [];
295
+ const printConsoleHelp = () => {
296
+ printMuted("Comandos: /help, /clear, /status, /exit");
297
+ };
298
+ const handlePrompt = async (promptText) => {
299
+ const normalizedPrompt = normalizeText(promptText);
300
+ if (!normalizedPrompt) {
301
+ return;
302
+ }
303
+ if (normalizedPrompt === "/help") {
304
+ printConsoleHelp();
305
+ return;
306
+ }
307
+ if (normalizedPrompt === "/clear") {
308
+ conversation.splice(0, conversation.length);
309
+ printMuted("Contexto local do console limpo.");
310
+ return;
311
+ }
312
+ if (normalizedPrompt === "/status") {
313
+ renderStatusOverview(config).forEach((line) => console.log(line));
314
+ if (runtimeFailure) {
315
+ printWarning(`Runtime reportou erro: ${runtimeFailure}`);
316
+ }
317
+ return;
318
+ }
319
+ if (normalizedPrompt === "/exit") {
320
+ throw new Error("__OTTO_CONSOLE_EXIT__");
321
+ }
322
+ console.log(`${style("você", ANSI.white, supportsAnsi())} ${normalizedPrompt}`);
323
+ const response = await submitRuntimeCliAssistantPrompt(config, {
324
+ prompt: normalizedPrompt,
325
+ conversation,
326
+ session_id: sessionId,
327
+ source: "cli_terminal",
328
+ });
329
+ const summary = extractResponseSummary(response);
330
+ if (summary) {
331
+ console.log(`${style("otto", ANSI.coral, supportsAnsi())} ${summary}`);
332
+ }
333
+ conversation.push({ role: "user", content: normalizedPrompt });
334
+ if (summary) {
335
+ conversation.push({ role: "assistant", content: summary });
336
+ }
337
+ while (conversation.length > 16) {
338
+ conversation.shift();
339
+ }
340
+ const job = response.job && typeof response.job === "object" ? response.job : null;
341
+ const jobId = normalizeText(job?.id);
342
+ if (jobId) {
343
+ const terminalSummary = await followConsoleJob(rl, config, jobId);
344
+ if (terminalSummary) {
345
+ conversation.push({ role: "assistant", content: terminalSummary });
346
+ }
347
+ }
348
+ else if (normalizeText(response.route_mode) === "cloud_then_execute" || normalizeText(response.route_mode) === "execute_then_cloud") {
349
+ printWarning("Este fluxo ainda pede continuação cloud fora do console local. Acompanhe o app web se precisar do fechamento completo.");
350
+ }
351
+ };
352
+ printConsoleHelp();
353
+ try {
354
+ if (options?.initialPrompt) {
355
+ await handlePrompt(options.initialPrompt);
356
+ }
357
+ for (;;) {
358
+ const promptText = await ask(rl, "OTTO", { allowEmpty: true });
359
+ try {
360
+ await handlePrompt(promptText);
361
+ }
362
+ catch (error) {
363
+ if (error instanceof Error && error.message === "__OTTO_CONSOLE_EXIT__") {
364
+ break;
365
+ }
366
+ throw error;
367
+ }
368
+ }
369
+ }
370
+ finally {
371
+ await runtime.stop().catch(() => undefined);
372
+ await runtimeTask.catch(() => undefined);
373
+ }
374
+ }
375
+ async function printStatusView(rl, config) {
376
+ printSection("Bridge Status");
377
+ renderStatusOverview(config).forEach((line) => console.log(line));
378
+ await printExtensionsOverview(config);
379
+ await pauseForEnter(rl);
380
+ }
381
+ async function pickHomeChoice(rl, paired) {
382
+ printSection("Home");
383
+ const options = paired
384
+ ? [
385
+ `${style("1.", ANSI.coral, supportsAnsi())} Otto Console`,
386
+ `${style("2.", ANSI.coral, supportsAnsi())} Re-pair / setup`,
387
+ `${style("3.", ANSI.coral, supportsAnsi())} Status do bridge`,
388
+ `${style("4.", ANSI.coral, supportsAnsi())} Extensões instaladas`,
389
+ `${style("5.", ANSI.coral, supportsAnsi())} Sair`,
390
+ ]
391
+ : [
392
+ `${style("1.", ANSI.coral, supportsAnsi())} Pairing setup`,
393
+ `${style("2.", ANSI.coral, supportsAnsi())} Sair`,
394
+ ];
395
+ console.log(options.join("\n"));
396
+ const answer = await ask(rl, "Escolha");
397
+ if (!paired) {
398
+ return answer === "1" ? "setup" : "exit";
399
+ }
400
+ if (answer === "1")
401
+ return "console";
402
+ if (answer === "2")
403
+ return "setup";
404
+ if (answer === "3")
405
+ return "status";
406
+ if (answer === "4")
407
+ return "extensions";
408
+ return "exit";
409
+ }
410
+ export async function launchInteractiveCli(options) {
411
+ const rl = await createPromptInterface();
412
+ try {
413
+ console.clear();
414
+ console.log(renderBanner());
415
+ let config = await loadBridgeConfig();
416
+ if (!config) {
417
+ const setup = await runSetupWizard(rl, options);
418
+ config = setup.config;
419
+ if (config && setup.openConsole) {
420
+ await runOttoConsole(rl, config);
421
+ return;
422
+ }
423
+ if (!config) {
424
+ return;
425
+ }
426
+ }
427
+ for (;;) {
428
+ console.log("");
429
+ renderStatusOverview(config).forEach((line) => console.log(line));
430
+ const choice = await pickHomeChoice(rl, true);
431
+ if (choice === "exit") {
432
+ break;
433
+ }
434
+ if (choice === "setup") {
435
+ const setup = await runSetupWizard(rl);
436
+ if (setup.config) {
437
+ config = setup.config;
438
+ }
439
+ if (setup.config && setup.openConsole) {
440
+ await runOttoConsole(rl, setup.config);
441
+ }
442
+ continue;
443
+ }
444
+ if (choice === "console") {
445
+ await runOttoConsole(rl, config);
446
+ continue;
447
+ }
448
+ if (choice === "status") {
449
+ await printStatusView(rl, config);
450
+ continue;
451
+ }
452
+ if (choice === "extensions") {
453
+ await printExtensionsOverview(config);
454
+ await pauseForEnter(rl);
455
+ }
456
+ }
457
+ }
458
+ finally {
459
+ rl.close();
460
+ }
461
+ }
462
+ export async function runSetupCommand(options) {
463
+ const rl = await createPromptInterface();
464
+ try {
465
+ console.clear();
466
+ console.log(renderBanner());
467
+ const setup = await runSetupWizard(rl, options);
468
+ if (setup.config && setup.openConsole) {
469
+ await runOttoConsole(rl, setup.config);
470
+ }
471
+ }
472
+ finally {
473
+ rl.close();
474
+ }
475
+ }
476
+ export async function runConsoleCommand(initialPrompt) {
477
+ const config = await loadBridgeConfig();
478
+ if (!config) {
479
+ throw new Error("Nenhum pairing local encontrado. Rode `otto-bridge setup` primeiro.");
480
+ }
481
+ const rl = await createPromptInterface();
482
+ try {
483
+ console.clear();
484
+ console.log(renderBanner());
485
+ await runOttoConsole(rl, config, { initialPrompt });
486
+ }
487
+ finally {
488
+ rl.close();
489
+ }
490
+ }