kafox-cli 0.6.0 → 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 +52 -3
- package/bin/kafox.js +69 -2
- package/package.json +1 -1
- package/src/agent.js +204 -9
- package/src/auth.js +77 -30
- package/src/cli.js +32 -2
- package/src/gateway.js +103 -9
- package/src/login-screen.js +203 -0
- package/src/models.js +1 -4
- package/src/runner.js +98 -0
- package/src/sessions.js +138 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/tools.js +12 -0
- package/src/tui.js +214 -44
package/src/cli.js
CHANGED
|
@@ -16,8 +16,8 @@ const { version: VERSION } = require("../package.json");
|
|
|
16
16
|
|
|
17
17
|
const COMMANDS = {
|
|
18
18
|
"/help": "show this help",
|
|
19
|
-
"/models": "
|
|
20
|
-
"/model": "
|
|
19
|
+
"/models": "escolhe o modelo (setas, mouse ou Enter)",
|
|
20
|
+
"/model": "escolhe o modelo no seletor, ou direto com /model <id>",
|
|
21
21
|
"/balance": "show balance",
|
|
22
22
|
"/pricing": "show recharge packages",
|
|
23
23
|
"/key": "create an API key",
|
|
@@ -25,6 +25,7 @@ const COMMANDS = {
|
|
|
25
25
|
"/logout": "end the session",
|
|
26
26
|
"/whoami": "show the linked account",
|
|
27
27
|
"/theme": "open theme selector",
|
|
28
|
+
"/agents": "lista os subagentes desta sessao (objetivo, ferramentas, tempo)",
|
|
28
29
|
"/mcp": "list MCP servers and tools (/mcp reload reconecta)",
|
|
29
30
|
"/allow": "aprovacao automatica nesta sessao (/allow off volta a perguntar)",
|
|
30
31
|
"/background": "set background image (modal asks for URL)",
|
|
@@ -116,6 +117,33 @@ async function handleCommand(line) {
|
|
|
116
117
|
);
|
|
117
118
|
return true;
|
|
118
119
|
}
|
|
120
|
+
case "/agents": {
|
|
121
|
+
const { listRuns } = require("./subagents");
|
|
122
|
+
const runs = listRuns();
|
|
123
|
+
if (!runs.length) {
|
|
124
|
+
console.log(chalk.dim("\nNenhum subagente nesta sessao."));
|
|
125
|
+
console.log(
|
|
126
|
+
chalk.dim(
|
|
127
|
+
"O agente abre subagentes sozinho quando dividir ajuda: objetivo proprio,\n" +
|
|
128
|
+
"ate 3 em paralelo, cada um em contexto separado. Para pedir na mao:\n" +
|
|
129
|
+
' "lanca dois subagentes: um revisa src/agent.js e outro roda os testes do backend"'
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
console.log(chalk.cyan(`\nSubagentes desta sessao (${runs.length}):\n`));
|
|
135
|
+
for (const run of runs) {
|
|
136
|
+
const status = run.ok ? chalk.green("ok") : chalk.red("falhou");
|
|
137
|
+
console.log(` ${status} ${chalk.bold(run.objective.slice(0, 90))}`);
|
|
138
|
+
console.log(
|
|
139
|
+
chalk.dim(
|
|
140
|
+
` ${run.tools.join(", ") || "read_file"} · ${run.steps} passo(s) · ${Math.round(run.ms / 1000)}s`
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
console.log("");
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
119
147
|
case "/mcp": {
|
|
120
148
|
const { McpHub } = require("./mcp");
|
|
121
149
|
const hub = new McpHub({ cwd: process.cwd() });
|
|
@@ -290,6 +318,8 @@ async function runREPL(options = {}) {
|
|
|
290
318
|
// /theme roda dentro da TUI (modal com preview); os demais abrem prompt no terminal.
|
|
291
319
|
interactiveCommands: new Set(["/login", "/key", "/background"]),
|
|
292
320
|
autoApprove: Boolean(options?.allowAll),
|
|
321
|
+
// kafox --resume <codigo> volta para a conversa salva em ~/.kafox/chats.
|
|
322
|
+
resume: options?.resume || null,
|
|
293
323
|
});
|
|
294
324
|
}
|
|
295
325
|
|
package/src/gateway.js
CHANGED
|
@@ -70,6 +70,46 @@ function mergeToolCalls(target, deltas) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/* ---------------------------------------------------------------- retry */
|
|
74
|
+
|
|
75
|
+
// Rede do provedor cai e o fetch devolve "fetch failed": em vez de morrer na
|
|
76
|
+
// primeira tentativa, espera e tenta de novo dobrando o intervalo.
|
|
77
|
+
const RETRY_MAX = 20;
|
|
78
|
+
const RETRY_BASE_MS = Number(process.env.KAFOX_RETRY_BASE_MS) || 3000;
|
|
79
|
+
const RETRY_MAX_MS = Number(process.env.KAFOX_RETRY_MAX_MS) || 60_000;
|
|
80
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 521, 522, 523, 524]);
|
|
81
|
+
|
|
82
|
+
/** Espera do retry: 3s, 6s, 12s, 24s... ate 60s. */
|
|
83
|
+
function backoffMs(attempt) {
|
|
84
|
+
return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Vale a pena tentar de novo? Erro de rede, timeout e 5xx sim; 4xx de
|
|
88
|
+
* conteudo (saldo, chave, modelo) nao adianta repetir. */
|
|
89
|
+
function retryableMessage(message) {
|
|
90
|
+
return /fetch failed|failed to fetch|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up|network|stream interrompido|terminated|other side closed/i.test(
|
|
91
|
+
String(message || "")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Dorme respeitando o abort: o Esc mata a espera na hora. */
|
|
96
|
+
function wait(ms, signal) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
if (signal?.aborted) return resolve(false);
|
|
99
|
+
const done = (value) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
102
|
+
resolve(value);
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => done(true), ms);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
function onAbort() {
|
|
107
|
+
done(false);
|
|
108
|
+
}
|
|
109
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
function errorFrom(data, status) {
|
|
74
114
|
const message =
|
|
75
115
|
data?.error?.message || data?.error || data?.body?.error?.message || `gateway error (${status})`;
|
|
@@ -81,10 +121,10 @@ function errorFrom(data, status) {
|
|
|
81
121
|
}
|
|
82
122
|
|
|
83
123
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
124
|
+
* Uma tentativa: request + leitura do stream.
|
|
125
|
+
* Devolve `retryable` quando o erro foi de rede/servidor e ainda nao veio texto.
|
|
86
126
|
*/
|
|
87
|
-
async function
|
|
127
|
+
async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
|
|
88
128
|
const ready = await ensureReady({ model });
|
|
89
129
|
if (!ready.ok) return ready;
|
|
90
130
|
|
|
@@ -105,12 +145,22 @@ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {
|
|
|
105
145
|
});
|
|
106
146
|
} catch (error) {
|
|
107
147
|
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
108
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: `Falha de rede: ${error.message}`,
|
|
151
|
+
retryable: retryableMessage(error.message),
|
|
152
|
+
keyCreated: ready.keyCreated,
|
|
153
|
+
};
|
|
109
154
|
}
|
|
110
155
|
|
|
111
156
|
if (!response.ok) {
|
|
112
157
|
const data = await response.json().catch(() => ({}));
|
|
113
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
...errorFrom(data, response.status),
|
|
160
|
+
status: response.status,
|
|
161
|
+
keyCreated: ready.keyCreated,
|
|
162
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
163
|
+
};
|
|
114
164
|
}
|
|
115
165
|
|
|
116
166
|
const decoder = new TextDecoder();
|
|
@@ -131,7 +181,8 @@ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {
|
|
|
131
181
|
return null;
|
|
132
182
|
}
|
|
133
183
|
if (event?.error) {
|
|
134
|
-
|
|
184
|
+
const message = event.error.message || String(event.error);
|
|
185
|
+
return { error: message, retryable: retryableMessage(message) };
|
|
135
186
|
}
|
|
136
187
|
if (event?.usage) usage = event.usage;
|
|
137
188
|
const delta = event?.choices?.[0]?.delta;
|
|
@@ -158,7 +209,7 @@ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {
|
|
|
158
209
|
const line = buffer.slice(0, index).replace(/\r$/, "");
|
|
159
210
|
buffer = buffer.slice(index + 1);
|
|
160
211
|
const failure = handleLine(line);
|
|
161
|
-
if (failure) return { ok: false, error: failure.error };
|
|
212
|
+
if (failure) return { ok: false, error: failure.error, retryable: failure.retryable };
|
|
162
213
|
index = buffer.indexOf("\n");
|
|
163
214
|
}
|
|
164
215
|
}
|
|
@@ -167,7 +218,12 @@ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {
|
|
|
167
218
|
if (error?.name === "AbortError") {
|
|
168
219
|
return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
|
|
169
220
|
}
|
|
170
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `Stream interrompido: ${error.message}`,
|
|
224
|
+
text,
|
|
225
|
+
retryable: retryableMessage(error.message),
|
|
226
|
+
};
|
|
171
227
|
}
|
|
172
228
|
|
|
173
229
|
return {
|
|
@@ -183,6 +239,35 @@ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {
|
|
|
183
239
|
};
|
|
184
240
|
}
|
|
185
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Chat completions com stream (SSE) via /api/chat, com retry em falha de rede.
|
|
244
|
+
* onRetry avisa a TUI (tentativa, espera) para ela mostrar a contagem.
|
|
245
|
+
*/
|
|
246
|
+
async function chat({ messages, model, tools, onDelta, onReasoning, onRetry, signal, retries = RETRY_MAX } = {}) {
|
|
247
|
+
const maxAttempts = Math.max(1, Number(retries) || RETRY_MAX);
|
|
248
|
+
let attempt = 0;
|
|
249
|
+
let last = null;
|
|
250
|
+
|
|
251
|
+
while (attempt < maxAttempts) {
|
|
252
|
+
attempt += 1;
|
|
253
|
+
const result = await attemptChat({ messages, model, tools, onDelta, onReasoning, signal });
|
|
254
|
+
|
|
255
|
+
if (result.ok || result.code === "aborted") return result;
|
|
256
|
+
// Ja saiu texto: repetir duplicaria a resposta na tela.
|
|
257
|
+
if (!result.retryable || result.text) return result;
|
|
258
|
+
last = result;
|
|
259
|
+
|
|
260
|
+
if (attempt >= maxAttempts) break;
|
|
261
|
+
|
|
262
|
+
const delay = backoffMs(attempt);
|
|
263
|
+
onRetry?.({ attempt, maxAttempts, delay, error: result.error });
|
|
264
|
+
const slept = await wait(delay, signal);
|
|
265
|
+
if (!slept) return { ok: false, code: "aborted", error: "Cancelado.", keyCreated: result.keyCreated };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { ...last, attempts: attempt };
|
|
269
|
+
}
|
|
270
|
+
|
|
186
271
|
/** Compatibilidade: resposta unica, sem callbacks (usada por `kafox ask`). */
|
|
187
272
|
async function askModel(question, options = {}) {
|
|
188
273
|
const text = typeof question === "string" ? question : (question || []).join(" ");
|
|
@@ -217,4 +302,13 @@ async function ask(question, options = {}) {
|
|
|
217
302
|
}
|
|
218
303
|
}
|
|
219
304
|
|
|
220
|
-
module.exports = {
|
|
305
|
+
module.exports = {
|
|
306
|
+
ask,
|
|
307
|
+
askModel,
|
|
308
|
+
chat,
|
|
309
|
+
ensureReady,
|
|
310
|
+
mergeToolCalls,
|
|
311
|
+
backoffMs,
|
|
312
|
+
RETRY_MAX,
|
|
313
|
+
RETRY_BASE_MS,
|
|
314
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tela de login do kafox (estilo Cline): ocupa o terminal inteiro, com a arte
|
|
3
|
+
* grande em cima, o link centralizado e o status de aprovacao animado.
|
|
4
|
+
*
|
|
5
|
+
* Sem TTY (pipe/CI) quem chama usa o fluxo de texto do auth.
|
|
6
|
+
*/
|
|
7
|
+
const chalk = require("chalk");
|
|
8
|
+
const { logoLines } = require("./logo");
|
|
9
|
+
const { borderColor } = require("./theme");
|
|
10
|
+
|
|
11
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
12
|
+
const visible = (value) => String(value).replace(ANSI, "").length;
|
|
13
|
+
const center = (text, width) => {
|
|
14
|
+
const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
|
|
15
|
+
return " ".repeat(pad) + text;
|
|
16
|
+
};
|
|
17
|
+
const rule = (width) => "─".repeat(Math.max(0, width));
|
|
18
|
+
|
|
19
|
+
function boxed(lines, width, theme) {
|
|
20
|
+
const border = borderColor(theme);
|
|
21
|
+
const inner = Math.max(20, Math.min(width, Math.max(...lines.map(visible)) + 4));
|
|
22
|
+
const out = [border(`┌${rule(inner)}┐`)];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
out.push(border("│") + " " + line + " ".repeat(Math.max(0, inner - visible(line) - 1)) + border("│"));
|
|
25
|
+
}
|
|
26
|
+
out.push(border(`└${rule(inner)}┘`));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.url URL que o usuario abre no navegador
|
|
33
|
+
* @param {string} options.code codigo curto (para digitar no site)
|
|
34
|
+
* @param {number} options.interval segundos entre consultas
|
|
35
|
+
* @param {number} options.expiresIn segundos de validade
|
|
36
|
+
* @param {(signal:AbortSignal) => Promise<{ok:boolean, session?:string, error?:string}>} options.poll
|
|
37
|
+
* @param {string} [options.theme]
|
|
38
|
+
* @param {string} [options.account] email ja conhecido (opcional)
|
|
39
|
+
*/
|
|
40
|
+
function runLoginScreen({
|
|
41
|
+
url,
|
|
42
|
+
code,
|
|
43
|
+
interval = 3,
|
|
44
|
+
expiresIn = 300,
|
|
45
|
+
poll,
|
|
46
|
+
theme = "default",
|
|
47
|
+
version = "",
|
|
48
|
+
} = {}) {
|
|
49
|
+
const stdout = process.stdout;
|
|
50
|
+
const stdin = process.stdin;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const columns = Math.max(40, stdout.columns || 80);
|
|
53
|
+
const rows = Math.max(16, stdout.rows || 24);
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
const deadline = startedAt + (Number(expiresIn) || 300) * 1000;
|
|
56
|
+
|
|
57
|
+
let dots = 0;
|
|
58
|
+
let done = false;
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
let status = "aguardando aprovacao";
|
|
61
|
+
let statusColor = chalk.cyan;
|
|
62
|
+
|
|
63
|
+
const logo = logoLines(theme);
|
|
64
|
+
const logoWidth = Math.max(...logo.map(visible));
|
|
65
|
+
|
|
66
|
+
function lines() {
|
|
67
|
+
const out = [];
|
|
68
|
+
const spin = "⠋⠙⠹⠸⠼⠴⠦⠧".charAt(dots % 8);
|
|
69
|
+
if (columns >= logoWidth + 4 && rows >= 22) {
|
|
70
|
+
out.push("");
|
|
71
|
+
for (const line of logo) out.push(center(line, columns));
|
|
72
|
+
out.push("");
|
|
73
|
+
}
|
|
74
|
+
out.push(center(chalk.bold("Entre na sua conta"), columns));
|
|
75
|
+
out.push(center(chalk.dim("Aprove o acesso no navegador para continuar."), columns));
|
|
76
|
+
out.push("");
|
|
77
|
+
for (const line of boxed([chalk.cyan(url)], columns - 4, theme)) out.push(center(line, columns));
|
|
78
|
+
out.push("");
|
|
79
|
+
out.push(center(chalk.dim("codigo") + " " + chalk.bold(code), columns));
|
|
80
|
+
out.push("");
|
|
81
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
82
|
+
const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
|
|
83
|
+
out.push(
|
|
84
|
+
center(
|
|
85
|
+
statusColor(`${spin} ${status}${".".repeat(dots % 4)}`) +
|
|
86
|
+
chalk.dim(` ${left}s para expirar · ${elapsed}s esperando`),
|
|
87
|
+
columns
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
out.push("");
|
|
91
|
+
out.push(
|
|
92
|
+
center(
|
|
93
|
+
chalk.dim("Esc cancela") + " · " + chalk.dim("abra o link em qualquer navegador"),
|
|
94
|
+
columns
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
if (version) out.push(center(chalk.dim(`kafox v${version}`), columns));
|
|
98
|
+
|
|
99
|
+
// Centraliza o bloco inteiro na vertical, sem estourar a altura.
|
|
100
|
+
const slice = out.slice(0, rows - 1);
|
|
101
|
+
const top = Math.max(0, Math.floor((rows - slice.length) / 2));
|
|
102
|
+
return [...Array(top).fill(""), ...slice].slice(0, rows);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function paint() {
|
|
106
|
+
const frame = lines();
|
|
107
|
+
// Atualizacao sincronizada: o terminal troca o frame inteiro de uma vez,
|
|
108
|
+
// sem piscar entre as linhas.
|
|
109
|
+
stdout.write(
|
|
110
|
+
"\x1b[?2026h\x1b[?25l\x1b[H" +
|
|
111
|
+
frame.map((line) => line + "\x1b[K").join("\n") +
|
|
112
|
+
"\x1b[?2026l"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const enter = () => stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
117
|
+
const leave = () => stdout.write("\x1b[?25h\x1b[?1049l");
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
let ticker = null;
|
|
121
|
+
let poller = null;
|
|
122
|
+
let finished = false;
|
|
123
|
+
|
|
124
|
+
function finish(result) {
|
|
125
|
+
if (finished) return;
|
|
126
|
+
finished = true;
|
|
127
|
+
done = result.ok;
|
|
128
|
+
cancelled = result.cancelled;
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
status = "aprovado";
|
|
131
|
+
statusColor = chalk.green;
|
|
132
|
+
paint();
|
|
133
|
+
}
|
|
134
|
+
clearInterval(ticker);
|
|
135
|
+
clearInterval(poller);
|
|
136
|
+
stdin.removeListener("data", onData);
|
|
137
|
+
stdin.removeListener("end", onCancel);
|
|
138
|
+
try {
|
|
139
|
+
stdin.setRawMode(false);
|
|
140
|
+
} catch {}
|
|
141
|
+
stdin.pause();
|
|
142
|
+
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
leave();
|
|
145
|
+
resolve(result);
|
|
146
|
+
}, result.ok ? 450 : 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function onCancel() {
|
|
150
|
+
controller.abort();
|
|
151
|
+
finish({ ok: false, cancelled: true, error: "login cancelado" });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onData(chunk) {
|
|
155
|
+
const text = String(chunk);
|
|
156
|
+
// Ctrl+C ou Esc solto cancelam (setas tambem comecam com ESC, entao so
|
|
157
|
+
// um chunk que e exatamente o Esc conta).
|
|
158
|
+
if (text.includes("\x03") || text === "\x1b") return onCancel();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
162
|
+
stdin.setEncoding("utf8");
|
|
163
|
+
stdin.resume();
|
|
164
|
+
stdin.on("data", onData);
|
|
165
|
+
stdin.on("end", onCancel);
|
|
166
|
+
|
|
167
|
+
enter();
|
|
168
|
+
paint();
|
|
169
|
+
|
|
170
|
+
ticker = setInterval(() => {
|
|
171
|
+
dots += 1;
|
|
172
|
+
if (Date.now() > deadline) {
|
|
173
|
+
status = "codigo expirado";
|
|
174
|
+
statusColor = chalk.red;
|
|
175
|
+
paint();
|
|
176
|
+
return finish({ ok: false, error: "codigo expirado" });
|
|
177
|
+
}
|
|
178
|
+
if (typeof stdout.rows === "number") paint();
|
|
179
|
+
}, 140);
|
|
180
|
+
|
|
181
|
+
async function check() {
|
|
182
|
+
if (finished) return;
|
|
183
|
+
try {
|
|
184
|
+
const result = await poll(controller.signal);
|
|
185
|
+
if (finished) return;
|
|
186
|
+
if (result?.ok) return finish({ ok: true, session: result.session });
|
|
187
|
+
if (result?.cancelled) return finish(result);
|
|
188
|
+
if (result?.error) {
|
|
189
|
+
status = result.error;
|
|
190
|
+
statusColor = chalk.yellow;
|
|
191
|
+
paint();
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// erro passageiro: continua tentando ate expirar
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
poller = setInterval(check, Math.max(1, Number(interval) || 3) * 1000);
|
|
199
|
+
check();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { runLoginScreen };
|
package/src/models.js
CHANGED
|
@@ -16,12 +16,9 @@ async function listModels() {
|
|
|
16
16
|
const data = await res.json();
|
|
17
17
|
console.log(chalk.cyan("\nKafox models:\n"));
|
|
18
18
|
for (const p of data.pricing) {
|
|
19
|
-
const inp = (p.input_micros_per_1m / 1_000_000).toFixed(2);
|
|
20
|
-
const out = (p.output_micros_per_1m / 1_000_000).toFixed(2);
|
|
21
19
|
console.log(` ${chalk.bold(p.model)}`);
|
|
22
|
-
console.log(` in: ${inp} R$/1M out: ${out} R$/1M`);
|
|
23
20
|
}
|
|
24
|
-
console.log(`\nGateway: ${
|
|
21
|
+
console.log(chalk.dim(`\nGateway: ${data.gateway_url}`));
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
async function showBalance() {
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const chalk = require("chalk");
|
|
2
|
+
const { runAgent } = require("./agent");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Roda o agente sem TUI: serve para script, CI e para usar o kafox como
|
|
6
|
+
* "agente auditor" de outro processo.
|
|
7
|
+
*
|
|
8
|
+
* kafox agent "revise src/x.js e aponte bugs" [-m modelo] [-y] [--max-steps 12]
|
|
9
|
+
*
|
|
10
|
+
* Sem --max-steps o agente roda ate terminar (Ctrl+C aborta).
|
|
11
|
+
*/
|
|
12
|
+
async function runHeadless({
|
|
13
|
+
prompt,
|
|
14
|
+
model,
|
|
15
|
+
allowAll = false,
|
|
16
|
+
maxSteps = Infinity,
|
|
17
|
+
cwd = process.cwd(),
|
|
18
|
+
quiet = false,
|
|
19
|
+
json = false,
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!prompt || !String(prompt).trim()) {
|
|
22
|
+
throw new Error("informe o objetivo do agente");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const abort = () => controller.abort();
|
|
27
|
+
process.once("SIGINT", abort);
|
|
28
|
+
process.once("SIGTERM", abort);
|
|
29
|
+
|
|
30
|
+
const log = (line) => {
|
|
31
|
+
if (!quiet && !json) process.stderr.write(`${line}\n`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const events = [];
|
|
35
|
+
const denied = [];
|
|
36
|
+
const subagents = [];
|
|
37
|
+
|
|
38
|
+
const result = await runAgent({
|
|
39
|
+
messages: [{ role: "user", content: String(prompt) }],
|
|
40
|
+
model,
|
|
41
|
+
cwd,
|
|
42
|
+
maxSteps,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
autoApprove: () => allowAll,
|
|
45
|
+
approve: async (name, args, context) => {
|
|
46
|
+
if (allowAll) return true;
|
|
47
|
+
denied.push(name);
|
|
48
|
+
log(chalk.dim(`[${name}] negado (use --allow-all para liberar)`));
|
|
49
|
+
return false;
|
|
50
|
+
},
|
|
51
|
+
onText: (chunk) => {
|
|
52
|
+
if (!json) process.stdout.write(chunk);
|
|
53
|
+
},
|
|
54
|
+
onToolStart: ({ name, label, subagent }) => {
|
|
55
|
+
events.push({ type: "tool-start", name, label });
|
|
56
|
+
log(
|
|
57
|
+
chalk.dim(
|
|
58
|
+
`[${name}] ${String(label || "").slice(0, 140)}${
|
|
59
|
+
subagent ? ` (${subagent.count} subagente(s))` : ""
|
|
60
|
+
}`
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
onToolResult: ({ name, ok, ms, summary }) => {
|
|
65
|
+
events.push({ type: "tool-result", name, ok, ms });
|
|
66
|
+
log(chalk.dim(`[${name}] ${ok ? "ok" : "falhou"} · ${ms}ms · ${String(summary || "").slice(0, 120)}`));
|
|
67
|
+
},
|
|
68
|
+
onSubagentEvent: (event) => {
|
|
69
|
+
if (event.phase === "start") {
|
|
70
|
+
subagents.push({ objective: event.objective, tools: event.tools });
|
|
71
|
+
log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.objective}`));
|
|
72
|
+
} else if (event.phase === "tool") {
|
|
73
|
+
log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.tool}: ${String(event.text || "").slice(0, 100)}`));
|
|
74
|
+
} else if (event.phase === "done") {
|
|
75
|
+
log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.ok ? "concluído" : "falhou"} em ${event.ms}ms`));
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!json && !quiet && result.text) process.stdout.write("\n");
|
|
81
|
+
|
|
82
|
+
const payload = {
|
|
83
|
+
ok: Boolean(result.ok),
|
|
84
|
+
text: result.text || "",
|
|
85
|
+
steps: result.steps || 0,
|
|
86
|
+
ms: result.ms || 0,
|
|
87
|
+
error: result.error || null,
|
|
88
|
+
aborted: Boolean(result.aborted),
|
|
89
|
+
tools: events,
|
|
90
|
+
subagents,
|
|
91
|
+
denied,
|
|
92
|
+
};
|
|
93
|
+
if (json) process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
94
|
+
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { runHeadless };
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historico de conversas do CLI.
|
|
3
|
+
*
|
|
4
|
+
* Cada sessao vira um arquivo em ~/.kafox/chats/<codigo>.json e o codigo curto
|
|
5
|
+
* e o que aparece no `kafox resume <codigo>` (e no `kafox history`).
|
|
6
|
+
*/
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const storage = require("./storage");
|
|
12
|
+
|
|
13
|
+
// Poda: com muitos arquivos a pasta vira lixo e o `history` precisa listar rapido.
|
|
14
|
+
const MAX_SESSIONS = 60;
|
|
15
|
+
const TITLE_MAX = 60;
|
|
16
|
+
|
|
17
|
+
const FALLBACK_DIR = process.env.KAFOX_HOME
|
|
18
|
+
? path.resolve(process.env.KAFOX_HOME)
|
|
19
|
+
: path.join(os.homedir(), ".kafox");
|
|
20
|
+
|
|
21
|
+
// storage.DIR vem do modulo real; em teste ele pode ser stub, entao nunca
|
|
22
|
+
// confiar cegamente (path.join(undefined) estoura).
|
|
23
|
+
function baseDir() {
|
|
24
|
+
return storage.DIR || FALLBACK_DIR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function chatsDir() {
|
|
28
|
+
return path.join(baseDir(), "chats");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDir() {
|
|
32
|
+
fs.mkdirSync(chatsDir(), { recursive: true, mode: 0o700 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function newId() {
|
|
36
|
+
return crypto.randomBytes(4).toString("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fileFor(id) {
|
|
40
|
+
return path.join(chatsDir(), `${id}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Titulo curto: primeira mensagem do usuario, em uma linha. */
|
|
44
|
+
function titleFrom(messages, fallback = "") {
|
|
45
|
+
const first = (messages || []).find(
|
|
46
|
+
(message) => message?.role === "user" && String(message.text || "").trim()
|
|
47
|
+
);
|
|
48
|
+
const text = String(first?.text || fallback || "")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim();
|
|
51
|
+
if (!text) return "conversa sem titulo";
|
|
52
|
+
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
56
|
+
if (!id) return null;
|
|
57
|
+
ensureDir();
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
60
|
+
const data = {
|
|
61
|
+
id,
|
|
62
|
+
title: title || titleFrom(list),
|
|
63
|
+
cwd: cwd || process.cwd(),
|
|
64
|
+
model: model || null,
|
|
65
|
+
theme: theme || null,
|
|
66
|
+
startedAt: startedAt || now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
messages: list,
|
|
69
|
+
context: Array.isArray(context) ? context : [],
|
|
70
|
+
};
|
|
71
|
+
const tmp = `${fileFor(id)}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
73
|
+
fs.renameSync(tmp, fileFor(id));
|
|
74
|
+
prune();
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function load(id) {
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(fs.readFileSync(fileFor(id), "utf8"));
|
|
82
|
+
if (!data || typeof data !== "object") return null;
|
|
83
|
+
// Arquivo antigo pode nao ter titulo: deriva das mensagens.
|
|
84
|
+
if (!data.title) data.title = titleFrom(data.messages);
|
|
85
|
+
return data;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function list() {
|
|
92
|
+
let names = [];
|
|
93
|
+
try {
|
|
94
|
+
names = fs.readdirSync(chatsDir()).filter((name) => name.endsWith(".json"));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const chats = [];
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const id = name.replace(/\.json$/, "");
|
|
101
|
+
const data = load(id);
|
|
102
|
+
if (!data) continue;
|
|
103
|
+
chats.push({
|
|
104
|
+
id,
|
|
105
|
+
title: data.title || titleFrom(data.messages),
|
|
106
|
+
cwd: data.cwd,
|
|
107
|
+
model: data.model,
|
|
108
|
+
updatedAt: data.updatedAt,
|
|
109
|
+
messages: (data.messages || []).length,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return chats.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function latest() {
|
|
116
|
+
return list()[0] || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function prune() {
|
|
120
|
+
for (const chat of list().slice(MAX_SESSIONS)) {
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(fileFor(chat.id));
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
MAX_SESSIONS,
|
|
129
|
+
TITLE_MAX,
|
|
130
|
+
chatsDir,
|
|
131
|
+
newId,
|
|
132
|
+
titleFrom,
|
|
133
|
+
save,
|
|
134
|
+
load,
|
|
135
|
+
list,
|
|
136
|
+
latest,
|
|
137
|
+
prune,
|
|
138
|
+
};
|
package/src/storage.js
CHANGED
|
@@ -4,7 +4,8 @@ const path = require("path");
|
|
|
4
4
|
const os = require("os");
|
|
5
5
|
|
|
6
6
|
const HOME = os.homedir();
|
|
7
|
-
|
|
7
|
+
// KAFOX_HOME aponta a pasta de configuracao direto (testes e uso portatil).
|
|
8
|
+
const DIR = process.env.KAFOX_HOME ? path.resolve(process.env.KAFOX_HOME) : path.join(HOME, ".kafox");
|
|
8
9
|
const TOKEN_FILE = path.join(DIR, "token.json");
|
|
9
10
|
const CONFIG_FILE = path.join(DIR, "config.json");
|
|
10
11
|
|