@theowlops/boombot 1.1.0 → 1.1.2

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.
@@ -97,11 +97,11 @@ function gatewayHostPort() {
97
97
  try {
98
98
  const config = loadConfig(resolveConfigPath());
99
99
  const host = process.env['BOOMBOT_HOST']?.trim() || config?.host || '127.0.0.1';
100
- const port = Number.parseInt(process.env['BOOMBOT_PORT']?.trim() || String(config?.port ?? 8787), 10);
101
- return { host, port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 8787 };
100
+ const port = Number.parseInt(process.env['BOOMBOT_PORT']?.trim() || String(config?.port ?? 8788), 10);
101
+ return { host, port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 8788 };
102
102
  }
103
103
  catch {
104
- return { host: '127.0.0.1', port: 8787 };
104
+ return { host: '127.0.0.1', port: 8788 };
105
105
  }
106
106
  }
107
107
  /** Public URL for the browser: 0.0.0.0/:: mean "this machine". Exported for tests. */
@@ -142,7 +142,7 @@ export function initializeGateway(configPath = resolveConfigPath()) {
142
142
  ensureMasterKey(masterKeyPath);
143
143
  }
144
144
  const { apiKeyHash } = ensureApiKey(resolveApiKeyPath(configPath));
145
- saveConfig({ host: '127.0.0.1', port: 8787, dataDir, apiKeyHash }, configPath, 'wx');
145
+ saveConfig({ host: '127.0.0.1', port: 8788, dataDir, apiKeyHash }, configPath, 'wx');
146
146
  return true;
147
147
  }
