kafox-cli 0.10.1 → 0.10.3

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/src/images.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Anexos de imagem do kafox.
3
+ *
4
+ * - carrega arquivos (png/jpg/gif/webp) do disco ou de bytes colados
5
+ * - decodifica para RGBA e gera um preview "pixelado" em blocos, no estilo
6
+ * dos terminais graficos: cada celula de caractere cobre 2 pixels verticais
7
+ * usando o glifo de meio bloco (▀) e as cores de verdadeiro color.
8
+ * - o preview e o que a TUI desenha dentro da caixa de input; a imagem
9
+ * completa (base64) vai no payload multimodal enviado ao modelo.
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ let jpegDecode = null;
15
+ try {
16
+ // Dep opcional: se nao existir, JPEG fica indisponivel mas PNG continua.
17
+ jpegDecode = require("jpeg-js").decode;
18
+ } catch {
19
+ jpegDecode = null;
20
+ }
21
+
22
+ const MAX_IMAGES = 3;
23
+ const MAX_BYTES = 12 * 1024 * 1024; // 12 MB por arquivo
24
+ const MAX_PREVIEW_COLS = 44;
25
+ const MAX_PREVIEW_ROWS = 10; // em celulas (cada celula = 2 pixels de altura)
26
+
27
+ const MIME_BY_EXT = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".bmp": "image/bmp",
34
+ };
35
+
36
+ /* ------------------------------------------------------------- decodificacao */
37
+
38
+ function sniffMime(buffer) {
39
+ if (buffer.length >= 8 && buffer.readUInt32BE(0) === 0x89504e47) return "image/png";
40
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
41
+ if (buffer.length >= 6 && buffer.slice(0, 6).toString("ascii") === "GIF87a") return "image/gif";
42
+ if (buffer.length >= 6 && buffer.slice(0, 6).toString("ascii") === "GIF89a") return "image/gif";
43
+ if (buffer.length >= 12 && buffer.slice(0, 4).toString("ascii") === "RIFF" && buffer.slice(8, 12).toString("ascii") === "WEBP") {
44
+ return "image/webp";
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function decodePng(buffer) {
50
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
51
+ if (!buffer.slice(0, 8).equals(signature)) throw new Error("PNG inválido");
52
+
53
+ const { inflateSync } = require("zlib");
54
+ let offset = 8;
55
+ let width = 0;
56
+ let height = 0;
57
+ let depth = 0;
58
+ let colorType = 0;
59
+ let interlace = 0;
60
+ const idat = [];
61
+
62
+ while (offset + 8 <= buffer.length) {
63
+ const length = buffer.readUInt32BE(offset);
64
+ const type = buffer.slice(offset + 4, offset + 8).toString("ascii");
65
+ const data = buffer.slice(offset + 8, offset + 8 + length);
66
+ offset += 12 + length;
67
+
68
+ if (type === "IHDR") {
69
+ width = data.readUInt32BE(0);
70
+ height = data.readUInt32BE(4);
71
+ depth = data[8];
72
+ colorType = data[9];
73
+ interlace = data[12];
74
+ } else if (type === "IDAT") {
75
+ idat.push(data);
76
+ } else if (type === "IEND") {
77
+ break;
78
+ }
79
+ }
80
+
81
+ if (!width || !height) throw new Error("PNG sem cabeçalho IHDR");
82
+ if (depth !== 8) throw new Error("PNG com profundidade não suportada");
83
+ if (interlace !== 0) throw new Error("PNG entrelaçado não suportado");
84
+
85
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
86
+ if (!channels) throw new Error("PNG com color type não suportado");
87
+
88
+ const raw = inflateSync(Buffer.concat(idat));
89
+ const stride = width * channels;
90
+ const out = Buffer.alloc(width * height * 4);
91
+
92
+ let prev = Buffer.alloc(stride);
93
+ let cursor = 0;
94
+
95
+ for (let y = 0; y < height; y += 1) {
96
+ const filter = raw[cursor];
97
+ cursor += 1;
98
+ const line = Buffer.from(raw.slice(cursor, cursor + stride));
99
+ cursor += stride;
100
+
101
+ for (let x = 0; x < stride; x += 1) {
102
+ const a = x >= channels ? line[x - channels] : 0;
103
+ const b = prev[x];
104
+ const c = x >= channels ? prev[x - channels] : 0;
105
+ switch (filter) {
106
+ case 1:
107
+ line[x] = (line[x] + a) & 0xff;
108
+ break;
109
+ case 2:
110
+ line[x] = (line[x] + b) & 0xff;
111
+ break;
112
+ case 3:
113
+ line[x] = (line[x] + ((a + b) >> 1)) & 0xff;
114
+ break;
115
+ case 4: {
116
+ const p = a + b - c;
117
+ const pa = Math.abs(p - a);
118
+ const pb = Math.abs(p - b);
119
+ const pc = Math.abs(p - c);
120
+ const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
121
+ line[x] = (line[x] + pred) & 0xff;
122
+ break;
123
+ }
124
+ default:
125
+ break;
126
+ }
127
+ }
128
+
129
+ for (let x = 0; x < width; x += 1) {
130
+ const src = x * channels;
131
+ const dst = (y * width + x) * 4;
132
+ if (colorType === 0) {
133
+ const gray = line[src];
134
+ out[dst] = gray;
135
+ out[dst + 1] = gray;
136
+ out[dst + 2] = gray;
137
+ out[dst + 3] = 255;
138
+ } else if (colorType === 2) {
139
+ out[dst] = line[src];
140
+ out[dst + 1] = line[src + 1];
141
+ out[dst + 2] = line[src + 2];
142
+ out[dst + 3] = 255;
143
+ } else if (colorType === 4) {
144
+ out[dst] = line[src];
145
+ out[dst + 1] = line[src];
146
+ out[dst + 2] = line[src];
147
+ out[dst + 3] = line[src + 1];
148
+ } else {
149
+ out[dst] = line[src];
150
+ out[dst + 1] = line[src + 1];
151
+ out[dst + 2] = line[src + 2];
152
+ out[dst + 3] = line[src + 3];
153
+ }
154
+ }
155
+
156
+ prev = line;
157
+ }
158
+
159
+ return { width, height, data: out };
160
+ }
161
+
162
+ function decodeImage(buffer, mime) {
163
+ const kind = mime || sniffMime(buffer);
164
+ if (kind === "image/png") return decodePng(buffer);
165
+ if (kind === "image/jpeg") {
166
+ if (!jpegDecode) throw new Error("JPEG requer a dependência jpeg-js");
167
+ const decoded = jpegDecode(buffer, { useTArray: true, maxMemoryUsageInMB: 512 });
168
+ // jpeg-js pode devolver RGB sem alfa; normaliza para RGBA.
169
+ if (decoded.data.length === decoded.width * decoded.height * 4) {
170
+ return { width: decoded.width, height: decoded.height, data: Buffer.from(decoded.data) };
171
+ }
172
+ const data = Buffer.alloc(decoded.width * decoded.height * 4);
173
+ for (let i = 0, j = 0; i < decoded.data.length; i += 3, j += 4) {
174
+ data[j] = decoded.data[i];
175
+ data[j + 1] = decoded.data[i + 1];
176
+ data[j + 2] = decoded.data[i + 2];
177
+ data[j + 3] = 255;
178
+ }
179
+ return { width: decoded.width, height: decoded.height, data };
180
+ }
181
+ throw new Error(`formato não suportado: ${kind || "desconhecido"}`);
182
+ }
183
+
184
+ /* --------------------------------------------------------------- pixelizacao */
185
+
186
+ function sampleBox(img, x0, y0, x1, y1) {
187
+ let r = 0;
188
+ let g = 0;
189
+ let b = 0;
190
+ let a = 0;
191
+ let n = 0;
192
+ const left = Math.max(0, Math.floor(x0));
193
+ const right = Math.min(img.width, Math.max(left + 1, Math.ceil(x1)));
194
+ const top = Math.max(0, Math.floor(y0));
195
+ const bottom = Math.min(img.height, Math.max(top + 1, Math.ceil(y1)));
196
+
197
+ for (let y = top; y < bottom; y += 1) {
198
+ for (let x = left; x < right; x += 1) {
199
+ const i = (y * img.width + x) * 4;
200
+ const alpha = img.data[i + 3] / 255;
201
+ // Composicao sobre branco: assim imagem com transparencia nao fica preta.
202
+ r += img.data[i] * alpha + 255 * (1 - alpha);
203
+ g += img.data[i + 1] * alpha + 255 * (1 - alpha);
204
+ b += img.data[i + 2] * alpha + 255 * (1 - alpha);
205
+ a += img.data[i + 3];
206
+ n += 1;
207
+ }
208
+ }
209
+
210
+ if (!n) return { r: 0, g: 0, b: 0, a: 0 };
211
+ return { r: r / n, g: g / n, b: b / n, a: a / n };
212
+ }
213
+
214
+ /**
215
+ * Gera o preview pixelado. Usa meio bloco: um caractere = 2 pixels empilhados,
216
+ * entao a quantidade de linhas de texto e metade dos pixels amostrados.
217
+ */
218
+ function pixelate(img, { cols = MAX_PREVIEW_COLS, rows = MAX_PREVIEW_ROWS } = {}) {
219
+ const cellRows = clampNumber(rows, 1, 40);
220
+ const cellCols = clampNumber(cols, 4, 120);
221
+ const pxHigh = cellRows * 2;
222
+
223
+ const scale = Math.min(cellCols / img.width, pxHigh / img.height);
224
+ const drawW = Math.max(1, Math.round(img.width * scale));
225
+ const drawH = Math.max(1, Math.round(img.height * scale));
226
+ const offX = Math.floor((cellCols - drawW) / 2);
227
+ const offY = Math.floor((pxHigh - drawH) / 2);
228
+
229
+ const grid = [];
230
+ for (let y = 0; y < pxHigh; y += 1) {
231
+ const row = [];
232
+ for (let x = 0; x < cellCols; x += 1) {
233
+ const inside = x >= offX && x < offX + drawW && y >= offY && y < offY + drawH;
234
+ if (!inside) {
235
+ row.push(null);
236
+ continue;
237
+ }
238
+ const sx0 = ((x - offX) / drawW) * img.width;
239
+ const sy0 = ((y - offY) / drawH) * img.height;
240
+ row.push(sampleBox(img, sx0, sy0, sx0 + img.width / drawW, sy0 + img.height / drawH));
241
+ }
242
+ grid.push(row);
243
+ }
244
+
245
+ const lines = [];
246
+ for (let y = 0; y < grid.length; y += 2) {
247
+ let out = "";
248
+ for (let x = 0; x < cellCols; x += 1) {
249
+ const top = grid[y][x];
250
+ const bottom = grid[y + 1] ? grid[y + 1][x] : null;
251
+ if (!top && !bottom) {
252
+ out += " ";
253
+ continue;
254
+ }
255
+ const fg = top || bottom;
256
+ const bg = bottom || top;
257
+ out += `\x1b[38;2;${Math.round(fg.r)};${Math.round(fg.g)};${Math.round(fg.b)}m` +
258
+ `\x1b[48;2;${Math.round(bg.r)};${Math.round(bg.g)};${Math.round(bg.b)}m▀\x1b[0m`;
259
+ }
260
+ lines.push(out);
261
+ }
262
+
263
+ return { lines, width: cellCols, height: pxHigh };
264
+ }
265
+
266
+ function clampNumber(value, min, max) {
267
+ return Math.max(min, Math.min(max, value));
268
+ }
269
+
270
+ /* ------------------------------------------------------------------- anexos */
271
+
272
+ function mimeFor(file) {
273
+ return MIME_BY_EXT[path.extname(file).toLowerCase()] || null;
274
+ }
275
+
276
+ /**
277
+ * Carrega uma imagem do disco e monta o anexo usado pela TUI.
278
+ * Retorna `{ ok, attachment }` ou `{ ok: false, error }` — nunca lanca.
279
+ */
280
+ function loadImage(file, options = {}) {
281
+ const target = path.resolve(file);
282
+ let buffer;
283
+ try {
284
+ const stat = fs.statSync(target);
285
+ if (!stat.isFile()) return { ok: false, error: "não é um arquivo" };
286
+ if (stat.size > (options.maxBytes || MAX_BYTES)) {
287
+ return { ok: false, error: "arquivo maior que 12 MB" };
288
+ }
289
+ buffer = fs.readFileSync(target);
290
+ } catch (error) {
291
+ return { ok: false, error: `não consegui abrir (${error.code || error.message})` };
292
+ }
293
+ return buildAttachment(buffer, { name: path.basename(target), mime: mimeFor(target) || sniffMime(buffer), ...options });
294
+ }
295
+
296
+ /** Monta o anexo a partir de bytes ja em memoria (colagem/arrastar). */
297
+ function buildAttachment(buffer, { name = "imagem", mime, cols, rows } = {}) {
298
+ const kind = mime || sniffMime(buffer);
299
+ if (!kind) return { ok: false, error: "formato de imagem não reconhecido" };
300
+
301
+ let decoded;
302
+ try {
303
+ decoded = decodeImage(buffer, kind);
304
+ } catch (error) {
305
+ return { ok: false, error: `não consegui decodificar (${error.message})` };
306
+ }
307
+
308
+ const preview = pixelate(decoded, { cols, rows });
309
+ return {
310
+ ok: true,
311
+ attachment: {
312
+ name,
313
+ mime: kind,
314
+ bytes: buffer.length,
315
+ width: decoded.width,
316
+ height: decoded.height,
317
+ dataUrl: `data:${kind};base64,${buffer.toString("base64")}`,
318
+ preview: preview.lines,
319
+ previewWidth: preview.width,
320
+ },
321
+ };
322
+ }
323
+
324
+ function humanSize(bytes) {
325
+ if (bytes < 1024) return `${bytes} B`;
326
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
327
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
328
+ }
329
+
330
+ module.exports = {
331
+ MAX_IMAGES,
332
+ MAX_BYTES,
333
+ MAX_PREVIEW_COLS,
334
+ MAX_PREVIEW_ROWS,
335
+ sniffMime,
336
+ decodeImage,
337
+ pixelate,
338
+ loadImage,
339
+ buildAttachment,
340
+ humanSize,
341
+ };
@@ -270,7 +270,7 @@ async function runMobile({
270
270
  sessionId: saved?.id || sessions.newId(),
271
271
  sessionStartedAt: saved?.startedAt || new Date().toISOString(),
272
272
  messages: saved?.messages || [],
273
- context: saved?.context || [],
273
+ context: saved?.context?.length ? saved.context : sessions.contextFromMessages(saved?.messages),
274
274
  model: saved?.model || null,
275
275
  allowAll: allowAll || Boolean(saved?.allowAll),
276
276
  });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Histórico de linhas digitadas no terminal (setas ↑/↓).
3
+ *
4
+ * É separado das conversas salvas em ~/.kafox/chats: aqui ficam só os prompts
5
+ * recentes, para reaproveitar uma pergunta sem precisar decorar. O arquivo é
6
+ * privado (0600) e tem teto de 200 linhas para não crescer sem limite.
7
+ */
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+ const storage = require("./storage");
12
+
13
+ const MAX = 200;
14
+ const FILE = "history.json";
15
+
16
+ function baseDir(dir) {
17
+ return dir || storage.DIR || path.join(os.homedir(), ".kafox");
18
+ }
19
+
20
+ function filePath(dir) {
21
+ return path.join(baseDir(dir), FILE);
22
+ }
23
+
24
+ function sanitize(list) {
25
+ return (Array.isArray(list) ? list : [])
26
+ .filter((item) => typeof item === "string" && item.trim())
27
+ .map((item) => item.replace(/\r/g, "").trim())
28
+ .slice(-MAX);
29
+ }
30
+
31
+ function load(dir) {
32
+ try {
33
+ const raw = JSON.parse(fs.readFileSync(filePath(dir), "utf8"));
34
+ const list = Array.isArray(raw) ? raw : raw?.history;
35
+ return sanitize(list);
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ function save(lines, dir) {
42
+ const clean = sanitize(lines);
43
+ try {
44
+ const file = filePath(dir);
45
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ const tmp = `${file}.tmp`;
47
+ fs.writeFileSync(tmp, JSON.stringify(clean), { mode: 0o600 });
48
+ fs.renameSync(tmp, file);
49
+ } catch {
50
+ // Histórico é conveniência: falha de disco não pode derrubar a TUI.
51
+ }
52
+ return clean;
53
+ }
54
+
55
+ /** Adiciona uma linha, sem repetir a anterior, e devolve a lista atualizada. */
56
+ function push(line, dir) {
57
+ const clean = String(line ?? "").replace(/\r/g, "").trim();
58
+ if (!clean) return load(dir);
59
+ const lines = load(dir);
60
+ if (lines[lines.length - 1] === clean) return lines;
61
+ return save([...lines, clean].slice(-MAX), dir);
62
+ }
63
+
64
+ module.exports = { MAX, FILE, filePath, load, save, push };
package/src/sessions.js CHANGED
@@ -69,6 +69,29 @@ function titleFrom(messages, fallback = "") {
69
69
  return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
70
70
  }
71
71
 
72
+ /**
73
+ * Converte a conversa visivel (o que a TUI guarda em `state.messages`) no
74
+ * contexto que vai para o modelo.
75
+ *
76
+ * Sessoes antigas foram salvas sem `context`, mas com a conversa visivel. Sem
77
+ * essa reconstrucao, retomar a conversa mandava so a mensagem nova ao provedor
78
+ * e a IA "esquecia" tudo o que ja tinha sido dito.
79
+ */
80
+ function contextFromMessages(messages) {
81
+ const context = [];
82
+ for (const message of Array.isArray(messages) ? messages : []) {
83
+ const text = String(message?.text ?? message?.content ?? "").trim();
84
+ if (!text) continue;
85
+ if (message.role === "user" || message.role === "assistant") {
86
+ context.push({ role: message.role, content: text });
87
+ } else if (message.role === "compaction") {
88
+ // O resumo e contexto, nao mensagem de chat.
89
+ context.push({ role: "system", content: text });
90
+ }
91
+ }
92
+ return context;
93
+ }
94
+
72
95
  function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
73
96
  if (!id) return null;
74
97
  ensureDir();
@@ -166,6 +189,7 @@ module.exports = {
166
189
  titleFrom,
167
190
  normalizeId,
168
191
  dayMonth,
192
+ contextFromMessages,
169
193
  save,
170
194
  load,
171
195
  list,