kafox-cli 0.9.9 → 0.10.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,285 @@
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 { EventLog } = require("./protocol");
24
+
25
+ const SAVE_DEBOUNCE_MS = 400;
26
+
27
+ class SessionBridge extends EventEmitter {
28
+ constructor({
29
+ cwd = process.cwd(),
30
+ sessionId = null,
31
+ sessionStartedAt = null,
32
+ messages = [],
33
+ context = [],
34
+ model = null,
35
+ allowAll = false,
36
+ mcp = null,
37
+ extraTools = [],
38
+ } = {}) {
39
+ super();
40
+ this.cwd = cwd;
41
+ this.sessionId = sessionId || sessions.newId();
42
+ this.sessionStartedAt = sessionStartedAt || new Date().toISOString();
43
+ this.messages = Array.isArray(messages) ? messages : [];
44
+ this.context = Array.isArray(context) ? context : [];
45
+ this.model = model || null;
46
+ this.allowAll = Boolean(allowAll);
47
+ this.mcp = mcp;
48
+ this.extraTools = extraTools;
49
+
50
+ this.log = new EventLog();
51
+ this.busy = false;
52
+ this.streaming = null;
53
+ this.abortController = null;
54
+ this.pendingApproval = null; // { id, resolve, name, args, change }
55
+ this.approvalCounter = 0;
56
+ this.saveTimer = null;
57
+ }
58
+
59
+ /** Snapshot inicial para quem acabou de conectar (historico + estado). */
60
+ snapshot() {
61
+ return {
62
+ sessionId: this.sessionId,
63
+ model: this.model,
64
+ cwd: this.cwd,
65
+ allowAll: this.allowAll,
66
+ busy: this.busy,
67
+ streaming: this.streaming,
68
+ messages: this.messages,
69
+ context: this.context,
70
+ pendingApproval: this.pendingApproval
71
+ ? {
72
+ id: this.pendingApproval.id,
73
+ name: this.pendingApproval.name,
74
+ args: this.pendingApproval.args,
75
+ change: this.pendingApproval.change,
76
+ }
77
+ : null,
78
+ seq: this.log.seq,
79
+ };
80
+ }
81
+
82
+ emitEvent(type, payload) {
83
+ const event = this.log.push(type, payload);
84
+ this.emit("event", event);
85
+ return event;
86
+ }
87
+
88
+ /** Enfileira/roda um turno do usuario. Um turno por vez (igual a TUI). */
89
+ async prompt(text) {
90
+ const line = String(text || "").trim();
91
+ if (!line) return { ok: false, error: "vazio" };
92
+ if (this.busy) return { ok: false, error: "ocupado" };
93
+
94
+ this.emitEvent("user", { text: line });
95
+ this.messages.push({ role: "user", text: line });
96
+ this.context.push({ role: "user", content: line });
97
+ this.scheduleSave();
98
+ return this.runTurn();
99
+ }
100
+
101
+ async runTurn() {
102
+ this.busy = true;
103
+ this.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
104
+ this.emitEvent("stream-start", {});
105
+ const controller = new AbortController();
106
+ this.abortController = controller;
107
+
108
+ let result;
109
+ try {
110
+ result = await runAgent({
111
+ messages: this.context,
112
+ model: this.model,
113
+ cwd: this.cwd,
114
+ approve: (name, args, extra) => this.requestApproval(name, args, extra),
115
+ autoApprove: () => this.allowAll,
116
+ extraTools: this.extraTools,
117
+ mcp: this.mcp,
118
+ signal: controller.signal,
119
+ onText: (chunk) => {
120
+ if (!this.streaming) return;
121
+ this.streaming.text += chunk;
122
+ this.emitEvent("token", { text: chunk });
123
+ },
124
+ onReasoning: (chunk) => {
125
+ if (!this.streaming) return;
126
+ if (!this.streaming.thinkingStartedAt) this.streaming.thinkingStartedAt = Date.now();
127
+ this.streaming.reasoning += chunk;
128
+ this.emitEvent("thought", { text: chunk });
129
+ },
130
+ onToolStart: ({ id, name, args, label, change, fromMcp, needsApproval, subagent }) => {
131
+ this.finishStreaming();
132
+ this.emitEvent("tool-start", {
133
+ id,
134
+ name,
135
+ args,
136
+ label,
137
+ change,
138
+ fromMcp,
139
+ needsApproval,
140
+ subagent,
141
+ });
142
+ },
143
+ onToolResult: ({ id, name, output, change, ok, skipped, summary, ms }) => {
144
+ this.emitEvent("tool-result", {
145
+ id,
146
+ name,
147
+ output: String(output || "").slice(0, 6000),
148
+ change,
149
+ ok,
150
+ skipped,
151
+ summary,
152
+ ms,
153
+ });
154
+ },
155
+ onRetry: (info) => this.emitEvent("retry", info),
156
+ onCompact: ({ before, after, summary }) => {
157
+ this.finishStreaming();
158
+ this.messages.push({ role: "compaction", text: summary || "", compaction: { before, after, at: Date.now() } });
159
+ this.emitEvent("compact", { before, after, summary });
160
+ },
161
+ onSubagentEvent: (event) =>
162
+ this.emitEvent("subagent", {
163
+ id: event.id,
164
+ index: event.index,
165
+ total: event.total,
166
+ objective: event.objective,
167
+ phase: event.phase,
168
+ text: event.text,
169
+ tool: event.tool,
170
+ ok: event.ok,
171
+ ms: event.ms,
172
+ }),
173
+ });
174
+ } catch (error) {
175
+ result = { ok: false, error: `Falha inesperada: ${error.message}` };
176
+ }
177
+
178
+ this.finishStreaming();
179
+ this.abortController = null;
180
+ this.busy = false;
181
+
182
+ if (result.messages) this.context = result.messages;
183
+
184
+ if (result.aborted || result.code === "aborted") {
185
+ const partial = String(result.text || "").trim();
186
+ if (partial) this.messages.push({ role: "assistant", text: partial, ms: result.ms, interrupted: true });
187
+ this.emitEvent("aborted", { text: partial });
188
+ } else if (result.ok) {
189
+ const text = String(result.text || "").trim();
190
+ if (text) this.messages.push({ role: "assistant", text, ms: result.ms });
191
+ this.emitEvent("done", { text, ms: result.ms, usage: result.usage || null });
192
+ } else {
193
+ this.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
194
+ this.emitEvent("error", { text: result.error || "Falha ao responder." });
195
+ }
196
+
197
+ this.streaming = null;
198
+ this.scheduleSave();
199
+ return result;
200
+ }
201
+
202
+ finishStreaming() {
203
+ const streaming = this.streaming;
204
+ if (!streaming) return;
205
+ if (streaming.reasoning.trim()) {
206
+ this.messages.push({
207
+ role: "thinking",
208
+ text: streaming.reasoning.trim(),
209
+ ms: streaming.thinkingStartedAt ? Date.now() - streaming.thinkingStartedAt : 0,
210
+ });
211
+ }
212
+ // Nao empilha o texto aqui: o evento final ("done") ja carrega o texto e o
213
+ // runTurn commita uma unica vez — evita a duplicacao que a TUI tambem evita.
214
+ this.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
215
+ }
216
+
217
+ /** Pede aprovacao para uma ferramenta. Resolve pelo cliente (PC ou celular). */
218
+ requestApproval(name, args, extra = {}) {
219
+ if (this.allowAll) return Promise.resolve(true);
220
+ this.approvalCounter += 1;
221
+ const id = `ap_${Date.now().toString(36)}_${this.approvalCounter}`;
222
+ return new Promise((resolve) => {
223
+ this.pendingApproval = { id, name, args, change: extra.change ?? null, fromMcp: !!extra.fromMcp, resolve };
224
+ this.emitEvent("approval-request", {
225
+ id,
226
+ name,
227
+ args,
228
+ change: this.pendingApproval.change,
229
+ fromMcp: this.pendingApproval.fromMcp,
230
+ });
231
+ });
232
+ }
233
+
234
+ /** Resolve a aprovacao pendente. `decision` = "once" | "always" | "deny". */
235
+ approve(id, decision = "once") {
236
+ const pending = this.pendingApproval;
237
+ if (!pending) return false;
238
+ if (id && pending.id !== id) return false;
239
+ this.pendingApproval = null;
240
+ if (decision === "always") {
241
+ this.allowAll = true;
242
+ this.scheduleSave();
243
+ }
244
+ const allowed = decision !== "deny";
245
+ this.emitEvent("approval-resolved", { id: pending.id, decision, allowed });
246
+ pending.resolve(allowed);
247
+ return true;
248
+ }
249
+
250
+ abort() {
251
+ if (this.pendingApproval) this.approve(this.pendingApproval.id, "deny");
252
+ this.abortController?.abort();
253
+ }
254
+
255
+ scheduleSave() {
256
+ if (this.saveTimer) return;
257
+ this.saveTimer = setTimeout(() => {
258
+ this.saveTimer = null;
259
+ this.save();
260
+ }, SAVE_DEBOUNCE_MS);
261
+ this.saveTimer.unref?.();
262
+ }
263
+
264
+ save() {
265
+ try {
266
+ sessions.save({
267
+ id: this.sessionId,
268
+ cwd: this.cwd,
269
+ model: this.model,
270
+ allowAll: this.allowAll,
271
+ messages: this.messages,
272
+ context: this.context,
273
+ startedAt: this.sessionStartedAt,
274
+ });
275
+ } catch {
276
+ // persistencia best-effort: nunca derruba o turno.
277
+ }
278
+ }
279
+
280
+ tokens() {
281
+ return estimateTokens({ context: this.context });
282
+ }
283
+ }
284
+
285
+ module.exports = { SessionBridge };