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 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
@@ -1,7 +1,23 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.9.8",
3
+ "version": "0.10.0",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/kafox/kafox-cli.git"
8
+ },
9
+ "homepage": "https://kafox.com",
10
+ "bugs": {
11
+ "url": "https://github.com/kafox/kafox-cli/issues"
12
+ },
13
+ "keywords": [
14
+ "cli",
15
+ "tui",
16
+ "ai",
17
+ "agent",
18
+ "llm",
19
+ "mcp"
20
+ ],
5
21
  "bin": {
6
22
  "kafox": "bin/kafox.js"
7
23
  },
@@ -14,6 +30,7 @@
14
30
  "node": ">=22.5"
15
31
  },
16
32
  "license": "MIT",
33
+ "author": "Kafox <dev@kafox.com> (https://kafox.com)",
17
34
  "dependencies": {
18
35
  "chalk": "4.1.2",
19
36
  "commander": "12.1.0",
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 url = `${storage.getApiUrl()}${pathname}`;
10
- const res = await fetch(url, {
11
- ...options,
12
- headers: { ...storage.getAuthHeaders(options.headers), ...options.headers },
13
- });
14
- return res;
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
+ };