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.
- package/bin/kafox.js +78 -0
- package/package.json +1 -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/tui.js +517 -27
package/bin/kafox.js
CHANGED
|
@@ -63,6 +63,18 @@ program.command("models").description("List available models").action(listModels
|
|
|
63
63
|
program.command("balance").description("Show current balance").action(showBalance);
|
|
64
64
|
program.command("pricing").description("Show recharge packages").action(showPricing);
|
|
65
65
|
program.command("key").description("Create an API key for this CLI").action(createKey);
|
|
66
|
+
program
|
|
67
|
+
.command("provider")
|
|
68
|
+
.alias("provedor")
|
|
69
|
+
.description("Gerencia provedores alternativos (chaves de API externas)")
|
|
70
|
+
.argument("[provider]", "Provedor")
|
|
71
|
+
.argument("[chave]", "Chave de API do provedor")
|
|
72
|
+
.option("--off", "remove a chave salva do provedor")
|
|
73
|
+
.option("-l, --list", "lista os provedores e os modelos disponiveis")
|
|
74
|
+
.action(async (provider, chave, options) => {
|
|
75
|
+
const { runProvider } = require("../src/providers-cli");
|
|
76
|
+
await runProvider({ provider, chave, off: options.off, list: options.list });
|
|
77
|
+
});
|
|
66
78
|
program.command("ask").argument("[question...]", "Question").option("-m, --model <id>", "Model").action(ask);
|
|
67
79
|
program
|
|
68
80
|
.command("agent")
|
|
@@ -106,6 +118,72 @@ program
|
|
|
106
118
|
.alias("c")
|
|
107
119
|
.description("Continua a conversa mais recente")
|
|
108
120
|
.action(() => runREPL({ allowAll: Boolean(program.opts().allowAll), resume: "last" }));
|
|
121
|
+
program
|
|
122
|
+
.command("mobile [acao] [alvo]")
|
|
123
|
+
.description("Usa o Kafox pelo celular via QR Code (mesma sessão da CLI)")
|
|
124
|
+
.option("--last", "usa a conversa mais recente sem perguntar")
|
|
125
|
+
.option("-n, --new", "começa uma conversa nova, sem abrir o seletor")
|
|
126
|
+
.option("-p, --port <n>", "porta local (padrao: 7788, com fallback se ocupada)")
|
|
127
|
+
.option("--lan", "expoe na rede local (0.0.0.0) e usa o IP da maquina no QR")
|
|
128
|
+
.option("--public-url <url>", "URL publica que vai no QR (ex.: https://web.kafox.dev/)")
|
|
129
|
+
.option("--readonly", "celular só acompanha: não digita nem aprova")
|
|
130
|
+
.option("-d, --daemon", "roda em background (terminal livre); use `kafox mobile log <id>` para rever o QR")
|
|
131
|
+
.option("--revoke", "revoga todos os pareamentos do celular e sai")
|
|
132
|
+
.action(async (acao, alvo, options) => {
|
|
133
|
+
const mobile = require("../src/mobile");
|
|
134
|
+
const auth = require("../src/mobile/auth");
|
|
135
|
+
|
|
136
|
+
// O commander muda o "shape" dos argumentos conforme a aridade:
|
|
137
|
+
// `kafox mobile` -> action(options)
|
|
138
|
+
// `kafox mobile list` -> action(acao, options)
|
|
139
|
+
// `kafox mobile kill all` -> action(acao, alvo, options)
|
|
140
|
+
// Aqui a gente normaliza: separa strings (posicionais) de objetos (options).
|
|
141
|
+
const positional = [acao, alvo].filter((v) => typeof v === "string");
|
|
142
|
+
const optsCandidates = [acao, alvo, options].filter((v) => v && typeof v === "object");
|
|
143
|
+
const opts = optsCandidates[optsCandidates.length - 1] || {};
|
|
144
|
+
const first = positional[0] || "";
|
|
145
|
+
const second = positional[1] || "";
|
|
146
|
+
|
|
147
|
+
// subcomandos: `kafox mobile list` / `kill` / `log`
|
|
148
|
+
if (first === "list" || first === "ls") {
|
|
149
|
+
return mobile.printMobileList();
|
|
150
|
+
}
|
|
151
|
+
if (first === "kill") {
|
|
152
|
+
return mobile.killMobileSessions(second || null);
|
|
153
|
+
}
|
|
154
|
+
if (first === "log" || first === "qr") {
|
|
155
|
+
return mobile.printMobileLog(second || null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (opts.revoke) {
|
|
159
|
+
const removed = auth.revoke({ all: true });
|
|
160
|
+
console.log(
|
|
161
|
+
removed
|
|
162
|
+
? chalk.green(`Revogados ${removed} pareamento(s) do celular.`)
|
|
163
|
+
: chalk.dim("Nenhum celular pareado.")
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fluxo principal: HEADLESS (menu de conversas + QR), SEM abrir a TUI no PC.
|
|
169
|
+
// O celular vira o cliente da sessao via SessionBridge.
|
|
170
|
+
const code = opts.new ? null : opts.last ? "last" : first || null;
|
|
171
|
+
const common = {
|
|
172
|
+
code,
|
|
173
|
+
port: opts.port ? Number(opts.port) : undefined,
|
|
174
|
+
lan: Boolean(opts.lan),
|
|
175
|
+
publicUrl: opts.publicUrl,
|
|
176
|
+
readonly: Boolean(opts.readonly),
|
|
177
|
+
allowAll: Boolean(program.opts().allowAll),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// --daemon: destaca o processo (setsid) e devolve o terminal. O filho roda
|
|
181
|
+
// o fluxo normal e grava PID/porta no registro, entao `list/kill/log` acham.
|
|
182
|
+
if (opts.daemon) {
|
|
183
|
+
return mobile.spawnMobileDaemon(common);
|
|
184
|
+
}
|
|
185
|
+
return mobile.runMobile(common);
|
|
186
|
+
});
|
|
109
187
|
program.command("compact").description("Compact the current conversation context").action(compact);
|
|
110
188
|
program.command("clear").description("Clear the conversation context").action(clearContext);
|
|
111
189
|
program.command("status").description("Show account, balance and gateway status").action(showStatus);
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { chat } = require("./gateway");
|
|
2
|
+
const { compactIfNeeded, estimateTokens, DEFAULT_THRESHOLD_TOKENS } = require("./compact");
|
|
2
3
|
const {
|
|
3
4
|
TOOL_SPECS,
|
|
4
5
|
APPROVAL_REQUIRED,
|
|
@@ -219,6 +220,11 @@ async function runAgent({
|
|
|
219
220
|
onToolResult,
|
|
220
221
|
onSubagentEvent,
|
|
221
222
|
onRetry,
|
|
223
|
+
// Compactacao de contexto: `compact:false` desliga; o limiar e em tokens.
|
|
224
|
+
compact = true,
|
|
225
|
+
compactThreshold = DEFAULT_THRESHOLD_TOKENS,
|
|
226
|
+
compactSummarize = null,
|
|
227
|
+
onCompact,
|
|
222
228
|
} = {}) {
|
|
223
229
|
const conversation = messages.map((message) => ({ ...message }));
|
|
224
230
|
if (!conversation.length || conversation[0].role !== "system") {
|
|
@@ -243,6 +249,24 @@ async function runAgent({
|
|
|
243
249
|
return { ok: true, aborted: true, text: last.text, messages: conversation, steps: step };
|
|
244
250
|
}
|
|
245
251
|
|
|
252
|
+
// Contexto grande e reenviado inteiro a cada passo e cobrado por token:
|
|
253
|
+
// passando do limiar, troca a parte antiga por um resumo. Nunca quebra o
|
|
254
|
+
// turno — se o resumo falhar, segue com o historico completo.
|
|
255
|
+
if (compact !== false && estimateTokens(conversation) > compactThreshold) {
|
|
256
|
+
const result = await compactIfNeeded(conversation, {
|
|
257
|
+
model,
|
|
258
|
+
thresholdTokens: compactThreshold,
|
|
259
|
+
signal,
|
|
260
|
+
summarize: compactSummarize,
|
|
261
|
+
});
|
|
262
|
+
if (result.compacted) {
|
|
263
|
+
// `conversation` e const: troca o conteudo, nao a referencia.
|
|
264
|
+
conversation.length = 0;
|
|
265
|
+
conversation.push(...result.messages);
|
|
266
|
+
onCompact?.({ before: result.before, after: result.after, summary: result.summary });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
246
270
|
const started = Date.now();
|
|
247
271
|
const result = await chat({
|
|
248
272
|
messages: conversation,
|
package/src/auth.js
CHANGED
|
@@ -6,12 +6,31 @@ const { mascot } = require("./mascot");
|
|
|
6
6
|
const MASCOT = mascot;
|
|
7
7
|
|
|
8
8
|
async function fetchApi(pathname, options = {}) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
const candidates = storage.apiUrlCandidates();
|
|
10
|
+
let lastError = null;
|
|
11
|
+
for (const base of candidates) {
|
|
12
|
+
const url = `${base}${pathname}`;
|
|
13
|
+
try {
|
|
14
|
+
return await fetch(url, {
|
|
15
|
+
...options,
|
|
16
|
+
headers: { ...storage.getAuthHeaders(options.headers), ...options.headers },
|
|
17
|
+
// timeout por tentativa: nao deixa um host travado segurar o login.
|
|
18
|
+
signal: AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined,
|
|
19
|
+
});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
lastError = error;
|
|
22
|
+
// So faz fallback em erro de REDE (DNS/timeout/recusa), nunca em HTTP 4xx/5xx.
|
|
23
|
+
const netError =
|
|
24
|
+
error?.cause?.code === "UND_ERR_CONNECT_TIMEOUT" ||
|
|
25
|
+
error?.cause?.code === "ECONNREFUSED" ||
|
|
26
|
+
error?.cause?.code === "ENOTFOUND" ||
|
|
27
|
+
error?.name === "TimeoutError" ||
|
|
28
|
+
/fetch failed|Timeout/i.test(String(error?.message || ""));
|
|
29
|
+
if (!netError) throw error;
|
|
30
|
+
// tenta o proximo host; o ultimo erro sera lancado se todos falharem.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw lastError;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
async function ensureSession() {
|
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ const COMMANDS = {
|
|
|
31
31
|
"/background": "imagem de fundo (/background <arquivo|url>; off remove)",
|
|
32
32
|
"/clear": "clear conversation context",
|
|
33
33
|
"/compact": "compact conversation context",
|
|
34
|
+
"/queue": "mostra a fila; /queue clear descarta (atalho: ctrl+q)",
|
|
34
35
|
"/status": "show account, balance and gateway status",
|
|
35
36
|
"/exit": "exit the CLI",
|
|
36
37
|
};
|
|
@@ -320,6 +321,8 @@ async function runREPL(options = {}) {
|
|
|
320
321
|
autoApprove: Boolean(options?.allowAll),
|
|
321
322
|
// kafox --resume <codigo> volta para a conversa salva em ~/.kafox/chats.
|
|
322
323
|
resume: options?.resume || null,
|
|
324
|
+
// `kafox mobile` hospeda o servidor no navegador da mesma sessao.
|
|
325
|
+
mobile: options?.mobile || null,
|
|
323
326
|
});
|
|
324
327
|
}
|
|
325
328
|
|
package/src/compact.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
const { chat } = require("./gateway");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compactacao de contexto.
|
|
5
|
+
*
|
|
6
|
+
* O CLI reenvia o historico inteiro a cada passo do loop de ferramentas. Numa
|
|
7
|
+
* sessao longa isso chega a 100k+ tokens de prompt, e como o provedor cobra o
|
|
8
|
+
* preco cheio por token de entrada, o custo de cada turno cresce junto. Este
|
|
9
|
+
* modulo corta o historico quando ele passa do limiar, trocando a parte antiga
|
|
10
|
+
* por um resumo curto.
|
|
11
|
+
*
|
|
12
|
+
* Duas regras que nao podem ser quebradas:
|
|
13
|
+
*
|
|
14
|
+
* 1. O provedor exige que toda mensagem `tool` venha logo depois do
|
|
15
|
+
* `assistant` que declarou o `tool_call_id`. Cortar no meio desse par
|
|
16
|
+
* devolve 400 e derruba o turno. Por isso o corte sempre cai numa
|
|
17
|
+
* fronteira segura.
|
|
18
|
+
* 2. O `system` (indice 0) nunca e removido: e ele que carrega cwd, modelo e
|
|
19
|
+
* as regras de ferramenta.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Limiar padrao: 60k tokens.
|
|
24
|
+
*
|
|
25
|
+
* Medido no uso real: sem compactacao o prompt chegou a 160k tokens (o teto da
|
|
26
|
+
* janela do DeepSeek) e cada turno custava ~R$ 0,17. Em 60k cada turno fica na
|
|
27
|
+
* faixa de poucos centavos e ainda sobram ~240k caracteres de contexto — o
|
|
28
|
+
* suficiente para um CLI saber o que ja foi feito.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_THRESHOLD_TOKENS = 60_000;
|
|
31
|
+
// Quantas mensagens recentes ficam intactas depois do resumo.
|
|
32
|
+
const DEFAULT_KEEP_MESSAGES = 8;
|
|
33
|
+
// Teto do resumo: o texto gerado vira contexto permanente, entao nao pode crescer.
|
|
34
|
+
const MAX_SUMMARY_CHARS = 4_000;
|
|
35
|
+
|
|
36
|
+
/** Estimativa de tokens sem dependencia externa: ~4 caracteres por token. */
|
|
37
|
+
function estimateTokens(messages) {
|
|
38
|
+
let chars = 0;
|
|
39
|
+
for (const message of messages || []) {
|
|
40
|
+
chars += String(message?.content || "").length;
|
|
41
|
+
// `arguments` das tool calls tambem vai no payload e e cobrado.
|
|
42
|
+
if (Array.isArray(message?.tool_calls)) {
|
|
43
|
+
for (const call of message.tool_calls) {
|
|
44
|
+
chars += String(call?.function?.arguments || "").length;
|
|
45
|
+
chars += String(call?.function?.name || "").length;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Math.round(chars / 4);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encontra o indice a partir do qual o historico pode ser cortado sem quebrar
|
|
54
|
+
* um par assistant(tool_calls) + tool(resultado).
|
|
55
|
+
*
|
|
56
|
+
* Um corte em `index` mantem `messages.slice(index)`. Ele so e valido quando a
|
|
57
|
+
* mensagem em `index` nao e um `tool` orfao nem um `assistant` que ainda vai
|
|
58
|
+
* receber resultados.
|
|
59
|
+
*/
|
|
60
|
+
function safeCutIndex(messages, wantedIndex) {
|
|
61
|
+
if (wantedIndex <= 0) return messages.length;
|
|
62
|
+
let index = Math.min(wantedIndex, messages.length);
|
|
63
|
+
|
|
64
|
+
// Volta enquanto a mensagem no corte for um resultado de tool: ela precisa
|
|
65
|
+
// continuar junto do assistant que a pediu.
|
|
66
|
+
while (index > 0 && messages[index]?.role === "tool") index -= 1;
|
|
67
|
+
// Se caiu num assistant com tool_calls, inclui ele e todos os resultados
|
|
68
|
+
// seguintes (o par completo).
|
|
69
|
+
if (index > 0 && messages[index]?.role === "assistant" && Array.isArray(messages[index].tool_calls) && messages[index].tool_calls.length) {
|
|
70
|
+
index -= 1;
|
|
71
|
+
}
|
|
72
|
+
// Nunca cortar o system.
|
|
73
|
+
return Math.max(1, index);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Texto corrido do historico, para alimentar o resumo. */
|
|
77
|
+
function transcript(messages, limitPerMessage = 1_500) {
|
|
78
|
+
return messages
|
|
79
|
+
.map((message) => {
|
|
80
|
+
const role = message?.role || "?";
|
|
81
|
+
let body = String(message?.content || "");
|
|
82
|
+
if (body.length > limitPerMessage) body = `${body.slice(0, limitPerMessage)}…[truncado]`;
|
|
83
|
+
if (Array.isArray(message?.tool_calls) && message.tool_calls.length) {
|
|
84
|
+
const calls = message.tool_calls
|
|
85
|
+
.map((call) => `${call?.function?.name}(${String(call?.function?.arguments || "").slice(0, 300)})`)
|
|
86
|
+
.join(", ");
|
|
87
|
+
body = `${body}\n[ferramentas chamadas: ${calls}]`.trim();
|
|
88
|
+
}
|
|
89
|
+
return `${role}: ${body}`;
|
|
90
|
+
})
|
|
91
|
+
.join("\n\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Instrucoes do resumo estruturado.
|
|
96
|
+
*
|
|
97
|
+
* O formato segue o do opencode: secoes fixas, sem prosa. Um resumo em
|
|
98
|
+
* paragrafos vira ruido no contexto; secoes nomeadas o modelo le e usa.
|
|
99
|
+
*/
|
|
100
|
+
const SUMMARY_SECTIONS = [
|
|
101
|
+
{ key: "objective", title: "Objective", hint: "o que o usuario quer, em 1-3 linhas" },
|
|
102
|
+
{ key: "details", title: "Important Details", hint: "decisoes, restricoes, preferencias ditas pelo usuario" },
|
|
103
|
+
{ key: "completed", title: "Completed", hint: "o que ja foi feito e verificado (com arquivo quando houver)" },
|
|
104
|
+
{ key: "active", title: "Active", hint: "o que estava em andamento no momento do corte" },
|
|
105
|
+
{ key: "blocked", title: "Blocked", hint: "o que travou e por que (ou 'none')" },
|
|
106
|
+
{ key: "next", title: "Next Move", hint: "os proximos passos concretos, numerados" },
|
|
107
|
+
{ key: "files", title: "Relevant Files", hint: "caminhos tocados ou lidos, um por linha (ou 'none')" },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const SUMMARY_INSTRUCTIONS = [
|
|
111
|
+
"You compact conversation history for a terminal coding agent.",
|
|
112
|
+
"Write ONLY the fields below, using the exact headings, in this order:",
|
|
113
|
+
"",
|
|
114
|
+
...SUMMARY_SECTIONS.map((section, index) => `${index + 1}. ${section.title}: ${section.hint}`),
|
|
115
|
+
"",
|
|
116
|
+
"Rules:",
|
|
117
|
+
"- Be terse. Bullets or short lines, never paragraphs of prose.",
|
|
118
|
+
"- Keep exact file paths, commands, identifiers and numbers. Never paraphrase them.",
|
|
119
|
+
"- Do not invent anything. If a section has nothing, write exactly: none",
|
|
120
|
+
"- No preamble, no closing remarks, no markdown fences.",
|
|
121
|
+
"- Write the content in the language the user was using.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Prompt de resumo (exportado: os testes travam o formato).
|
|
126
|
+
*
|
|
127
|
+
* `structured` liga o formato em secoes do opencode; sem ele sai um resumo
|
|
128
|
+
* corrido, util para modelos pequenos que ignoram instrucao longa.
|
|
129
|
+
*/
|
|
130
|
+
function summaryPrompt(messages, { structured = true } = {}) {
|
|
131
|
+
const system = structured
|
|
132
|
+
? SUMMARY_INSTRUCTIONS
|
|
133
|
+
: "Voce resume historico de conversa entre um desenvolvedor e um agente de terminal. " +
|
|
134
|
+
"Preserve: objetivo pedido, arquivos tocados, decisoes tomadas, comandos executados e o " +
|
|
135
|
+
"estado atual do trabalho. Descarte conversa social e repeticoes. Responda em portugues, " +
|
|
136
|
+
"em no maximo 25 linhas, sem preambulo.";
|
|
137
|
+
return [
|
|
138
|
+
{ role: "system", content: system },
|
|
139
|
+
{ role: "user", content: transcript(messages) },
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Mensagem que substitui o historico antigo no contexto do modelo.
|
|
145
|
+
*
|
|
146
|
+
* As secoes ficam nomeadas porque o modelo precisa achar "Next Move" e
|
|
147
|
+
* "Relevant Files" rapido; um paragrafo corrido faz ele reler tudo.
|
|
148
|
+
*/
|
|
149
|
+
function buildCompactionNote(summaryText, messageCount) {
|
|
150
|
+
return [
|
|
151
|
+
`[Contexto compactado: ${messageCount} mensagens anteriores viraram este resumo.]`,
|
|
152
|
+
"",
|
|
153
|
+
summaryText,
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Senhas e chaves nunca vao para o resumo: o texto vai para o provedor. */
|
|
158
|
+
function scrubSecrets(text) {
|
|
159
|
+
return String(text || "")
|
|
160
|
+
.replace(/\b(kafox_live_|sk-|ghp_|cfat_|npm_)[A-Za-z0-9_\-]{8,}/g, "$1***")
|
|
161
|
+
.replace(/\b(Bearer\s+)[A-Za-z0-9._\-]{12,}/gi, "$1***");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Corta uma mensagem grande demais por conta própria.
|
|
166
|
+
*
|
|
167
|
+
* Uma única mensagem (saída de `run_command`, arquivo lido, relatório de
|
|
168
|
+
* subagente) pode sozinha passar do orçamento do contexto. O provedor cobra
|
|
169
|
+
* cada token dela em todo turno seguinte, então ela é podada na origem mesmo
|
|
170
|
+
* que o resto da conversa seja curto.
|
|
171
|
+
*
|
|
172
|
+
* Mantém o começo e o fim — o começo tem o contexto e o fim costuma ter o
|
|
173
|
+
* resultado/erro, que é o que importa. O meio vira uma marca explícita.
|
|
174
|
+
*/
|
|
175
|
+
function trimHugeMessage(message, maxChars) {
|
|
176
|
+
const content = String(message?.content || "");
|
|
177
|
+
if (content.length <= maxChars) return message;
|
|
178
|
+
|
|
179
|
+
const text = `[trecho omitido: ${(content.length - maxChars).toLocaleString("pt-BR")} caracteres]`;
|
|
180
|
+
// 40% no começo, 60% no fim: o final costuma trazer o resultado.
|
|
181
|
+
const room = maxChars - text.length - 4;
|
|
182
|
+
const headLen = Math.floor(room * 0.4);
|
|
183
|
+
const tailLen = room - headLen;
|
|
184
|
+
return {
|
|
185
|
+
...message,
|
|
186
|
+
content: `${content.slice(0, headLen)}\n${text}\n${content.slice(content.length - tailLen)}`,
|
|
187
|
+
truncated: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Poda mensagens individuais que passam de `maxMessageChars`.
|
|
193
|
+
* Devolve `{ messages, changed }`.
|
|
194
|
+
*/
|
|
195
|
+
function trimHugeMessages(messages, maxMessageChars) {
|
|
196
|
+
let changed = false;
|
|
197
|
+
const next = messages.map((message) => {
|
|
198
|
+
// Tool results e saida de comando sao os casos que incham de verdade.
|
|
199
|
+
const trimmed = trimHugeMessage(message, maxMessageChars);
|
|
200
|
+
if (trimmed !== message) changed = true;
|
|
201
|
+
return trimmed;
|
|
202
|
+
});
|
|
203
|
+
return { messages: next, changed };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Compacta o historico quando ele passa do limiar.
|
|
208
|
+
*
|
|
209
|
+
* Retorna `{ messages, compacted, before, after, error }`. Quando nao ha nada a
|
|
210
|
+
* fazer devolve o proprio array (mesma referencia) para o chamador detectar.
|
|
211
|
+
*
|
|
212
|
+
* `summarize` existe para teste: por padrao usa o mesmo `chat()` do agente.
|
|
213
|
+
*/
|
|
214
|
+
async function compactIfNeeded(
|
|
215
|
+
messages,
|
|
216
|
+
{
|
|
217
|
+
model,
|
|
218
|
+
thresholdTokens = DEFAULT_THRESHOLD_TOKENS,
|
|
219
|
+
keepMessages = DEFAULT_KEEP_MESSAGES,
|
|
220
|
+
summarize,
|
|
221
|
+
signal,
|
|
222
|
+
} = {}
|
|
223
|
+
) {
|
|
224
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
225
|
+
let before = estimateTokens(list);
|
|
226
|
+
|
|
227
|
+
// 1) Mensagem unica gigante: podar resolve sem precisar de resumo (e sem
|
|
228
|
+
// gastar uma chamada de modelo). Uma saida de comando de 500k chars
|
|
229
|
+
// sozinha ja passaria do limiar e nunca dispararia o resumo por haver
|
|
230
|
+
// poucas mensagens.
|
|
231
|
+
const perMessageBudget = Math.max(8_000, Math.floor(thresholdTokens * 0.6) * 4);
|
|
232
|
+
const trimmed = trimHugeMessages(list, perMessageBudget);
|
|
233
|
+
// Sem poda, mantem a referencia original: o chamador usa isso para saber
|
|
234
|
+
// que nada mudou.
|
|
235
|
+
let working = trimmed.changed ? trimmed.messages : list;
|
|
236
|
+
const antesPoda = estimateTokens(list);
|
|
237
|
+
if (trimmed.changed) before = estimateTokens(working);
|
|
238
|
+
|
|
239
|
+
const alreadyShrank = trimmed.changed;
|
|
240
|
+
if (before <= thresholdTokens) {
|
|
241
|
+
return {
|
|
242
|
+
messages: working,
|
|
243
|
+
compacted: alreadyShrank,
|
|
244
|
+
before: antesPoda,
|
|
245
|
+
after: before,
|
|
246
|
+
summary: alreadyShrank ? "(mensagens longas podadas)" : null,
|
|
247
|
+
error: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 2) Muitas mensagens: troca a parte antiga por resumo.
|
|
252
|
+
if (working.length <= keepMessages + 1) {
|
|
253
|
+
return {
|
|
254
|
+
messages: working,
|
|
255
|
+
compacted: alreadyShrank,
|
|
256
|
+
before: antesPoda,
|
|
257
|
+
after: before,
|
|
258
|
+
summary: alreadyShrank ? "(mensagens longas podadas)" : null,
|
|
259
|
+
error: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const head = working[0]?.role === "system" ? working[0] : null;
|
|
264
|
+
const body = head ? working.slice(1) : working;
|
|
265
|
+
const cut = safeCutIndex(body, body.length - keepMessages);
|
|
266
|
+
const oldPart = body.slice(0, cut);
|
|
267
|
+
const recent = body.slice(cut);
|
|
268
|
+
|
|
269
|
+
if (!oldPart.length) {
|
|
270
|
+
return {
|
|
271
|
+
messages: working,
|
|
272
|
+
compacted: alreadyShrank,
|
|
273
|
+
before: estimateTokens(list),
|
|
274
|
+
after: before,
|
|
275
|
+
summary: alreadyShrank ? "(mensagens longas podadas)" : null,
|
|
276
|
+
error: null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let summaryText = "";
|
|
281
|
+
try {
|
|
282
|
+
const runner =
|
|
283
|
+
summarize ||
|
|
284
|
+
(async (msgs) => {
|
|
285
|
+
const result = await chat({ messages: msgs, model, signal });
|
|
286
|
+
if (!result.ok) throw new Error(result.error || "resumo falhou");
|
|
287
|
+
return result.text;
|
|
288
|
+
});
|
|
289
|
+
summaryText = scrubSecrets(String((await runner(summaryPrompt(oldPart))) || "").trim());
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// Sem resumo nao ha compactacao: seguir com o historico inteiro e melhor
|
|
292
|
+
// do que perder contexto ou quebrar o turno no meio.
|
|
293
|
+
// Falhou o resumo, mas a poda das mensagens enormes ja vale: nao perde
|
|
294
|
+
// contexto, so corta o excesso.
|
|
295
|
+
return {
|
|
296
|
+
messages: working,
|
|
297
|
+
compacted: alreadyShrank,
|
|
298
|
+
before: antesPoda,
|
|
299
|
+
after: before,
|
|
300
|
+
summary: alreadyShrank ? "(mensagens longas podadas)" : null,
|
|
301
|
+
error: error?.message || "resumo falhou",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!summaryText) {
|
|
306
|
+
return {
|
|
307
|
+
messages: working,
|
|
308
|
+
compacted: alreadyShrank,
|
|
309
|
+
before: antesPoda,
|
|
310
|
+
after: before,
|
|
311
|
+
summary: alreadyShrank ? "(mensagens longas podadas)" : null,
|
|
312
|
+
error: "resumo vazio",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (summaryText.length > MAX_SUMMARY_CHARS) summaryText = `${summaryText.slice(0, MAX_SUMMARY_CHARS)}…`;
|
|
316
|
+
|
|
317
|
+
const note = {
|
|
318
|
+
role: "system",
|
|
319
|
+
content: buildCompactionNote(summaryText, oldPart.length),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const next = [...(head ? [head] : []), note, ...recent];
|
|
323
|
+
return {
|
|
324
|
+
messages: next,
|
|
325
|
+
compacted: true,
|
|
326
|
+
before,
|
|
327
|
+
after: estimateTokens(next),
|
|
328
|
+
summary: summaryText,
|
|
329
|
+
error: null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
compactIfNeeded,
|
|
335
|
+
estimateTokens,
|
|
336
|
+
safeCutIndex,
|
|
337
|
+
summaryPrompt,
|
|
338
|
+
buildCompactionNote,
|
|
339
|
+
scrubSecrets,
|
|
340
|
+
trimHugeMessages,
|
|
341
|
+
trimHugeMessage,
|
|
342
|
+
SUMMARY_SECTIONS,
|
|
343
|
+
DEFAULT_THRESHOLD_TOKENS,
|
|
344
|
+
DEFAULT_KEEP_MESSAGES,
|
|
345
|
+
MAX_SUMMARY_CHARS,
|
|
346
|
+
};
|