kafox-cli 0.6.0 → 0.8.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 +61 -5
- package/bin/kafox.js +85 -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 +172 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/tools.js +12 -0
- package/src/tui.js +216 -44
package/src/sessions.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
/** Codigo digitado pelo usuario: so letras/numeros (sem caminho, sem "."). */
|
|
44
|
+
function normalizeId(value) {
|
|
45
|
+
return String(value || "")
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9]/g, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Data curta do chat: "2026-10-28T..." -> "28/10". */
|
|
52
|
+
function dayMonth(value) {
|
|
53
|
+
const parsed = new Date(value || "");
|
|
54
|
+
if (Number.isNaN(parsed.getTime())) return "";
|
|
55
|
+
const day = String(parsed.getDate()).padStart(2, "0");
|
|
56
|
+
const month = String(parsed.getMonth() + 1).padStart(2, "0");
|
|
57
|
+
return `${day}/${month}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Titulo curto: primeira mensagem do usuario, em uma linha. */
|
|
61
|
+
function titleFrom(messages, fallback = "") {
|
|
62
|
+
const first = (messages || []).find(
|
|
63
|
+
(message) => message?.role === "user" && String(message.text || "").trim()
|
|
64
|
+
);
|
|
65
|
+
const text = String(first?.text || fallback || "")
|
|
66
|
+
.replace(/\s+/g, " ")
|
|
67
|
+
.trim();
|
|
68
|
+
if (!text) return "conversa sem titulo";
|
|
69
|
+
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
ensureDir();
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
77
|
+
const data = {
|
|
78
|
+
id,
|
|
79
|
+
title: title || titleFrom(list),
|
|
80
|
+
cwd: cwd || process.cwd(),
|
|
81
|
+
model: model || null,
|
|
82
|
+
theme: theme || null,
|
|
83
|
+
startedAt: startedAt || now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
messages: list,
|
|
86
|
+
context: Array.isArray(context) ? context : [],
|
|
87
|
+
};
|
|
88
|
+
const tmp = `${fileFor(id)}.tmp`;
|
|
89
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
90
|
+
fs.renameSync(tmp, fileFor(id));
|
|
91
|
+
prune();
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function load(id) {
|
|
96
|
+
if (!id) return null;
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(fs.readFileSync(fileFor(id), "utf8"));
|
|
99
|
+
if (!data || typeof data !== "object") return null;
|
|
100
|
+
// Arquivo antigo pode nao ter titulo: deriva das mensagens.
|
|
101
|
+
if (!data.title) data.title = titleFrom(data.messages);
|
|
102
|
+
return data;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function list() {
|
|
109
|
+
let names = [];
|
|
110
|
+
try {
|
|
111
|
+
names = fs.readdirSync(chatsDir()).filter((name) => name.endsWith(".json"));
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const chats = [];
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
const id = name.replace(/\.json$/, "");
|
|
118
|
+
const data = load(id);
|
|
119
|
+
if (!data) continue;
|
|
120
|
+
chats.push({
|
|
121
|
+
id,
|
|
122
|
+
title: data.title || titleFrom(data.messages),
|
|
123
|
+
cwd: data.cwd,
|
|
124
|
+
model: data.model,
|
|
125
|
+
updatedAt: data.updatedAt,
|
|
126
|
+
messages: (data.messages || []).length,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return chats.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function latest() {
|
|
133
|
+
return list()[0] || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acha a conversa por id exato ou por prefixo unico: `kafox resume 3f1c`
|
|
138
|
+
* tambem funciona quando o codigo foi copiado pela metade.
|
|
139
|
+
*/
|
|
140
|
+
function find(value) {
|
|
141
|
+
const needle = normalizeId(value);
|
|
142
|
+
if (!needle) return null;
|
|
143
|
+
const exact = load(needle);
|
|
144
|
+
if (exact) return exact;
|
|
145
|
+
const matches = list().filter((chat) => chat.id.startsWith(needle));
|
|
146
|
+
if (matches.length !== 1) return null;
|
|
147
|
+
return load(matches[0].id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function prune() {
|
|
151
|
+
for (const chat of list().slice(MAX_SESSIONS)) {
|
|
152
|
+
try {
|
|
153
|
+
fs.unlinkSync(fileFor(chat.id));
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
MAX_SESSIONS,
|
|
160
|
+
TITLE_MAX,
|
|
161
|
+
chatsDir,
|
|
162
|
+
newId,
|
|
163
|
+
titleFrom,
|
|
164
|
+
normalizeId,
|
|
165
|
+
dayMonth,
|
|
166
|
+
save,
|
|
167
|
+
load,
|
|
168
|
+
list,
|
|
169
|
+
latest,
|
|
170
|
+
find,
|
|
171
|
+
prune,
|
|
172
|
+
};
|
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
|
|
package/src/subagents.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagentes: um agente dentro do agente.
|
|
3
|
+
*
|
|
4
|
+
* O modelo chama `spawn_subagent` com um objetivo; cada tarefa roda em contexto
|
|
5
|
+
* proprio (nao polui a conversa principal) e devolve apenas o resultado final.
|
|
6
|
+
* Várias tarefas rodam em paralelo, o que serve para investigar pontos
|
|
7
|
+
* diferentes do projeto ao mesmo tempo (auditoria, testes, levantamento).
|
|
8
|
+
*/
|
|
9
|
+
const SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
10
|
+
const MAX_TASKS = 3;
|
|
11
|
+
const MAX_DEPTH = 1;
|
|
12
|
+
// Subagente tambem nao tem teto por padrao: roda ate entregar o objetivo.
|
|
13
|
+
const DEFAULT_MAX_STEPS = Infinity;
|
|
14
|
+
const READ_ONLY_TOOLS = ["read_file", "list_dir"];
|
|
15
|
+
const ALL_LOCAL_TOOLS = ["read_file", "list_dir", "write_file", "edit_file", "run_command"];
|
|
16
|
+
|
|
17
|
+
const SUBAGENT_TOOL = {
|
|
18
|
+
type: "function",
|
|
19
|
+
function: {
|
|
20
|
+
name: SUBAGENT_TOOL_NAME,
|
|
21
|
+
description:
|
|
22
|
+
"Lanca um ou mais subagentes com objetivo proprio. Cada subagente roda em contexto separado, usa as ferramentas liberadas (padrao: read_file e list_dir) e devolve so o resultado final. Use para dividir trabalho, investigar varios pontos em paralelo (auditoria, testes, busca de arquivos) ou tarefas longas que nao precisam sujar esta conversa.",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
tasks: {
|
|
27
|
+
type: "array",
|
|
28
|
+
description: `Lista de tarefas (1 a ${MAX_TASKS}). Independentes rodam em paralelo.`,
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
objective: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "O que o subagente deve entregar, com o criterio de pronto",
|
|
35
|
+
},
|
|
36
|
+
context: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Contexto extra: arquivos, caminhos, comandos, restricoes",
|
|
39
|
+
},
|
|
40
|
+
tools: {
|
|
41
|
+
type: "array",
|
|
42
|
+
items: { type: "string" },
|
|
43
|
+
description:
|
|
44
|
+
"Ferramentas liberadas para esta tarefa (read_file, list_dir, write_file, edit_file, run_command). Padrao: read_file e list_dir.",
|
|
45
|
+
},
|
|
46
|
+
max_steps: {
|
|
47
|
+
type: "integer",
|
|
48
|
+
description: "Limite de passos (opcional; sem ele o subagente roda ate terminar)",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ["objective"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
objective: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Atalho para uma unica tarefa (equivalente a tasks: [{objective}])",
|
|
57
|
+
},
|
|
58
|
+
context: { type: "string", description: "Contexto extra para a tarefa unica" },
|
|
59
|
+
model: { type: "string", description: "Modelo dos subagentes (padrao: o da sessao)" },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function normalizeTasks(args = {}) {
|
|
66
|
+
const list = [];
|
|
67
|
+
if (Array.isArray(args.tasks)) {
|
|
68
|
+
for (const item of args.tasks) {
|
|
69
|
+
if (!item) continue;
|
|
70
|
+
if (typeof item === "string") list.push({ objective: item });
|
|
71
|
+
else if (item.objective) list.push(item);
|
|
72
|
+
}
|
|
73
|
+
} else if (args.objective) {
|
|
74
|
+
list.push({ objective: args.objective, context: args.context, tools: args.tools });
|
|
75
|
+
}
|
|
76
|
+
return list.slice(0, MAX_TASKS).map((task) => ({
|
|
77
|
+
objective: String(task.objective).trim(),
|
|
78
|
+
context: task.context ? String(task.context) : "",
|
|
79
|
+
tools: allowedTools(task.tools),
|
|
80
|
+
maxSteps: Number(task.max_steps || task.maxSteps) > 0 ? Number(task.max_steps || task.maxSteps) : Infinity,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function allowedTools(requested) {
|
|
85
|
+
if (!Array.isArray(requested) || !requested.length) return [...READ_ONLY_TOOLS];
|
|
86
|
+
const chosen = requested
|
|
87
|
+
.map((name) => String(name).trim())
|
|
88
|
+
.filter((name) => ALL_LOCAL_TOOLS.includes(name));
|
|
89
|
+
return chosen.length ? [...new Set(chosen)] : [...READ_ONLY_TOOLS];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function subagentPrompt({ objective, context, cwd, depth }) {
|
|
93
|
+
return [
|
|
94
|
+
"Voce e um subagente do kafox CLI, rodando em contexto separado do agente principal.",
|
|
95
|
+
`Diretorio de trabalho: ${cwd}`,
|
|
96
|
+
`Objetivo: ${objective}`,
|
|
97
|
+
context ? `Contexto: ${context}` : "",
|
|
98
|
+
depth >= MAX_DEPTH ? "Nao lance outros subagentes." : "",
|
|
99
|
+
"Trabalhe so no objetivo recebido, use as ferramentas disponiveis e pare quando tiver a resposta.",
|
|
100
|
+
"Devolva um relatorio curto e direto em Markdown: o que encontrou, evidencias (arquivo:linha, saida de comando) e o que ficou em duvida.",
|
|
101
|
+
"Nao invente nada: se nao conseguiu verificar, diga isso explicitamente.",
|
|
102
|
+
]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeSubagentCall(args = {}) {
|
|
108
|
+
const tasks = normalizeTasks(args);
|
|
109
|
+
if (!tasks.length) return "(sem objetivo)";
|
|
110
|
+
if (tasks.length === 1) return tasks[0].objective.slice(0, 120);
|
|
111
|
+
return `${tasks.length} tarefas: ${tasks.map((task) => task.objective.slice(0, 40)).join(" | ")}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ------------------------- historico da sessao ------------------------- */
|
|
115
|
+
// O que os subagentes desta sessao fizeram: alimenta o `/agents`.
|
|
116
|
+
const SESSION_RUNS = [];
|
|
117
|
+
const MAX_RUNS = 50;
|
|
118
|
+
|
|
119
|
+
function recordRun({ objective, tools = [], ok, ms = 0, steps = 0, report = "" }) {
|
|
120
|
+
SESSION_RUNS.push({
|
|
121
|
+
objective: String(objective || "(sem objetivo)"),
|
|
122
|
+
tools: [...tools],
|
|
123
|
+
ok: Boolean(ok),
|
|
124
|
+
ms: Number(ms) || 0,
|
|
125
|
+
steps: Number(steps) || 0,
|
|
126
|
+
report: String(report || ""),
|
|
127
|
+
at: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
if (SESSION_RUNS.length > MAX_RUNS) SESSION_RUNS.splice(0, SESSION_RUNS.length - MAX_RUNS);
|
|
130
|
+
return SESSION_RUNS[SESSION_RUNS.length - 1];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function listRuns() {
|
|
134
|
+
return SESSION_RUNS.map((run) => ({ ...run }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clearRuns() {
|
|
138
|
+
SESSION_RUNS.length = 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
SUBAGENT_TOOL,
|
|
143
|
+
SUBAGENT_TOOL_NAME,
|
|
144
|
+
MAX_TASKS,
|
|
145
|
+
MAX_DEPTH,
|
|
146
|
+
DEFAULT_MAX_STEPS,
|
|
147
|
+
READ_ONLY_TOOLS,
|
|
148
|
+
ALL_LOCAL_TOOLS,
|
|
149
|
+
normalizeTasks,
|
|
150
|
+
allowedTools,
|
|
151
|
+
subagentPrompt,
|
|
152
|
+
describeSubagentCall,
|
|
153
|
+
recordRun,
|
|
154
|
+
listRuns,
|
|
155
|
+
clearRuns,
|
|
156
|
+
};
|
package/src/tools.js
CHANGED
|
@@ -102,6 +102,10 @@ const TOOL_NAMES = LOCAL_TOOL_NAMES;
|
|
|
102
102
|
const APPROVAL_REQUIRED = new Set(["write_file", "edit_file", "run_command"]);
|
|
103
103
|
|
|
104
104
|
const TOOL_META = {
|
|
105
|
+
// A ferramenta de subagente e montada no agent.js (nao vai para TOOL_SPECS
|
|
106
|
+
// porque só aparece enquanto a profundidade permite), mas o card da TUI
|
|
107
|
+
// precisa do mesmo rótulo das outras.
|
|
108
|
+
spawn_subagent: { kind: "agent", label: "Subagente" },
|
|
105
109
|
read_file: { kind: "read", label: "Read" },
|
|
106
110
|
list_dir: { kind: "list", label: "List" },
|
|
107
111
|
write_file: { kind: "write", label: "Write" },
|
|
@@ -292,6 +296,14 @@ function previewToolChange(name, args = {}, context = {}) {
|
|
|
292
296
|
|
|
293
297
|
/** Resumo curto da chamada para mostrar na conversa. */
|
|
294
298
|
function describeToolCall(name, args = {}) {
|
|
299
|
+
if (name === "spawn_subagent") {
|
|
300
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : args.objective ? [args] : [];
|
|
301
|
+
if (!tasks.length) return "(sem objetivo)";
|
|
302
|
+
if (tasks.length === 1) return String(tasks[0].objective || "(objetivo)").slice(0, 120);
|
|
303
|
+
return `${tasks.length} subagentes: ${tasks
|
|
304
|
+
.map((task) => String(task.objective || "").slice(0, 40))
|
|
305
|
+
.join(" | ")}`;
|
|
306
|
+
}
|
|
295
307
|
if (name === "read_file") return args.path || "(arquivo)";
|
|
296
308
|
if (name === "write_file") return `${args.path || "(arquivo)"} (${String(args.content || "").length} chars)`;
|
|
297
309
|
if (name === "edit_file") return `${args.path || "(arquivo)"}`;
|