kafox-cli 0.9.8 → 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.
- package/bin/kafox.js +78 -0
- package/package.json +18 -1
- package/src/agent.js +24 -0
- package/src/auth.js +25 -6
- package/src/cli.js +3 -0
- package/src/compact.js +346 -0
- package/src/gateway.js +119 -53
- package/src/mobile/auth.js +352 -0
- package/src/mobile/bridge.js +285 -0
- package/src/mobile/index.js +538 -0
- package/src/mobile/protocol.js +51 -0
- package/src/mobile/qr.js +487 -0
- package/src/mobile/server.js +297 -0
- package/src/mobile/tui-bridge.js +289 -0
- package/src/mobile/view.js +734 -0
- package/src/models.js +19 -0
- package/src/providers-cli.js +82 -0
- package/src/providers.js +87 -0
- package/src/storage.js +39 -0
- package/src/tools.js +13 -2
- package/src/tui.js +549 -44
|
@@ -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 };
|