kafox-cli 0.10.2 → 0.10.4
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 +20 -3
- package/package.json +1 -1
- package/src/cli.js +158 -23
- package/src/clipboard.js +183 -0
- package/src/images.js +341 -0
- package/src/mobile/index.js +1 -1
- package/src/prompt-history.js +64 -0
- package/src/sessions.js +88 -14
- package/src/tui.js +278 -41
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
|
+
};
|
package/src/mobile/index.js
CHANGED
|
@@ -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
|
@@ -57,6 +57,23 @@ function dayMonth(value) {
|
|
|
57
57
|
return `${day}/${month}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A conversa tem pelo menos uma fala do usuario ou da IA?
|
|
62
|
+
*
|
|
63
|
+
* Sessao sem nenhuma fala e so um codigo reservado: gravar isso criava
|
|
64
|
+
* dezenas de "conversa sem titulo" iguais no `history`.
|
|
65
|
+
*/
|
|
66
|
+
function hasConversation(messages, context) {
|
|
67
|
+
const speaks = (item) => {
|
|
68
|
+
if (item?.role !== "user" && item?.role !== "assistant") return false;
|
|
69
|
+
if (Array.isArray(item.content)) {
|
|
70
|
+
return item.content.some((part) => part?.type === "text" && String(part.text || "").trim());
|
|
71
|
+
}
|
|
72
|
+
return Boolean(String(item?.text ?? item?.content ?? "").trim());
|
|
73
|
+
};
|
|
74
|
+
return [messages, context].some((list) => (Array.isArray(list) ? list : []).some(speaks));
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/** Titulo curto: primeira mensagem do usuario, em uma linha. */
|
|
61
78
|
function titleFrom(messages, fallback = "") {
|
|
62
79
|
const first = (messages || []).find(
|
|
@@ -69,11 +86,38 @@ function titleFrom(messages, fallback = "") {
|
|
|
69
86
|
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Converte a conversa visivel (o que a TUI guarda em `state.messages`) no
|
|
91
|
+
* contexto que vai para o modelo.
|
|
92
|
+
*
|
|
93
|
+
* Sessoes antigas foram salvas sem `context`, mas com a conversa visivel. Sem
|
|
94
|
+
* essa reconstrucao, retomar a conversa mandava so a mensagem nova ao provedor
|
|
95
|
+
* e a IA "esquecia" tudo o que ja tinha sido dito.
|
|
96
|
+
*/
|
|
97
|
+
function contextFromMessages(messages) {
|
|
98
|
+
const context = [];
|
|
99
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
100
|
+
const text = String(message?.text ?? message?.content ?? "").trim();
|
|
101
|
+
if (!text) continue;
|
|
102
|
+
if (message.role === "user" || message.role === "assistant") {
|
|
103
|
+
context.push({ role: message.role, content: text });
|
|
104
|
+
} else if (message.role === "compaction") {
|
|
105
|
+
// O resumo e contexto, nao mensagem de chat.
|
|
106
|
+
context.push({ role: "system", content: text });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return context;
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
|
|
73
113
|
if (!id) return null;
|
|
114
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
115
|
+
// Conversa vazia nao vira arquivo: `/clear` e sessoes abertas sem pergunta
|
|
116
|
+
// enchiam o `history` de "conversa sem titulo" iguais.
|
|
117
|
+
if (!hasConversation(list, context)) return null;
|
|
118
|
+
const existed = fs.existsSync(fileFor(id));
|
|
74
119
|
ensureDir();
|
|
75
120
|
const now = new Date().toISOString();
|
|
76
|
-
const list = Array.isArray(messages) ? messages : [];
|
|
77
121
|
const data = {
|
|
78
122
|
id,
|
|
79
123
|
title: title || titleFrom(list),
|
|
@@ -90,7 +134,8 @@ function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, t
|
|
|
90
134
|
const tmp = `${fileFor(id)}.tmp`;
|
|
91
135
|
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
92
136
|
fs.renameSync(tmp, fileFor(id));
|
|
93
|
-
|
|
137
|
+
// A poda varre e le a pasta inteira: so vale quando o arquivo e novo.
|
|
138
|
+
if (!existed) prune();
|
|
94
139
|
return data;
|
|
95
140
|
}
|
|
96
141
|
|
|
@@ -107,7 +152,8 @@ function load(id) {
|
|
|
107
152
|
}
|
|
108
153
|
}
|
|
109
154
|
|
|
110
|
-
|
|
155
|
+
/** Tudo o que esta no disco, inclusive as sessoes sem nenhuma fala. */
|
|
156
|
+
function entries() {
|
|
111
157
|
let names = [];
|
|
112
158
|
try {
|
|
113
159
|
names = fs.readdirSync(chatsDir()).filter((name) => name.endsWith(".json"));
|
|
@@ -119,17 +165,30 @@ function list() {
|
|
|
119
165
|
const id = name.replace(/\.json$/, "");
|
|
120
166
|
const data = load(id);
|
|
121
167
|
if (!data) continue;
|
|
122
|
-
chats.push({
|
|
123
|
-
id,
|
|
124
|
-
title: data.title || titleFrom(data.messages),
|
|
125
|
-
cwd: data.cwd,
|
|
126
|
-
model: data.model,
|
|
127
|
-
allowAll: Boolean(data.allowAll),
|
|
128
|
-
updatedAt: data.updatedAt,
|
|
129
|
-
messages: (data.messages || []).length,
|
|
130
|
-
});
|
|
168
|
+
chats.push({ id, data });
|
|
131
169
|
}
|
|
132
|
-
return chats
|
|
170
|
+
return chats;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function summarize({ id, data }) {
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
title: data.title || titleFrom(data.messages),
|
|
177
|
+
cwd: data.cwd,
|
|
178
|
+
model: data.model,
|
|
179
|
+
allowAll: Boolean(data.allowAll),
|
|
180
|
+
updatedAt: data.updatedAt,
|
|
181
|
+
messages: (data.messages || []).length,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byNewest = (a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt));
|
|
186
|
+
|
|
187
|
+
function list() {
|
|
188
|
+
return entries()
|
|
189
|
+
.filter(({ data }) => hasConversation(data.messages, data.context))
|
|
190
|
+
.map(summarize)
|
|
191
|
+
.sort(byNewest);
|
|
133
192
|
}
|
|
134
193
|
|
|
135
194
|
function latest() {
|
|
@@ -151,11 +210,24 @@ function find(value) {
|
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
function prune() {
|
|
154
|
-
|
|
213
|
+
const all = entries();
|
|
214
|
+
const chats = all
|
|
215
|
+
.filter(({ data }) => hasConversation(data.messages, data.context))
|
|
216
|
+
.map(summarize)
|
|
217
|
+
.sort(byNewest);
|
|
218
|
+
for (const chat of chats.slice(MAX_SESSIONS)) {
|
|
155
219
|
try {
|
|
156
220
|
fs.unlinkSync(fileFor(chat.id));
|
|
157
221
|
} catch {}
|
|
158
222
|
}
|
|
223
|
+
// Arquivos sem nenhuma fala sao lixo de versoes antigas: nao aparecem no
|
|
224
|
+
// `history` e, fora daqui, nunca seriam podados.
|
|
225
|
+
for (const { id, data } of all) {
|
|
226
|
+
if (hasConversation(data.messages, data.context)) continue;
|
|
227
|
+
try {
|
|
228
|
+
fs.unlinkSync(fileFor(id));
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
159
231
|
}
|
|
160
232
|
|
|
161
233
|
module.exports = {
|
|
@@ -164,8 +236,10 @@ module.exports = {
|
|
|
164
236
|
chatsDir,
|
|
165
237
|
newId,
|
|
166
238
|
titleFrom,
|
|
239
|
+
hasConversation,
|
|
167
240
|
normalizeId,
|
|
168
241
|
dayMonth,
|
|
242
|
+
contextFromMessages,
|
|
169
243
|
save,
|
|
170
244
|
load,
|
|
171
245
|
list,
|