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 CHANGED
@@ -85,6 +85,22 @@ conversa, com o texto por cima. Aceita **PNG** e **JPG**; a imagem e escurecida
85
85
  para nao atrapalhar a leitura e se ajusta sozinha ao tamanho do terminal.
86
86
  `/background off` remove, e a escolha fica em `~/.kafox/config.json`.
87
87
 
88
+ ## Colar imagem (Ctrl+V)
89
+
90
+ Copie um print ou um recorte e aperte **Ctrl+V** na interface: a imagem entra
91
+ como anexo do proximo turno, com preview dentro da caixa de texto, e vai junto
92
+ com a mensagem para qualquer modelo com visao. Vale o mesmo limite do `/image`
93
+ (3 por mensagem) — clique no ✕ do preview para tirar uma.
94
+
95
+ A leitura usa o que existe na maquina: PowerShell no Windows, `pngpaste` (ou
96
+ `osascript`) no macOS, `wl-paste` no Wayland e `xclip` no X11. Se nenhum estiver
97
+ instalado, a CLI avisa e o `/image <arquivo>` continua funcionando.
98
+
99
+ Alguns terminais consomem o Ctrl+V antes da CLI ver (o paste deles e
100
+ `Ctrl+Shift+V`). Nesse caso use **`/paste`**, que le a area de transferencia do
101
+ mesmo jeito. Sem imagem copiada, o Ctrl+V/`/paste` cola o texto, como um paste
102
+ comum.
103
+
88
104
  ## Agentes e subagentes
89
105
 
90
106
  O agente tem a ferramenta `spawn_subagent`: ele lança subagentes com objetivo
@@ -106,7 +122,8 @@ Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter execut
106
122
  `/model` abre o seletor de modelo e `/theme` o de tema (↑/↓, clique do mouse,
107
123
  Enter aplica, Esc cancela). A resposta chega em streaming, com bloco `Thinking`
108
124
  quando o modelo manda o raciocinio. PgUp/PgDn ou a roda do mouse rolam a conversa;
109
- Ctrl+Y copia a ultima resposta e F2 solta o mouse para selecionar texto.
125
+ Ctrl+Y copia a ultima resposta, Ctrl+V cola uma imagem copiada e F2 solta o
126
+ mouse para selecionar texto.
110
127
 
111
128
  Enquanto o modelo trabalha a caixa de texto mostra `trabalhando...` com os
112
129
  pontinhos correndo, e o nome do modelo aparece fora dela, na linha de baixo.
@@ -122,8 +139,8 @@ o segundo interrompe o passo em andamento e guarda na conversa o que ja tinha
122
139
  chegado.
123
140
 
124
141
  Demais comandos: `/help`, `/models`, `/balance`, `/pricing`, `/key`, `/login`,
125
- `/logout`, `/whoami`, `/mcp`, `/allow`, `/theme`, `/background`, `/clear`,
126
- `/compact`, `/status`, `/exit`.
142
+ `/logout`, `/whoami`, `/mcp`, `/allow`, `/theme`, `/background`, `/image`,
143
+ `/paste`, `/clear`, `/compact`, `/status`, `/exit`.
127
144
 
