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/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
@@ -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
- prune();
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
- function list() {
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.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
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
- for (const chat of list().slice(MAX_SESSIONS)) {
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,