kafox-cli 0.9.9 → 0.10.1

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,296 @@
1
+ /**
2
+ * SessionBridge: a ponte de sessao entre o agente e os clientes (TUI e mobile).
3
+ *
4
+ * Ele NAO reimplementa o agente: chama `runAgent` (src/agent.js) exatamente como
5
+ * a TUI chama, e transforma os callbacks em eventos numerados no EventLog — que
6
+ * a TUI e o celular (via SSE) consomem.
7
+ *
8
+ * Modelo de uso:
9
+ * const bridge = new SessionBridge({ cwd, sessionId, messages, context, model });
10
+ * bridge.on("event", (ev) => renderTUI(ev)); // a TUI assina
11
+ * bridge.prompt("faz X"); // o usuario manda
12
+ * await bridge.approve(id, "once"|"always"|"deny"); // decisao de tool
13
+ * bridge.abort(); // para o turno
14
+ *
15
+ * Estado persistido: usa src/sessions.js (os mesmos ~/.kafox/chats/<id>.json),
16
+ * com debounce, igual a TUI, para o `kafox resume` continuar achando a conversa.
17
+ */
18
+
19
+ const { EventEmitter } = require("events");
20
+ const { runAgent } = require("../agent");
21
+ const { estimateTokens } = require("../compact");
22
+ const sessions = require("../sessions");
23
+ const { trimHistory } = require("./history");
24
+ const { EventLog } = require("./protocol");
25
+
26
+ const SAVE_DEBOUNCE_MS = 400;
27
+
28
+ class SessionBridge extends EventEmitter {
29
+ constructor({
30
+ cwd = process.cwd(),
31
+ sessionId = null,
32
+ sessionStartedAt = null,
33
+ messages = [],
34
+ context = [],
35
+ model = null,
36
+ allowAll = false,
37
+ mcp = null,
38
+ extraTools = [],
39
+ } = {}) {
40
+ super();
41
+ this.cwd = cwd;
42
+ this.sessionId = sessionId || sessions.newId();
43
+ this.title = null; // preenchido no snapshot a partir da 1a mensagem do usuario
44
+ this.sessionStartedAt = sessionStartedAt || new Date().toISOString();
45
+ this.messages = Array.isArray(messages) ? messages : [];
46
+ this.context = Array.isArray(context) ? context : [];
47
+ this.model = model || null;
48
+ this.allowAll = Boolean(allowAll);
49
+ this.mcp = mcp;
50
+ this.extraTools = extraTools;
51
+
52
+ this.log = new EventLog();
53
+ this.busy = false;
54
+ this.streaming = null;
55
+ this.abortController = null;
56
+ this.pendingApproval = null; // { id, resolve, name, args, change }
57
+ this.approvalCounter = 0;
58
+ this.saveTimer = null;
59
+ }
60
+
61
+ /** Snapshot inicial para quem acabou de conectar (historico + estado). */
62
+ snapshot() {
63
+ // O celular nao sobe o chat inteiro: manda so as ultimas ~20 mensagens
64
+ // (10 do usuario + 10 da IA). O resto fica no PC.
65
+ const trimmed = trimHistory(this.messages);
66
+ return {
67
+ sessionId: this.sessionId,
68
+ title:
69
+ this.title ||
70
+ (this.messages.find((m) => m.role === "user")?.text || "").slice(0, 120) ||
71
+ null,
72
+ model: this.model,
73
+ cwd: this.cwd,
74
+ allowAll: this.allowAll,
75
+ busy: this.busy,
76
+ streaming: this.streaming,
77
+ messages: trimmed.messages,
78
+ historyTruncated: trimmed.truncated,
79
+ historyTotal: trimmed.total,
80
+ context: this.context,
81
+ pendingApproval: this.pendingApproval
82
+ ? {
83
+ id: this.pendingApproval.id,
84
+ name: this.pendingApproval.name,
85
+ args: this.pendingApproval.args,
86
+ change: this.pendingApproval.change,
87
+ }
88
+ : null,
89
+ seq: this.log.seq,
90
+ };
91
+ }
92
+
93
+ emitEvent(type, payload) {
94
+ const event = this.log.push(type, payload);
95
+ this.emit("event", event);
96
+ return event;
97
+ }
98
+
99
+ /** Enfileira/roda um turno do usuario. Um turno por vez (igual a TUI). */
100
+ async prompt(text) {
101
+ const line = String(text || "").trim();
102
+ if (!line) return { ok: false, error: "vazio" };
103
+ if (this.busy) return { ok: false, error: "ocupado" };
104
+
105
+ this.emitEvent("user", { text: line });
106
+ this.messages.push({ role: "user", text: line });
107
+ this.context.push({ role: "user", content: line });
108
+ this.scheduleSave();
109
+ return this.runTurn();
110
+ }
111
+
112
+ async runTurn() {
113
+ this.busy = true;
114
+ this.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
115
+ this.emitEvent("stream-start", {});
116
+ const controller = new AbortController();
117
+ this.abortController = controller;
118
+
119
+ let result;
120
+ try {
121
+ result = await runAgent({
122
+ messages: this.context,
123
+ model: this.model,
124
+ cwd: this.cwd,
125
+ approve: (name, args, extra) => this.requestApproval(name, args, extra),
126
+ autoApprove: () => this.allowAll,
127
+ extraTools: this.extraTools,
128
+ mcp: this.mcp,
129
+ signal: controller.signal,
130
+ onText: (chunk) => {
131
+ if (!this.streaming) return;
132
+ this.streaming.text += chunk;
133
+ this.emitEvent("token", { text: chunk });
134
+ },
135
+ onReasoning: (chunk) => {
136
+ if (!this.streaming) return;
137
+ if (!this.streaming.thinkingStartedAt) this.streaming.thinkingStartedAt = Date.now();
138
+ this.streaming.reasoning += chunk;
139
+ this.emitEvent("thought", { text: chunk });
140
+ },
141
+ onToolStart: ({ id, name, args, label, change, fromMcp, needsApproval, subagent }) => {
142
+ this.finishStreaming();
143
+ this.emitEvent("tool-start", {
144
+ id,
145
+ name,
146
+ args,
147
+ label,
148
+ change,
149
+ fromMcp,
150
+ needsApproval,
151
+ subagent,
152
+ });
153
+ },
154
+ onToolResult: ({ id, name, output, change, ok, skipped, summary, ms }) => {
155
+ this.emitEvent("tool-result", {
156
+ id,
157
+ name,
158
+ output: String(output || "").slice(0, 6000),
159
+ change,
160
+ ok,
161
+ skipped,
162
+ summary,
163
+ ms,
164
+ });
165
+ },
166
+ onRetry: (info) => this.emitEvent("retry", info),
167
+ onCompact: ({ before, after, summary }) => {
168
+ this.finishStreaming();
169
+ this.messages.push({ role: "compaction", text: summary || "", compaction: { before, after, at: Date.now() } });
170
+ this.emitEvent("compact", { before, after, summary });
171
+ },
172
+ onSubagentEvent: (event) =>
173
+ this.emitEvent("subagent", {
174
+ id: event.id,
175
+ index: event.index,
176
+ total: event.total,
177
+ objective: event.objective,
178
+ phase: event.phase,
179
+ text: event.text,
180
+ tool: event.tool,
181
+ ok: event.ok,
182
+ ms: event.ms,
183
+ }),
184
+ });
185
+ } catch (error) {
186
+ result = { ok: false, error: `Falha inesperada: ${error.message}` };
187
+ }
188
+
189
+ this.finishStreaming();
190
+ this.abortController = null;
191
+ this.busy = false;
192
+
193
+ if (result.messages) this.context = result.messages;
194
+
195
+ if (result.aborted || result.code === "aborted") {
196
+ const partial = String(result.text || "").trim();
197
+ if (partial) this.messages.push({ role: "assistant", text: partial, ms: result.ms, interrupted: true });
198
+ this.emitEvent("aborted", { text: partial });
199
+ } else if (result.ok) {
200
+ const text = String(result.text || "").trim();
201
+ if (text) this.messages.push({ role: "assistant", text, ms: result.ms });
202
+ this.emitEvent("done", { text, ms: result.ms, usage: result.usage || null });
203
+ } else {
204
+ this.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
205
+ this.emitEvent("error", { text: result.error || "Falha ao responder." });
206
+ }
207
+
208
+ this.streaming = null;
209
+ this.scheduleSave();
210
+ return result;
211
+ }
212
+
213
+ finishStreaming() {
214
+ const streaming = this.streaming;
215
+ if (!streaming) return;
216
+ if (streaming.reasoning.trim()) {
217
+ this.messages.push({
218
+ role: "thinking",
219
+ text: streaming.reasoning.trim(),
220
+ ms: streaming.thinkingStartedAt ? Date.now() - streaming.thinkingStartedAt : 0,
221
+ });
222
+ }
223
+ // Nao empilha o texto aqui: o evento final ("done") ja carrega o texto e o
224
+ // runTurn commita uma unica vez — evita a duplicacao que a TUI tambem evita.
225
+ this.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
226
+ }
227
+
228
+ /** Pede aprovacao para uma ferramenta. Resolve pelo cliente (PC ou celular). */
229
+ requestApproval(name, args, extra = {}) {
230
+ if (this.allowAll) return Promise.resolve(true);
231
+ this.approvalCounter += 1;
232
+ const id = `ap_${Date.now().toString(36)}_${this.approvalCounter}`;
233
+ return new Promise((resolve) => {
234
+ this.pendingApproval = { id, name, args, change: extra.change ?? null, fromMcp: !!extra.fromMcp, resolve };
235
+ this.emitEvent("approval-request", {
236
+ id,
237
+ name,
238
+ args,
239
+ change: this.pendingApproval.change,
240
+ fromMcp: this.pendingApproval.fromMcp,
241
+ });
242
+ });
243
+ }
244
+
245
+ /** Resolve a aprovacao pendente. `decision` = "once" | "always" | "deny". */
246
+ approve(id, decision = "once") {
247
+ const pending = this.pendingApproval;
248
+ if (!pending) return false;
249
+ if (id && pending.id !== id) return false;
250
+ this.pendingApproval = null;
251
+ if (decision === "always") {
252
+ this.allowAll = true;
253
+ this.scheduleSave();
254
+ }
255
+ const allowed = decision !== "deny";
256
+ this.emitEvent("approval-resolved", { id: pending.id, decision, allowed });
257
+ pending.resolve(allowed);
258
+ return true;
259
+ }
260
+
261
+ abort() {
262
+ if (this.pendingApproval) this.approve(this.pendingApproval.id, "deny");
263
+ this.abortController?.abort();
264
+ }
265
+
266
+ scheduleSave() {
267
+ if (this.saveTimer) return;
268
+ this.saveTimer = setTimeout(() => {
269
+ this.saveTimer = null;
270
+ this.save();
271
+ }, SAVE_DEBOUNCE_MS);
272
+ this.saveTimer.unref?.();
273
+ }
274
+
275
+ save() {
276
+ try {
277
+ sessions.save({
278
+ id: this.sessionId,
279
+ cwd: this.cwd,
280
+ model: this.model,
281
+ allowAll: this.allowAll,
282
+ messages: this.messages,
283
+ context: this.context,
284
+ startedAt: this.sessionStartedAt,
285
+ });
286
+ } catch {
287
+ // persistencia best-effort: nunca derruba o turno.
288
+ }
289
+ }
290
+
291
+ tokens() {
292
+ return estimateTokens({ context: this.context });
293
+ }
294
+ }
295
+
296
+ module.exports = { SessionBridge };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Captcha proprio do kafox mobile — sem dependencia externa (sem node:crypto
3
+ * extra, sem servico de terceiros). Desafio aritmetico simples, renderizado
4
+ * como SVG inline no cliente, resolvido no servidor.
5
+ *
6
+ * Modelo de seguranca:
7
+ * - cada desafio e de USO UNICO e expira em CAPTCHA_TTL_MS;
8
+ * - a resposta fica guardada como hash (sha256), nunca em texto;
9
+ * - apos K tentativas o desafio morre (evita forca bruta);
10
+ * - limpeza oportunista dos expirados a cada emissao.
11
+ *
12
+ * Nao substitui Cloudflare Turnstile/hCaptcha, mas corta bots ingenuos e
13
+ * scripts que so reenviam o codigo do QR, que e o suficiente aqui.
14
+ */
15
+
16
+ const crypto = require("crypto");
17
+
18
+ const CAPTCHA_TTL_MS = 3 * 60 * 1000; // 3 minutos
19
+ const MAX_ATTEMPTS = 5;
20
+
21
+ /** @type {Map<string, {hash:string, expires:number, tries:number}>} */
22
+ const store = new Map();
23
+
24
+ function hash(text) {
25
+ return crypto.createHash("sha256").update(String(text).trim().toLowerCase()).digest("hex");
26
+ }
27
+
28
+ function sweep() {
29
+ const now = Date.now();
30
+ for (const [id, entry] of store) {
31
+ if (entry.expires <= now) store.delete(id);
32
+ }
33
+ }
34
+
35
+ function randomInt(min, max) {
36
+ // inteiro uniforme em [min, max] usando bytes aleatorios
37
+ const span = max - min + 1;
38
+ const buf = crypto.randomBytes(4);
39
+ const n = buf.readUInt32BE(0);
40
+ return min + (n % span);
41
+ }
42
+
43
+ /**
44
+ * Emite um novo desafio.
45
+ * @returns {{ id:string, question:string, expiresAt:number }}
46
+ */
47
+ function issue() {
48
+ sweep();
49
+ const a = randomInt(2, 9);
50
+ const b = randomInt(2, 9);
51
+ const usePlus = randomInt(0, 1) === 0;
52
+ let answer;
53
+ let question;
54
+ if (usePlus) {
55
+ answer = a + b;
56
+ question = `${a} + ${b} = ?`;
57
+ } else {
58
+ // subtracao sempre positiva
59
+ const hi = Math.max(a, b);
60
+ const lo = Math.min(a, b);
61
+ answer = hi - lo;
62
+ question = `${hi} - ${lo} = ?`;
63
+ }
64
+ const id = crypto.randomBytes(16).toString("hex");
65
+ store.set(id, { hash: hash(answer), expires: Date.now() + CAPTCHA_TTL_MS, tries: 0 });
66
+ return { id, question, expiresAt: Date.now() + CAPTCHA_TTL_MS };
67
+ }
68
+
69
+ /**
70
+ * Verifica e CONSOME o desafio (uso unico).
71
+ * @returns {{ ok:boolean, reason?:string }}
72
+ */
73
+ function verify(id, answer) {
74
+ const entry = store.get(String(id || ""));
75
+ if (!entry) return { ok: false, reason: "captcha expirado ou invalido" };
76
+ if (entry.expires <= Date.now()) {
77
+ store.delete(id);
78
+ return { ok: false, reason: "captcha expirado" };
79
+ }
80
+ entry.tries += 1;
81
+ if (entry.tries > MAX_ATTEMPTS) {
82
+ store.delete(id);
83
+ return { ok: false, reason: "tentativas demais — recarregue o captcha" };
84
+ }
85
+ if (entry.hash !== hash(answer)) {
86
+ return { ok: false, reason: "captcha incorreto" };
87
+ }
88
+ store.delete(id); // uso unico
89
+ return { ok: true };
90
+ }
91
+
92
+ function size() {
93
+ return store.size;
94
+ }
95
+
96
+ module.exports = { issue, verify, size, CAPTCHA_TTL_MS };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Recorte de historico para o celular.
3
+ *
4
+ * Regra do produto: o celular NUNCA sobe o chat inteiro. Sobe no maximo
5
+ * MAX_USER mensagens do usuario + MAX_ASSISTANT respostas da IA (as mais
6
+ * recentes), deixando a conversa leve no primeiro carregamento.
7
+ *
8
+ * O recorte preserva a ordem cronologica e, quando ha varias, prioriza as
9
+ * mensagens recentes de cada papel. Turnos auxiliares (system/erro/compaction)
10
+ * que caiam entre as selecionadas sao mantidos para o contexto fazer sentido.
11
+ */
12
+
13
+ const MAX_USER = 10;
14
+ const MAX_ASSISTANT = 10;
15
+
16
+ /**
17
+ * @param {Array<{role:string}>} messages
18
+ * @param {{ maxUser?:number, maxAssistant?:number }} [opts]
19
+ * @returns {{ messages:Array, truncated:boolean, total:number, shown:number }}
20
+ */
21
+ function trimHistory(messages, opts = {}) {
22
+ const list = Array.isArray(messages) ? messages : [];
23
+ const maxUser = Number.isInteger(opts.maxUser) ? opts.maxUser : MAX_USER;
24
+ const maxAssistant = Number.isInteger(opts.maxAssistant) ? opts.maxAssistant : MAX_ASSISTANT;
25
+
26
+ // Conta quantas ainda devemos MANTER, varrendo do fim pro inicio.
27
+ let userLeft = maxUser;
28
+ let assistantLeft = maxAssistant;
29
+ const keep = new Array(list.length).fill(false);
30
+
31
+ for (let i = list.length - 1; i >= 0; i--) {
32
+ const role = list[i] && list[i].role;
33
+ if (role === "user") {
34
+ if (userLeft > 0) {
35
+ keep[i] = true;
36
+ userLeft -= 1;
37
+ }
38
+ } else if (role === "assistant") {
39
+ if (assistantLeft > 0) {
40
+ keep[i] = true;
41
+ assistantLeft -= 1;
42
+ }
43
+ } else {
44
+ // system / error / compaction: acompanha o vizinho mais proximo que ficou.
45
+ // Mantem provisoriamente; a segunda passada remove orfaos das bordas.
46
+ keep[i] = true;
47
+ }
48
+ }
49
+
50
+ // Remove auxiliares nas bordas que nao tem nenhum user/assistant mantido
51
+ // depois (ou antes) deles — evita abrir o chat com uma linha solta.
52
+ let first = keep.indexOf(true);
53
+ let last = keep.lastIndexOf(true);
54
+ if (first === -1) return { messages: [], truncated: list.length > 0, total: list.length, shown: 0 };
55
+ while (first <= last && !isKeptTurn(list[first])) first++;
56
+ while (last >= first && !isKeptTurn(list[last])) last--;
57
+
58
+ const out = [];
59
+ for (let i = first; i <= last; i++) {
60
+ if (keep[i]) out.push(list[i]);
61
+ }
62
+ const truncated = out.length < list.length;
63
+ return { messages: out, truncated, total: list.length, shown: out.length };
64
+ }
65
+
66
+ function isKeptTurn(m) {
67
+ const role = m && m.role;
68
+ return role === "user" || role === "assistant";
69
+ }
70
+
71
+ module.exports = { trimHistory, MAX_USER, MAX_ASSISTANT };