kafox-cli 0.2.3 → 0.6.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/src/gateway.js CHANGED
@@ -3,26 +3,20 @@ const storage = require("./storage");
3
3
  const { ensureSession, fetchApi } = require("./auth");
4
4
 
5
5
  /**
6
- * Envia a pergunta ao gateway e devolve o resultado estruturado.
7
- * A TUI usa o retorno; o comando `kafox ask` so imprime.
6
+ * Garante sessao, saldo, modelo e chave antes de qualquer chamada.
7
+ * A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
8
8
  */
9
- async function askModel(question, options = {}) {
10
- const text = typeof question === "string" ? question : (question || []).join(" ");
11
- if (!text.trim()) {
12
- return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
13
- }
14
-
9
+ async function ensureReady(options = {}) {
15
10
  const token = await ensureSession();
16
11
  if (!token) {
17
12
  return { ok: false, error: "Login first: `kafox login`" };
18
13
  }
19
14
 
20
- // Saldo decide se a CLI responde: sem credito, nem chega no gateway.
21
15
  const boot = await fetchApi("/api/bootstrap");
22
16
  if (!boot.ok) {
23
17
  return { ok: false, error: "Session invalid. Run `kafox login`" };
24
18
  }
25
- const account = await boot.json();
19
+ const account = await boot.json().catch(() => null);
26
20
  const balance = Number(account?.user?.balance_micros || 0);
27
21
  if (balance <= 0) {
28
22
  return {
@@ -38,9 +32,7 @@ async function askModel(question, options = {}) {
38
32
  .then((response) => response.json())
39
33
  .catch(() => null);
40
34
  model = pricing?.pricing?.[0]?.model;
41
- if (!model) {
42
- return { ok: false, error: "Could not load model catalog." };
43
- }
35
+ if (!model) return { ok: false, error: "Could not load model catalog." };
44
36
  }
45
37
 
46
38
  let keyCreated = false;
@@ -59,51 +51,152 @@ async function askModel(question, options = {}) {
59
51
  keyCreated = true;
60
52
  }
61
53
 
62
- const response = await fetchApi("/api/public/playground", {
63
- method: "POST",
64
- body: JSON.stringify({ api_key: apiKey, model, message: text }),
65
- });
66
- const data = await response.json().catch(() => ({}));
54
+ return { ok: true, model, apiKey, keyCreated, balance };
55
+ }
56
+
57
+ function mergeToolCalls(target, deltas) {
58
+ for (const delta of deltas) {
59
+ const index = Number.isInteger(delta?.index) ? delta.index : target.length;
60
+ const current = target[index] || {
61
+ id: "",
62
+ type: "function",
63
+ function: { name: "", arguments: "" },
64
+ };
65
+ if (delta.id) current.id = delta.id;
66
+ if (delta.type) current.type = delta.type;
67
+ if (delta.function?.name) current.function.name += delta.function.name;
68
+ if (delta.function?.arguments) current.function.arguments += delta.function.arguments;
69
+ target[index] = current;
70
+ }
71
+ }
72
+
73
+ function errorFrom(data, status) {
74
+ const message =
75
+ data?.error?.message || data?.error || data?.body?.error?.message || `gateway error (${status})`;
76
+ return {
77
+ ok: false,
78
+ code: data?.code || (status === 402 ? "no_balance" : "gateway_error"),
79
+ error: String(message),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Chat completions com stream (SSE) via /api/chat.
85
+ * onDelta/onReasoning recebem o texto conforme chega; no fim devolve tudo junto.
86
+ */
87
+ async function chat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
88
+ const ready = await ensureReady({ model });
89
+ if (!ready.ok) return ready;
90
+
91
+ const started = Date.now();
92
+ let response;
93
+ try {
94
+ response = await fetchApi("/api/chat", {
95
+ method: "POST",
96
+ headers: { accept: "text/event-stream" },
97
+ body: JSON.stringify({
98
+ api_key: ready.apiKey,
99
+ model: ready.model,
100
+ messages,
101
+ tools: tools && tools.length ? tools : undefined,
102
+ stream: true,
103
+ }),
104
+ signal,
105
+ });
106
+ } catch (error) {
107
+ if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
108
+ return { ok: false, error: `Falha de rede: ${error.message}` };
109
+ }
67
110
 
68
111
  if (!response.ok) {
69
- if (response.status === 402) {
70
- return {
71
- ok: false,
72
- code: "no_balance",
73
- error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
74
- };
75
- }
76
- return { ok: false, error: `Failed: ${data.error || "gateway error"}` };
112
+ const data = await response.json().catch(() => ({}));
113
+ return { ...errorFrom(data, response.status), status: response.status, keyCreated: ready.keyCreated };
77
114
  }
78
115
 
79
- // O playground devolve 200 mesmo quando o gateway recusa a chamada.
80
- if (data.ok === false) {
81
- const detail = data.body?.error?.message || data.body?.error || "gateway error";
82
- if (data.status === 402) {
83
- return {
84
- ok: false,
85
- code: "no_balance",
86
- error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
87
- };
116
+ const decoder = new TextDecoder();
117
+ let buffer = "";
118
+ let text = "";
119
+ let reasoning = "";
120
+ let usage = null;
121
+ const toolCalls = [];
122
+
123
+ const handleLine = (line) => {
124
+ if (!line.startsWith("data:")) return null;
125
+ const data = line.slice(5).trim();
126
+ if (!data || data === "[DONE]") return null;
127
+ let event;
128
+ try {
129
+ event = JSON.parse(data);
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (event?.error) {
134
+ return { error: event.error.message || String(event.error) };
135
+ }
136
+ if (event?.usage) usage = event.usage;
137
+ const delta = event?.choices?.[0]?.delta;
138
+ if (!delta) return null;
139
+ // Cada provedor manda o "pensamento" com um nome diferente.
140
+ const think = delta.reasoning_content ?? delta.reasoning ?? delta.thinking;
141
+ if (typeof think === "string" && think) {
142
+ reasoning += think;
143
+ onReasoning?.(think);
144
+ }
145
+ if (delta.content) {
146
+ text += delta.content;
147
+ onDelta?.(delta.content);
148
+ }
149
+ if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
150
+ return null;
151
+ };
152
+
153
+ try {
154
+ for await (const chunk of response.body) {
155
+ buffer += decoder.decode(chunk, { stream: true });
156
+ let index = buffer.indexOf("\n");
157
+ while (index >= 0) {
158
+ const line = buffer.slice(0, index).replace(/\r$/, "");
159
+ buffer = buffer.slice(index + 1);
160
+ const failure = handleLine(line);
161
+ if (failure) return { ok: false, error: failure.error };
162
+ index = buffer.indexOf("\n");
163
+ }
88
164
  }
89
- return { ok: false, error: `Failed (${data.status}): ${detail}` };
165
+ if (buffer.trim()) handleLine(buffer.trim());
166
+ } catch (error) {
167
+ if (error?.name === "AbortError") {
168
+ return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
169
+ }
170
+ return { ok: false, error: `Stream interrompido: ${error.message}`, text };
90
171
  }
91
172
 
92
- const body = data.body;
93
- const output =
94
- typeof body === "string"
95
- ? body
96
- : body?.choices?.[0]?.message?.content ||
97
- body?.choices?.[0]?.text ||
98
- JSON.stringify(body, null, 2);
173
+ return {
174
+ ok: true,
175
+ text,
176
+ reasoning,
177
+ toolCalls: toolCalls.filter(Boolean),
178
+ usage,
179
+ ms: Date.now() - started,
180
+ model: ready.model,
181
+ keyCreated: ready.keyCreated,
182
+ provider: response.headers?.get?.("x-kafox-provider") || "",
183
+ };
184
+ }
99
185
 
186
+ /** Compatibilidade: resposta unica, sem callbacks (usada por `kafox ask`). */
187
+ async function askModel(question, options = {}) {
188
+ const text = typeof question === "string" ? question : (question || []).join(" ");
189
+ if (!text.trim()) {
190
+ return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
191
+ }
192
+ const result = await chat({ messages: [{ role: "user", content: text }], model: options.model });
193
+ if (!result.ok) return result;
100
194
  return {
101
195
  ok: true,
102
- text: String(output || "").trim() || "(resposta vazia)",
103
- ms: data.ms,
104
- provider: data.provider,
105
- model,
106
- keyCreated,
196
+ text: String(result.text || "").trim() || "(resposta vazia)",
197
+ ms: result.ms,
198
+ model: result.model,
199
+ keyCreated: result.keyCreated,
107
200
  };
108
201
  }
109
202
 
@@ -124,4 +217,4 @@ async function ask(question, options = {}) {
124
217
  }
125
218
  }
126
219
 
127
- module.exports = { ask, askModel };
220
+ module.exports = { ask, askModel, chat, ensureReady, mergeToolCalls };
package/src/logo.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Logo do kafox: a arte "KAFOX" em blocos (fundo azul), adaptada por tema.
3
+ *
4
+ * A arte original vem com codigos ANSI por trecho; aqui ela e decodificada em
5
+ * segmentos {fg, bg, texto} e repintada com chalk, para que o fundo do tema
6
+ * entre nos espacos transparentes e o texto continue legivel no tema claro.
7
+ */
8
+ const chalk = require("chalk");
9
+ const { themeColors } = require("./theme");
10
+
11
+ const SGR = /\x1b\[([0-9;]*)m/g;
12
+
13
+ // Paleta da arte. 97 = tinta clara, 94 = azul, 34 = azul fundo, 37 = neutro,
14
+ // 44 = fundo azul (aplicado junto de 94).
15
+ const PALETTES = {
16
+ dark: { ink: "#ffffff", gray: "#c8c8c8", blue: "#3f6ef0", deep: "#1c2fc4", fill: "#1c2fc4" },
17
+ light: { ink: "#2323b8", gray: "#6f6f6f", blue: "#4b52e8", deep: "#2a2ab0", fill: "#c7cfff" },
18
+ };
19
+
20
+ const STYLE_KEYS = {
21
+ 37: { fg: "gray" },
22
+ 34: { fg: "deep" },
23
+ 94: { fg: "blue" },
24
+ 97: { fg: "ink" },
25
+ 44: { bg: "fill" },
26
+ };
27
+
28
+ const RAW_LOGO = [
29
+ "\x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m█▓▒░\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m████\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0m",
30
+ "\x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m \x1b[0;34m██\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▒░░\x1b[0;34m█\x1b[0m",
31
+ "\x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;94;44m░▒░▒\x1b[0;34m▄\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m██████\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▒▒\x1b[0;34m█\x1b[0;37m \x1b[0;94m▀\x1b[0;94;44m▒░░\x1b[0;34m██\x1b[0;94;44m░▒▒\x1b[0;94m▀\x1b[0m",
32
+ "\x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0m",
33
+ "\x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m▀████████▀\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0m",
34
+ ];
35
+
36
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
37
+
38
+ /** Decodifica uma linha crua em segmentos com estilo e texto. */
39
+ function parseLine(raw) {
40
+ const segments = [];
41
+ let style = { fg: null, bg: null };
42
+ let last = 0;
43
+ let match;
44
+ SGR.lastIndex = 0;
45
+
46
+ while ((match = SGR.exec(raw)) !== null) {
47
+ if (match.index > last) {
48
+ segments.push({ ...style, text: raw.slice(last, match.index) });
49
+ }
50
+ const codes = match[1].split(";").filter(Boolean).map(Number);
51
+ for (const code of codes) {
52
+ if (code === 0) style = { fg: null, bg: null };
53
+ else if (STYLE_KEYS[code]) Object.assign(style, STYLE_KEYS[code]);
54
+ }
55
+ last = match.index + match[0].length;
56
+ }
57
+ if (last < raw.length) segments.push({ ...style, text: raw.slice(last) });
58
+ return segments;
59
+ }
60
+
61
+ const SEGMENTS = RAW_LOGO.map(parseLine);
62
+
63
+ const LOGO_TEXT = SEGMENTS.map((line) => line.map((part) => part.text).join(""));
64
+ const LOGO_WIDTH = Math.max(...LOGO_TEXT.map((line) => line.length));
65
+ const LOGO_HEIGHT = LOGO_TEXT.length;
66
+
67
+ /** Temas de fundo claro precisam de tinta escura para o desenho nao sumir. */
68
+ function paletteFor(theme) {
69
+ const { bg } = themeColors(theme);
70
+ if (!bg) return PALETTES.dark;
71
+ const [r, g, b] = [1, 3, 5].map((index) => parseInt(bg.slice(index, index + 2), 16));
72
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
73
+ return luminance > 0.6 ? PALETTES.light : PALETTES.dark;
74
+ }
75
+
76
+ function paintSegment(text, style, palette) {
77
+ if (!text) return "";
78
+ const fg = style.fg ? palette[style.fg] : null;
79
+ const bg = style.bg ? palette[style.bg] : null;
80
+ if (fg && bg) return chalk.bgHex(bg).hex(fg)(text);
81
+ if (bg) return chalk.bgHex(bg)(text);
82
+ if (fg) return chalk.hex(fg)(text);
83
+ return text;
84
+ }
85
+
86
+ /** Linhas da arte ja coloridas (sem a cor de fundo da tela). */
87
+ function logoLines(theme = "default") {
88
+ const palette = paletteFor(theme);
89
+ return SEGMENTS.map((segments) =>
90
+ segments.map((part) => paintSegment(part.text, part, palette)).join("")
91
+ );
92
+ }
93
+
94
+ /** Versao em texto puro (sem cor), para saidas nao-TTY e testes. */
95
+ function logoText() {
96
+ return LOGO_TEXT.join("\n");
97
+ }
98
+
99
+ module.exports = {
100
+ logoLines,
101
+ logoText,
102
+ parseLine,
103
+ LOGO_WIDTH,
104
+ LOGO_HEIGHT,
105
+ PALETTES,
106
+ paletteFor,
107
+ stripAnsi: (value) => String(value).replace(ANSI_RE, ""),
108
+ };
package/src/mascot.js CHANGED
@@ -1,32 +1,4 @@
1
- // Kafox mascot art (ASCII only, no accents).
2
- const MASCOT = [
3
- "████ ████",
4
- " ▒▒░░░░▓▓ ▓▓░░░░▒▒",
5
- " ██░░▒▒░░██ ██░░▒▒░░██",
6
- " ██░░▓▓▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒▓▓░░██",
7
- " ██░░▓▓▓▓▒▒▓▓ ▓▓▒▒▓▓▓▓░░██",
8
- " ██░░▒▒▒▒▓▓░░░░░░░░░░░░░░▓▓▒▒▒▒░░██",
9
- " ██▒▒▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓▒▒██",
10
- "▒▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓▓",
11
- "██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
12
- "██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
13
- "██ ░░▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓░░ ██",
14
- "██ ░░░░░░▓▓░░░░░░▒▒░░░░░░▓▓░░░░░░ ██",
15
- "██░░░░░░░░░░▓▓░░▒▒▒▒▒▒░░▓▓░░░░░░░░░░██",
16
- "██░░ ░░░░░░░░░░░░░░ ░░██ ▒▒▓▓",
17
- " ██ ▓▓▓▓▓▓ ██ ██ ██",
18
- " ▓▓ ▓▓ ▓▓ ▓▓▒▒ ██",
19
- " ██ ▓▓ ▓▓ ██ ██ ██",
20
- " ▓▓▓▓ ▓▓▓▓ ▓▓░░░░ ██",
21
- " ██▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒██░░▒▒▒▒░░░░░░██",
22
- " ██░░░░ ░░░░▓▓▒▒▒▒▒▒▒▒░░░░██",
23
- " ██░░░░░░ ░░░░░░▓▓▒▒▒▒▒▒░░██",
24
- " ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒▒▒▒▒▓▓",
25
- " ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒████",
26
- " ██▒▒░░ ▓▓ ▒▒ ▓▓ ░░▒▒██▓▓",
27
- " ████ ▓▓ ▓▓ ▓▓ ████",
28
- " ▒▒██▓▓ ▓▓ ▓▓██▒▒",
29
- " ██████████",
30
- ].join("\n");
1
+ // Compatibilidade: o "mascote" agora e o logo KAFOX em blocos (logo.js).
2
+ const { logoText, logoLines } = require("./logo");
31
3
 
32
- module.exports = { mascot: MASCOT };
4
+ module.exports = { mascot: logoText(), logoLines };