128
145
  O `/theme` traz 29 temas (default, dark, light, brutalist, neon, paper,
129
146
  dracula, nord, gruvbox, monokai, tokyo-night, catppuccin, solarized, midnight,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.js CHANGED
@@ -4,8 +4,11 @@ const path = require("path");
4
4
  const inquirer = require("inquirer");
5
5
  const chalk = require("chalk");
6
6
  const storage = require("./storage");
7
+ const promptHistory = require("./prompt-history");
7
8
  const { ensureSession, fetchApi } = require("./auth");
8
- const { ask } = require("./gateway");
9
+ const { chat } = require("./gateway");
10
+ const { compactIfNeeded, trimHistory } = require("./compact");
11
+ const sessions = require("./sessions");
9
12
  const { runTUI } = require("./tui");
10
13
  const { runTheme, setContentBackground } = require("./theme");
11
14
  const { logoText, logoLines } = require("./logo");
@@ -38,12 +41,61 @@ const COMMANDS = {
38
41
 
39
42
  let history = [];
40
43
  let modelOverride = null;
44
+ // Sessao do REPL sem TTY: e o que permite `kafox resume`/`kafox history`
45
+ // acharem uma conversa usada por pipe, script ou CI (sem passar pela TUI).
46
+ let plainSession = null;
41
47
 
42
48
  function pushHistory(role, content) {
43
49
  history.push({ role, content, at: new Date().toISOString() });
44
50
  if (history.length > 200) history = history.slice(-200);
45
51
  }
46
52
 
53
+ /**
54
+ * Historico no formato OpenAI: e isso que vai para o provedor.
55
+ *
56
+ * Sem esse mapeamento o REPL sem TTY mandava so a linha digitada em cada
57
+ * requisicao, entao a IA respondia como se a conversa tivesse acabado de
58
+ * comecar. Mensagens seguidas do mesmo papel viram uma so (o provedor recusa
59
+ * duas em sequencia) e o system gerado pelo /compact e preservado.
60
+ */
61
+ function conversationMessages(entries = history) {
62
+ const out = [];
63
+ for (const entry of entries) {
64
+ const role = String(entry?.role || "");
65
+ if (role !== "system" && role !== "user" && role !== "assistant") continue;
66
+ const content = String(entry?.content || "").trim();
67
+ if (!content) continue;
68
+ const last = out[out.length - 1];
69
+ if (last && last.role === role && role !== "system") last.content += `\n\n${content}`;
70
+ else out.push({ role, content });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** Conversa visivel, no formato que `sessions.save` e `kafox history` leem. */
76
+ function displayMessages() {
77
+ return history.map((entry) => ({ role: entry.role, text: entry.content }));
78
+ }
79
+
80
+ /** Grava a conversa do REPL sem TTY em ~/.kafox/chats (best-effort). */
81
+ function savePlainSession() {
82
+ if (!plainSession) return;
83
+ try {
84
+ sessions.save({
85
+ id: plainSession.id,
86
+ cwd: process.cwd(),
87
+ model: modelOverride || storage.getConfig().model,
88
+ theme: storage.getConfig().theme,
89
+ allowAll: false,
90
+ messages: displayMessages(),
91
+ context: conversationMessages(),
92
+ startedAt: plainSession.startedAt,
93
+ });
94
+ } catch {
95
+ // Persistencia e conveniencia: falha de disco nao derruba o chat.
96
+ }
97
+ }
98
+
47
99
  async function handleCommand(line) {
48
100
  const [cmd, ...rest] = line.trim().split(/\s+/);
49
101
  const arg = rest.join(" ");
@@ -58,6 +110,7 @@ async function handleCommand(line) {
58
110
  console.log(` ${chalk.bold("Esc Esc")} ${chalk.dim("para a IA na hora (o primeiro Esc avisa)")}`);
59
111
  console.log(` ${chalk.bold("PgUp/PgDn")} ${chalk.dim("ou a roda do mouse rola a conversa")}`);
60
112
  console.log(` ${chalk.bold("Ctrl+Y")} ${chalk.dim("copia a ultima resposta para a area de transferencia")}`);
113
+ console.log(` ${chalk.bold("Ctrl+V")} ${chalk.dim("cola uma imagem copiada (ou o texto) no proximo turno")}`);
61
114
  console.log(` ${chalk.bold("F2")} ${chalk.dim("solta o mouse para selecionar texto com o terminal")}`);
62
115
  console.log(` ${chalk.bold("Ctrl+C")} ${chalk.dim("sai")}`);
63
116
  console.log("");
@@ -178,6 +231,7 @@ async function handleCommand(line) {
178
231
  return true;
179
232
  case "/exit":
180
233
  case "/quit":
234
+ savePlainSession();
181
235
  process.exit(0);
182
236
  default:
183
237
  console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
@@ -186,25 +240,39 @@ async function handleCommand(line) {
186
240
  }
187
241
 
188
242
  async function clearContext() {
243
+ // Guarda a conversa atual e comeca outra: manter o mesmo codigo fazia o
244
+ // que viesse depois do /clear cair no chat antigo (e ser sobrescrito por ele).
245
+ savePlainSession();
189
246
  history = [];
247
+ plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
190
248
  console.log(chalk.green("Context cleared."));
191
249
  }
192
250
 
193
251
  async function compact() {
194
- if (!history.length) {
252
+ const messages = conversationMessages();
253
+ if (!messages.length) {
195
254
  console.log(chalk.dim("Nothing to compact."));
196
255
  return;
197
256
  }
198
- const turns = history.length;
199
- const last = history.slice(-6);
200
- history = [
201
- {
202
- role: "system",
203
- content: `(context compacted: ${turns} messages summarized)`,
204
- at: new Date().toISOString(),
205
- },
206
- ...last,
207
- ];
257
+ const turns = messages.length;
258
+ // Mesmo resumo estruturado da TUI: o texto antigo vira nota de contexto em
259
+ // vez de sumir (o comportamento antigo so guardava um aviso vazio).
260
+ const result = await compactIfNeeded(messages, {
261
+ model: modelOverride || storage.getConfig().model,
262
+ // 0 forca o resumo: quem digitou /compact quer compactar agora.
263
+ thresholdTokens: 0,
264
+ });
265
+ if (!result.compacted) {
266
+ console.log(chalk.yellow(`Nothing to compact: ${result.error || "resumo vazio"}`));
267
+ return;
268
+ }
269
+ const at = new Date().toISOString();
270
+ history = result.messages.map((message) => ({
271
+ role: message.role,
272
+ content: String(message.content || ""),
273
+ at,
274
+ }));
275
+ savePlainSession();
208
276
  console.log(
209
277
  chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
210
278
  );
@@ -234,13 +302,40 @@ async function showStatus() {
234
302
  }
235
303
 
236
304
  /** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
237
- async function runPlainREPL() {
305
+ async function runPlainREPL(options = {}) {
238
306
  const cfg = storage.getConfig();
239
307
  const theme = cfg.theme || "default";
308
+ history = [];
309
+ plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
310
+
311
+ // `kafox resume <codigo>` tambem vale sem TTY: antes o pipe/script abria
312
+ // sempre uma conversa vazia, mesmo com a sessao salva no disco.
313
+ if (options.resume) {
314
+ const wanted = options.resume === "pick" || options.resume === true ? "last" : options.resume;
315
+ const saved = String(wanted) === "last" ? sessions.latest() : sessions.find(wanted);
316
+ if (saved) {
317
+ plainSession = { id: saved.id, startedAt: saved.startedAt || plainSession.startedAt };
318
+ const restored =
319
+ Array.isArray(saved.context) && saved.context.length
320
+ ? saved.context
321
+ : sessions.contextFromMessages(saved.messages);
322
+ const at = new Date().toISOString();
323
+ history = restored
324
+ .filter((message) => ["system", "user", "assistant"].includes(message?.role))
325
+ .map((message) => ({ role: message.role, content: String(message.content || ""), at }));
326
+ } else {
327
+ console.log(chalk.yellow(` Conversa ${wanted} nao encontrada - comecando uma nova.`));
328
+ }
329
+ }
240
330
 
241
331
  console.log(logoLines(theme).join("\n"));
242
332
  console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
243
333
  console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
334
+ if (history.length) {
335
+ console.log(
336
+ chalk.dim(` Retomando ${plainSession.id} (${conversationMessages().length} mensagens no contexto).`)
337
+ );
338
+ }
244
339
 
245
340
  const token = await ensureSession().catch(() => null);
246
341
  if (!token) {
@@ -268,35 +363,68 @@ async function runPlainREPL() {
268
363
  input: process.stdin,
269
364
  output: process.stdout,
270
365
  prompt: chalk.cyan("kafox> "),
366
+ // O readline guarda o mais recente no começo da lista.
367
+ history: promptHistory.load().slice().reverse(),
368
+ historySize: promptHistory.MAX,
369
+ removeHistoryDuplicates: true,
271
370
  });
272
371
 
273
- rl.prompt();
274
- rl.on("line", async (raw) => {
372
+ async function handleLine(raw) {
275
373
  const line = raw.trim();
276
- if (!line) return rl.prompt();
374
+ if (!line) return;
277
375
  try {
278
376
  if (line.startsWith("/")) {
279
377
  await handleCommand(line);
280
378
  } else {
281
379
  pushHistory("user", line);
282
- await ask(line, { model: modelOverride || storage.getConfig().model });
283
- pushHistory("assistant", "(answer above)");
380
+ promptHistory.push(line);
381
+ // O historico inteiro (nao so a ultima linha) e o que faz a IA lembrar
382
+ // do que ja foi dito nesta conversa.
383
+ const result = await chat({
384
+ messages: trimHistory(conversationMessages()),
385
+ model: modelOverride || storage.getConfig().model,
386
+ });
387
+ if (!result.ok) {
388
+ console.log(chalk.red(`\n${result.error}`));
389
+ } else {
390
+ const answer = String(result.text || "").trim() || "(resposta vazia)";
391
+ console.log(chalk.green("\nAnswer:"));
392
+ console.log(answer);
393
+ pushHistory("assistant", answer);
394
+ }
395
+ savePlainSession();
284
396
  }
285
397
  } catch (err) {
286
398
  console.log(chalk.red(`\nError: ${err.message}`));
287
399
  }
288
- rl.prompt();
400
+ if (!rl.closed) rl.prompt();
401
+ }
402
+
403
+ // Num pipe o stdin chega inteiro de uma vez: processar as linhas em paralelo
404
+ // embaralhava o historico (a segunda nao via a resposta da primeira).
405
+ let pending = Promise.resolve();
406
+ rl.prompt();
407
+ rl.on("line", (raw) => {
408
+ pending = pending.then(() => handleLine(raw));
409
+ return pending;
289
410
  });
290
411
 
291
- rl.on("close", () => {
292
- console.log(chalk.dim("\nSee you."));
412
+ rl.on("close", async () => {
413
+ // EOF do pipe nao pode cortar a ultima resposta no meio: antes o processo
414
+ // saia antes do POST /api/chat terminar e o chat ficava sem resposta.
415
+ await pending.catch(() => {});
416
+ savePlainSession();
417
+ if (plainSession && conversationMessages().length) {
418
+ console.log(chalk.dim(`\nConversa salva: kafox resume ${plainSession.id}`));
419
+ }
420
+ console.log(chalk.dim("See you."));
293
421
  process.exit(0);
294
422
  });
295
423
  }
296
424
 
297
425
  async function runREPL(options = {}) {
298
426
  // Sem TTY (pipe/CI), mantem o REPL linha a linha.
299
- if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
427
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL(options);
300
428
 
301
429
  const cfg = storage.getConfig();
302
430
  const token = await ensureSession().catch(() => null);
@@ -314,7 +442,13 @@ async function runREPL(options = {}) {
314
442
  }
315
443
 
316
444
  return runTUI({
317
- commands: COMMANDS,
445
+ // /image e /paste so valem na interface: e ela que desenha o preview e
446
+ // manda a imagem junto da mensagem. O REPL sem TTY continua so texto.
447
+ commands: {
448
+ ...COMMANDS,
449
+ "/image": "anexa imagem ao proximo turno (/image <arquivo>; clear limpa)",
450
+ "/paste": "cola a imagem da area de transferencia (atalho: Ctrl+V)",
451
+ },
318
452
  onCommand: handleCommand,
319
453
  // /theme e /background rodam dentro da TUI; os demais abrem prompt no terminal.
320
454
  interactiveCommands: new Set(["/login", "/key"]),
@@ -332,5 +466,6 @@ module.exports = {
332
466
  clearContext,
333
467
  showStatus,
334
468
  handleCommand,
469
+ conversationMessages,
335
470
  COMMANDS,
336
471
  };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Area de transferencia: colar imagem no chat.
3
+ *
4
+ * O Ctrl+V do terminal so entrega texto. A imagem que voce copiou de um print
5
+ * (Win+Shift+S no Windows, Ctrl+Shift+Cmd+4 no macOS, "Copiar imagem" no Linux)
6
+ * nao passa por esse caminho, entao a CLI le a area de transferencia do sistema
7
+ * pelos utilitarios de sempre:
8
+ *
9
+ * - Windows: PowerShell (System.Windows.Forms.Clipboard)
10
+ * - macOS: `pngpaste` se estiver instalado, senao `osascript` com «class PNGf»
11
+ * - Linux: `wl-paste` (Wayland) e `xclip` (X11)
12
+ *
13
+ * Nada aqui lanca: sem utilitario instalado, a TUI recebe `{ ok: false }` com
14
+ * uma mensagem que diz o que instalar, e o `/image <arquivo>` continua valendo.
15
+ */
16
+ const { spawnSync } = require("child_process");
17
+ const fs = require("fs");
18
+ const os = require("os");
19
+ const path = require("path");
20
+ const { sniffMime } = require("./images");
21
+
22
+ // Ordem de preferencia: PNG e o formato que todos os provedores aceitam e o
23
+ // unico que o preview desenha sem depender de dependencia opcional.
24
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
25
+
26
+ function run(command, args, options = {}) {
27
+ try {
28
+ const result = spawnSync(command, args, {
29
+ timeout: options.timeout || 6000,
30
+ windowsHide: true,
31
+ maxBuffer: 32 * 1024 * 1024,
32
+ input: options.input,
33
+ });
34
+ if (result.error) return { ok: false, reason: result.error.code || result.error.message };
35
+ if (result.status !== 0) return { ok: false, reason: `exit ${result.status ?? "?"}` };
36
+ const buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || "");
37
+ if (!buffer.length) return { ok: false, reason: "vazio" };
38
+ return { ok: true, buffer };
39
+ } catch (error) {
40
+ return { ok: false, reason: error.message };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Escolhe o tipo de imagem na lista que o gerenciador de clipboard publica
46
+ * (`xclip -t TARGETS -o`, `wl-paste --list-types`). Uma imagem e anunciada por
47
+ * varios tipos; PNG ganha dos outros para a colagem sair igual em todo modelo.
48
+ */
49
+ function pickMime(list) {
50
+ const text = String(list || "");
51
+ for (const mime of IMAGE_TYPES) {
52
+ if (text.includes(mime)) return mime;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function fromWayland() {
58
+ const types = run("wl-paste", ["--list-types"]);
59
+ if (!types.ok) return null;
60
+ const mime = pickMime(types.buffer.toString("utf8"));
61
+ if (!mime) return null;
62
+ const data = run("wl-paste", ["--no-newline", "--type", mime]);
63
+ if (!data.ok) return null;
64
+ return { buffer: data.buffer, mime };
65
+ }
66
+
67
+ function fromX11() {
68
+ const targets = run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]);
69
+ if (!targets.ok) return null;
70
+ const mime = pickMime(targets.buffer.toString("utf8"));
71
+ if (!mime) return null;
72
+ const data = run("xclip", ["-selection", "clipboard", "-t", mime, "-o"]);
73
+ if (!data.ok) return null;
74
+ return { buffer: data.buffer, mime };
75
+ }
76
+
77
+ function fromMac() {
78
+ const png = run("pngpaste", ["-"]);
79
+ if (png.ok && sniffMime(png.buffer)) return { buffer: png.buffer, mime: sniffMime(png.buffer) };
80
+
81
+ const file = path.join(os.tmpdir(), `kafox-paste-${process.pid}-${Date.now()}.png`);
82
+ const script = [
83
+ "set pngData to the clipboard as «class PNGf»",
84
+ `set f to open for access POSIX file "${file}" with write permission`,
85
+ "set eof f to 0",
86
+ "write pngData to f",
87
+ "close access f",
88
+ ];
89
+ const args = [];
90
+ for (const line of script) args.push("-e", line);
91
+
92
+ try {
93
+ if (!run("osascript", args).ok) return null;
94
+ const buffer = fs.readFileSync(file);
95
+ return buffer.length ? { buffer, mime: sniffMime(buffer) || "image/png" } : null;
96
+ } catch {
97
+ return null;
98
+ } finally {
99
+ try {
100
+ fs.unlinkSync(file);
101
+ } catch {}
102
+ }
103
+ }
104
+
105
+ function fromWindows() {
106
+ const file = path.join(os.tmpdir(), `kafox-paste-${process.pid}-${Date.now()}.png`);
107
+ const script =
108
+ "Add-Type -AssemblyName System.Windows.Forms;" +
109
+ "Add-Type -AssemblyName System.Drawing;" +
110
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();" +
111
+ "if ($img -eq $null) { exit 3 };" +
112
+ `$img.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png);`;
113
+
114
+ try {
115
+ if (!run("powershell", ["-NoProfile", "-NonInteractive", "-STA", "-Command", script]).ok) return null;
116
+ const buffer = fs.readFileSync(file);
117
+ return buffer.length ? { buffer, mime: sniffMime(buffer) || "image/png" } : null;
118
+ } catch {
119
+ return null;
120
+ } finally {
121
+ try {
122
+ fs.unlinkSync(file);
123
+ } catch {}
124
+ }
125
+ }
126
+
127
+ function missingImageHint() {
128
+ if (process.platform === "darwin") {
129
+ return "nenhuma imagem copiada (copie um print com Ctrl+Shift+Cmd+4, ou use /image <arquivo>)";
130
+ }
131
+ if (process.platform === "win32") {
132
+ return "nenhuma imagem copiada (copie com Win+Shift+S, ou use /image <arquivo>)";
133
+ }
134
+ return "nenhuma imagem copiada (no Linux precisa de wl-clipboard ou xclip; /image <arquivo> sempre funciona)";
135
+ }
136
+
137
+ /**
138
+ * Le a imagem da area de transferencia.
139
+ * Retorna `{ ok: true, buffer, mime }` ou `{ ok: false, error }`.
140
+ */
141
+ function readImage() {
142
+ const attempts =
143
+ process.platform === "win32"
144
+ ? [fromWindows]
145
+ : process.platform === "darwin"
146
+ ? [fromMac]
147
+ : [fromWayland, fromX11];
148
+
149
+ for (const attempt of attempts) {
150
+ let found = null;
151
+ try {
152
+ found = attempt();
153
+ } catch {
154
+ found = null;
155
+ }
156
+ if (found?.buffer?.length) {
157
+ return { ok: true, buffer: found.buffer, mime: sniffMime(found.buffer) || found.mime };
158
+ }
159
+ }
160
+
161
+ return { ok: false, error: missingImageHint() };
162
+ }
163
+
164
+ /** Le o texto da area de transferencia (fallback do Ctrl+V sem imagem). */
165
+ function readText() {
166
+ const attempts =
167
+ process.platform === "win32"
168
+ ? [["powershell", ["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"]]]
169
+ : process.platform === "darwin"
170
+ ? [["pbpaste", []]]
171
+ : [
172
+ ["wl-paste", ["--no-newline"]],
173
+ ["xclip", ["-selection", "clipboard", "-o"]],
174
+ ];
175
+
176
+ for (const [command, args] of attempts) {
177
+ const result = run(command, args);
178
+ if (result.ok) return result.buffer.toString("utf8");
179
+ }
180
+ return "";
181
+ }
182
+
183
+ module.exports = { IMAGE_TYPES, pickMime, readImage, readText };