148
148
  /**
@@ -129,59 +129,69 @@ export function browserHost(host) {
129
129
  return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
130
130
  }
131
131
  export async function runSetup() {
132
- console.log('🦉 BoomBot setup\n');
132
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
133
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
134
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
135
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
136
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
137
+ console.log(`
138
+ ${cyan('╭───────────────────────────────────────────────╮')}
139
+ ${cyan('│')} ${bold('🦉 BoomBot — First-Run Setup Wizard')} ${cyan('│')}
140
+ ${cyan('│')} ${dim('Thiết lập nhanh Gateway, API Key & LLM')} ${cyan('│')}
141
+ ${cyan('╰───────────────────────────────────────────────╯')}
142
+ `);
133
143
  const existing = loadConfig();
134
- const host = await askDefault('Gateway host', existing?.host ?? '127.0.0.1');
135
- const defaultPort = existing?.port ?? 8787;
136
- const port = parsePort(await ask(`Gateway port [${defaultPort}]: `), defaultPort);
137
- const dataDir = await askDefault('Data directory', existing?.dataDir ?? '~/.boombot/data');
144
+ console.log(`${bold('1. Cấu hình mạng & đường dẫn (Network & Paths)')}`);
145
+ const host = await askDefault(` ${cyan('•')} Gateway host`, existing?.host ?? '127.0.0.1');
146
+ const defaultPort = existing?.port ?? 8788;
147
+ const port = parsePort(await ask(` ${cyan('•')} Gateway port [${defaultPort}]: `), defaultPort);
148
+ const dataDir = await askDefault(` ${cyan('•')} Thư mục dữ liệu (Data directory)`, existing?.dataDir ?? '~/.boombot/data');
138
149
  // API key — kept at <config-dir>/api.key (0600) so it stays recoverable; the
139
150
  // config only ever holds its sha256. An existing key is reused, never rotated.
151
+ console.log(`\n${bold('2. Khóa bảo mật Gateway (Security Keys)')}`);
140
152
  const { apiKey, apiKeyHash, keyPath, created, fromEnv } = ensureApiKey();
141
153
  if (fromEnv) {
142
- console.log('\nUsing the API key from BOOMBOT_API_KEY (no key file written).\n');
154
+ console.log(` ${green('✔')} Sử dụng API key từ biến môi trường BOOMBOT_API_KEY`);
143
155
  }
144
156
  else if (created) {
145
- console.log('\n────────────────────────────────────────');
146
- console.log('Your gateway API key (save it somewhere safe):');
147
- console.log(`\n ${apiKey}\n`);
148
- console.log(`Also stored at ${keyPath} (mode 0600) — \`boombot gateway\` reads it from`);
149
- console.log('there and signs your browser in automatically, so you can ignore this');
150
- console.log('notice. Only the sha256 hash is written to config.json.');
151
- console.log('────────────────────────────────────────\n');
157
+ console.log(` ${yellow('┌─ Gateway API Key của bạn (Lưu vào nơi an toàn) ───────────────┐')}`);
158
+ console.log(` ${yellow('│')} ${bold(apiKey)}`);
159
+ console.log(` ${yellow('└───────────────────────────────────────────────────────────────┘')}`);
160
+ console.log(` ${dim(`Đã lưu tại: ${keyPath} (tự động đăng nhập web)`)}`);
152
161
  }
153
162
  else {
154
- console.log(`\nKeeping the existing gateway API key from ${keyPath}.`);
155
- console.log('(delete that file and re-run setup to rotate it)\n');
163
+ console.log(` ${green('✔')} Giữ API key hiện có tại: ${dim(keyPath)}`);
156
164
  }
157
- // Master key — encrypts provider API keys at rest. The gateway needs it
158
- // as BOOMBOT_MASTER_KEY; `boombot gateway` loads this file automatically.
165
+ // Master key — encrypts provider API keys at rest.
159
166
  const masterKeyPath = resolveMasterKeyPath();
160
167
  const masterKey = process.env['BOOMBOT_MASTER_KEY'] || ensureMasterKey(masterKeyPath);
161
- console.log(`Master key for provider encryption: ${masterKeyPath}`);
162
- console.log('(mode 0600 — it decrypts your provider keys, keep it safe)\n');
168
+ console.log(` ${green('✔')} Master key mã hóa AES-256: ${dim(masterKeyPath)}`);
163
169
  // Telegram (optional).
170
+ console.log(`\n${bold('3. Kênh kết nối Chat (Messaging Channels)')}`);
164
171
  let telegram = existing?.telegram;
165
- if (await askYesNo('Configure Telegram bot?', false)) {
166
- const botToken = await readSecret('Telegram bot token: ');
167
- const allowlist = parseAllowlist(await ask('Allowlisted chat IDs (comma-separated, empty = none): '));
172
+ if (await askYesNo(` ${cyan('?')} Cấu hình bot Telegram?`, false)) {
173
+ const botToken = await readSecret(' Token bot Telegram: ');
174
+ const allowlist = parseAllowlist(await ask(' Chat IDs được phép (phân cách bằng dấu phẩy, để trống = tất cả): '));
168
175
  telegram = { botToken: botToken.trim(), allowlistChatIds: allowlist };
176
+ console.log(` ${green('✔')} Đã cấu hình Telegram.`);
169
177
  }
170
- // Default provider → the real encrypted store (<dataDir>/providers.json),
171
- // the same store the gateway reads. The key is pasted once, masked, and
172
- // never written in plaintext anywhere.
173
- let providerSaved = false;
174
- if (await askYesNo('Configure a default LLM provider?', true)) {
178
+ else {
179
+ console.log(` ${dim('Bỏ qua kết nối Telegram (có thể cấu hình sau trên Web UI).')}`);
180
+ }
181
+ // Default provider → the real encrypted store (<dataDir>/providers.json)
182
+ console.log(`\n${bold('4. Nhà cung cấp AI (LLM Provider)')}`);
183
+ if (await askYesNo(` ${cyan('?')} Thiết lập nhà cung cấp LLM mặc định?`, true)) {
184
+ console.log(` ${dim(`Các loại hỗ trợ: ${PROVIDER_KINDS.join(', ')}`)}`);
175
185
  const kind = await askProviderKind();
176
186
  if (kind !== null) {
177
- const model = await askRequired('Default model: ');
187
+ const model = await askRequired(' Model mặc định (vd: gpt-4o, claude-3-7-sonnet): ');
178
188
  const needsBaseUrl = kind === 'openai-compatible' || kind === 'local';
179
189
  const baseUrl = needsBaseUrl
180
- ? await askRequired('Base URL (required for this provider kind): ')
181
- : (await ask('Base URL (empty = provider default): ')).trim();
190
+ ? await askRequired(' Base URL (bắt buộc cho loại này): ')
191
+ : (await ask(' Base URL (để trống = mặc định): ')).trim();
182
192
  const providerApiKey = kind === 'local'
183
- ? (await readSecret('Provider API key (empty = none): ')).trim()
184
- : await readRequiredSecret('Provider API key: ');
193
+ ? (await readSecret(' Provider API key (để trống nếu không có): ')).trim()
194
+ : await readRequiredSecret(' Provider API key: ');
185
195
  const service = createProviderService({
186
196
  storagePath: path.join(expandTilde(dataDir), 'providers.json'),
187
197
  masterKey,
@@ -195,11 +205,13 @@ export async function runSetup() {
195
205
  ...(providerApiKey !== '' ? { apiKey: providerApiKey } : {}),
196
206
  active: true,
197
207
  });
198
- providerSaved = true;
199
- const keyNote = providerApiKey !== '' ? ' — API key stored encrypted' : ' — no API key stored';
200
- console.log(`\nSaved default provider '${saved.name}' (${saved.kind})${keyNote}.`);
208
+ const keyNote = providerApiKey !== '' ? ' (đã mã hóa an toàn)' : '';
209
+ console.log(` ${green('✔')} Đã lưu provider '${saved.name}' [${saved.kind}]${keyNote}`);
201
210
  }
202
211
  }
212
+ else {
213
+ console.log(` ${dim('Bỏ qua cấu hình LLM (bạn có thể thêm trực tiếp trên Web UI).')}`);
214
+ }
203
215
  const config = {
204
216
  ...existing,
205
217
  host,
@@ -210,21 +222,23 @@ export async function runSetup() {
210
222
  };
211
223
  const problems = validateConfig(config);
212
224
  if (problems.length > 0) {
213
- console.error(`\nInvalid config: ${problems.join('; ')}`);
225
+ console.error(`\n❌ Cấu hình không hợp lệ: ${problems.join('; ')}`);
214
226
  process.exitCode = 1;
215
227
  return;
216
228
  }
217
229
  const configPath = resolveConfigPath();
218
230
  saveConfig(config, configPath);
219
- const permsNote = process.platform === 'win32'
220
- ? 'permissions NOT enforced on Windows (chmod is a no-op) — see SECURITY.md §5'
221
- : 'mode 0600 — secrets protected';
222
- console.log(`\nSaved config to ${configPath} (${permsNote}).`);
223
231
  const url = `http://${browserHost(host)}:${port}`;
224
- console.log('\nNext steps:');
225
- console.log(` 1. boombot gateway # start the gateway — opens ${url} in your browser`);
226
- console.log(' 2. boombot doctor # health check, if anything looks off');
227
- if (!providerSaved) {
228
- console.log(' (tip: re-run `boombot setup` any time to add a default provider)');
229
- }
232
+ console.log(`
233
+ ${green('╭───────────────────────────────────────────────╮')}
234
+ ${green('│')} ${bold('🎉 CÀI ĐẶT HOÀN TẤT THÀNH CÔNG!')} ${green('│')}
235
+ ${green('╰───────────────────────────────────────────────╯')}
236
+
237
+ ${bold('Tệp cấu hình:')} ${dim(configPath)}
238
+ ${bold('Giao diện Web:')} ${cyan(url)}
239
+
240
+ ${bold('Bắt đầu sử dụng:')}
241
+ ${cyan('1.')} ${bold('boombot')} Khởi động Gateway và mở Web UI
242
+ ${cyan('2.')} ${bold('boombot doctor')} Kiểm tra toàn diện tình trạng hệ thống
243
+ `);
230
244
  }
@@ -1,8 +1,9 @@
1
+ import { homedir } from 'node:os';
1
2
  import { join } from 'node:path';
2
3
  import { hashApiKey } from './auth.js';
3
4
  import { coerceChannelSection, defaultConfigDir, defaultConfigPath, readConfigObject, resolveConfigFilePath, } from './config-format.js';
4
5
  const DEFAULT_HOST = '127.0.0.1';
5
- const DEFAULT_PORT = 8787;
6
+ const DEFAULT_PORT = 8788;
6
7
  /**
7
8
  * Default config path (`~/.boombot/config.yml`). The real definition lives in
8
9
  * `config-format.ts`, next to the file probe order; re-exported here so old
@@ -397,8 +398,15 @@ export function loadConfig(env = process.env) {
397
398
  const fileHost = typeof file.host === 'string' && file.host.length > 0 ? file.host : undefined;
398
399
  const host = get('BOOMBOT_HOST') ?? fileHost ?? DEFAULT_HOST;
399
400
  const port = parsePort(get('BOOMBOT_PORT') ?? file.port);
400
- const fileDataDir = typeof file.dataDir === 'string' && file.dataDir.length > 0 ? file.dataDir : undefined;
401
- const dataDir = get('BOOMBOT_DATA_DIR') ?? fileDataDir ?? defaultDataDir(env);
401
+ const fileDataDir = typeof file.dataDir === 'string' && file.dataDir.length > 0
402
+ ? (file.dataDir.startsWith('~/') || file.dataDir.startsWith('~\\')
403
+ ? join(homedir(), file.dataDir.slice(2))
404
+ : file.dataDir)
405
+ : undefined;
406
+ const rawDataDir = get('BOOMBOT_DATA_DIR') ?? fileDataDir ?? defaultDataDir(env);
407
+ const dataDir = rawDataDir.startsWith('~/') || rawDataDir.startsWith('~\\')
408
+ ? join(homedir(), rawDataDir.slice(2))
409
+ : rawDataDir;
402
410
  const apiKeyHash = resolveApiKeyHash(file.apiKeyHash, get('BOOMBOT_API_KEY'));
403
411
  const channels = mergeChannelEnv(parseChannels(file.channels, configPath), get);
404
412
  const reasoningGuard = {
@@ -84,6 +84,7 @@ function toChatMessage(m) {
84
84
  ...(metadata?.toolCalls !== undefined ? { toolCalls: metadata.toolCalls } : {}),
85
85
  ...(metadata?.toolCallId !== undefined ? { toolCallId: metadata.toolCallId } : {}),
86
86
  ...(metadata?.toolName !== undefined ? { name: metadata.toolName } : {}),
87
+ ...(metadata?.reasoning !== undefined ? { reasoning: metadata.reasoning } : {}),
87
88
  };
88
89
  }
89
90
  /**
@@ -105,6 +106,9 @@ function toSessionMetadata(message) {
105
106
  if (message.name !== undefined && message.name !== '') {
106
107
  metadata.toolName = message.name;
107
108
  }
109
+ if (message.reasoning !== undefined && message.reasoning !== '') {
110
+ metadata.reasoning = message.reasoning;
111
+ }
108
112
  return Object.keys(metadata).length > 0 ? metadata : undefined;
109
113
  }
110
114
  function boundedContext(messages, alreadyOmitted = 0) {
@@ -174,6 +174,7 @@ async function modelRound(deps, messages, tools, emit, signal) {
174
174
  checkCancelled(signal);
175
175
  let text = '';
176
176
  let reasoning = '';
177
+ let inThinkTag = false;
177
178
  const usage = { inputTokens: 0, outputTokens: 0 };
178
179
  const accumulators = new Map();
179
180
  const order = [];
@@ -188,8 +189,43 @@ async function modelRound(deps, messages, tools, emit, signal) {
188
189
  }
189
190
  }
190
191
  if (chunk.delta) {
191
- text += chunk.delta;
192
- emit({ type: 'text.delta', text: chunk.delta });
192
+ let remaining = chunk.delta;
193
+ while (remaining.length > 0) {
194
+ if (inThinkTag) {
195
+ const closeIdx = remaining.indexOf('</think>');
196
+ if (closeIdx !== -1) {
197
+ const thinkPart = remaining.slice(0, closeIdx);
198
+ remaining = remaining.slice(closeIdx + '</think>'.length);
199
+ inThinkTag = false;
200
+ if (thinkPart) {
201
+ reasoning += thinkPart;
202
+ emit({ type: 'reasoning.delta', text: thinkPart });
203
+ }
204
+ }
205
+ else {
206
+ reasoning += remaining;
207
+ emit({ type: 'reasoning.delta', text: remaining });
208
+ remaining = '';
209
+ }
210
+ }
211
+ else {
212
+ const openIdx = remaining.indexOf('<think>');
213
+ if (openIdx !== -1) {
214
+ const textPart = remaining.slice(0, openIdx);
215
+ remaining = remaining.slice(openIdx + '<think>'.length);
216
+ inThinkTag = true;
217
+ if (textPart) {
218
+ text += textPart;
219
+ emit({ type: 'text.delta', text: textPart });
220
+ }
221
+ }
222
+ else {
223
+ text += remaining;
224
+ emit({ type: 'text.delta', text: remaining });
225
+ remaining = '';
226
+ }
227
+ }
228
+ }
193
229
  }
194
230
  if (chunk.reasoningDelta) {
195
231
  reasoning += chunk.reasoningDelta;
@@ -244,15 +280,15 @@ async function modelRound(deps, messages, tools, emit, signal) {
244
280
  arguments: parsed,
245
281
  });
246
282
  }
247
- // Fallback: If model emitted thinking/reasoning but no content tokens or tool calls,
248
- // salvage reasoning as response text instead of failing with empty response.
249
- const finalText = text.trim() ? text : (calls.length ? '' : reasoning.trim());
283
+ const finalText = text.trim();
284
+ const trimmedReasoning = reasoning.trim();
250
285
  const assistantMessage = {
251
286
  role: 'assistant',
252
287
  content: finalText,
288
+ ...(trimmedReasoning ? { reasoning: trimmedReasoning } : {}),
253
289
  ...(calls.length > 0 ? { toolCalls: calls } : {}),
254
290
  };
255
- return { text: finalText, toolCalls: calls, assistantMessage, usage };
291
+ return { text: finalText, reasoning: trimmedReasoning || undefined, toolCalls: calls, assistantMessage, usage };
256
292
  }
257
293
  async function executeToolBatch(deps, batch, emit, signal) {
258
294
  const results = [];
@@ -431,6 +467,7 @@ export async function runTurn(deps, context, onEvent, signal) {
431
467
  let emptyRecoveryPending = false;
432
468
  let emptyRecoveryUsed = false;
433
469
  let contextRetryUsed = false;
470
+ let lastRoundReasoning;
434
471
  while (rounds < maxRounds) {
435
472
  checkCancelled(signal);
436
473
  rounds++;
@@ -440,7 +477,10 @@ export async function runTurn(deps, context, onEvent, signal) {
440
477
  const requestMessages = () => {
441
478
  const prepared = [...modelMessages];
442
479
  if (recoverEmpty) {
443
- prepared.push({ role: 'user', content: `[Lưu ý hệ thống: ${EMPTY_RESPONSE_NUDGE}]` });
480
+ const nudge = lastRoundReasoning
481
+ ? 'Bạn đã hoàn thành phần suy luận nhưng chưa sinh nội dung câu trả lời cho người dùng. Hãy đưa ra câu trả lời trực tiếp và chi tiết cho người dùng ngay bây giờ bằng tiếng Việt, không lặp lại phần suy luận và không gọi công cụ.'
482
+ : EMPTY_RESPONSE_NUDGE;
483
+ prepared.push({ role: 'user', content: `[Lưu ý hệ thống: ${nudge}]` });
444
484
  }
445
485
  if (answerOnly) {
446
486
  prepared.push({
@@ -469,8 +509,36 @@ export async function runTurn(deps, context, onEvent, signal) {
469
509
  if (!emptyRecoveryUsed && rounds < maxRounds) {
470
510
  emptyRecoveryUsed = true;
471
511
  emptyRecoveryPending = true;
512
+ lastRoundReasoning = model.reasoning;
472
513
  continue;
473
514
  }
515
+ // Force one final synthesis round without tools if reasoning exists
516
+ if (model.reasoning && rounds < maxRounds) {
517
+ try {
518
+ const forceMessages = [
519
+ ...modelMessages,
520
+ {
521
+ role: 'user',
522
+ content: 'Hãy tổng hợp và đưa ra câu trả lời trực tiếp, đầy đủ cho người dùng ngay bây giờ dựa trên các suy luận ở trên bằng tiếng Việt. Không gọi thêm công cụ.',
523
+ },
524
+ ];
525
+ const finalModel = await execPhase('modelRound', () => modelRound(deps, forceMessages, [], emit, signal));
526
+ const finalText = finalModel.text.trim();
527
+ if (finalText) {
528
+ const finalMsg = {
529
+ role: 'assistant',
530
+ content: finalText,
531
+ ...(model.reasoning ? { reasoning: model.reasoning } : {}),
532
+ };
533
+ messages.push(finalMsg);
534
+ modelMessages.push(finalMsg);
535
+ break;
536
+ }
537
+ }
538
+ catch {
539
+ // fall through to error
540
+ }
541
+ }
474
542
  const result = await finish('error', 'model returned an empty response');
475
543
  emit({ type: 'turn.end', result });
476
544
  return result;
@@ -21,6 +21,8 @@ export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
21
21
  export interface ChatMessage {
22
22
  readonly role: MessageRole;
23
23
  readonly content: string;
24
+ /** Optional reasoning/thinking output from reasoning models or thinking blocks. */
25
+ readonly reasoning?: string;
24
26
  /** Optional image references for multimodal user messages. */
25
27
  readonly images?: readonly {
26
28
  readonly url: string;
@@ -123,6 +125,7 @@ export type EventHandler = (event: AgentEvent) => void;
123
125
  /** Phase payloads consumed by gateway middleware after Python returns them. */
124
126
  export interface ModelRoundResult {
125
127
  readonly text: string;
128
+ readonly reasoning?: string;
126
129
  readonly toolCalls: readonly ToolCall[];
127
130
  readonly assistantMessage: ChatMessage;
128
131
  }