nothumanallowed 15.1.47 → 15.1.49
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/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/server/routes/chat.mjs +78 -12
- package/src/services/tool-executor.mjs +185 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.49",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '15.1.
|
|
8
|
+
export const VERSION = '15.1.49';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from '../../services/conversations.mjs';
|
|
17
17
|
import { callLLMStream, callLLM, callLLMVision, parseAgentFile } from '../../services/llm.mjs';
|
|
18
18
|
import { buildMemoryContext } from '../../services/memory.mjs';
|
|
19
|
-
import { parseActions, executeTool, buildSystemPrompt } from '../../services/tool-executor.mjs';
|
|
19
|
+
import { parseActions, executeTool, buildSystemPrompt, stripOrphanFences } from '../../services/tool-executor.mjs';
|
|
20
20
|
|
|
21
21
|
// Migrate on import (once)
|
|
22
22
|
migrateOldHistory();
|
|
@@ -275,17 +275,59 @@ export function register(router) {
|
|
|
275
275
|
a.params = { url: 'https://' + a.params.query.trim() };
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
-
// Auto-detect email reading intent — force
|
|
278
|
+
// Auto-detect email reading intent — force the right IMAP tool if the
|
|
279
|
+
// LLM didn't emit one. Semantic keywords (offerta/RFQ/preventivo/...)
|
|
280
|
+
// trigger `imap_search` across all synced messages with each variant,
|
|
281
|
+
// because users almost never want "just the 5 latest" — they want
|
|
282
|
+
// "everything matching X". Generic "leggi le email" still falls back
|
|
283
|
+
// to imap_list. Multi-language: it/en/de/fr/es.
|
|
284
|
+
const lower = msg.toLowerCase();
|
|
279
285
|
const wantsReadEmail = /\b(leggi|read|mostra|lista|ultime?|recenti?|email|mail|inbox|posta)\b.*\b(email|mail|messag|inbox|posta)\b|\b(email|mail)\b.*\b(leggi|read|mostra|lista|ultime?|recenti?)\b/i.test(msg);
|
|
280
|
-
|
|
286
|
+
// Semantic intent → quote / offer / proposal / order requests
|
|
287
|
+
const QUOTE_KEYWORDS = {
|
|
288
|
+
offerta: ['offerta', 'richiesta offerta', 'richiesta di offerta', 'preventivo', 'quotazione', 'rdo', 'rda'],
|
|
289
|
+
quote: ['quote', 'quotation', 'rfq', 'rfp', 'request for quote', 'request for proposal', 'price request', 'pricing'],
|
|
290
|
+
order: ['ordine', 'order', 'po ', 'purchase order', 'commessa', 'bestellung', 'commande'],
|
|
291
|
+
invoice: ['fattura', 'invoice', 'rechnung', 'facture'],
|
|
292
|
+
proposal: ['proposta', 'proposal', 'angebot', 'devis'],
|
|
293
|
+
};
|
|
294
|
+
let semanticBag = null;
|
|
295
|
+
if (/\b(offert|preventiv|quotaz|rdo\b|rda\b|quotation|rfq|rfp|request\s+for\s+(quote|proposal|pricing)|price\s+request|pricing|angebot|devis|proposta|proposal)/i.test(lower)) {
|
|
296
|
+
semanticBag = 'offerta';
|
|
297
|
+
} else if (/\b(ordin|order|purchase\s+order|po\s+\d|commessa|bestellung|commande)/i.test(lower)) {
|
|
298
|
+
semanticBag = 'order';
|
|
299
|
+
} else if (/\b(fattur|invoice|rechnung|facture)/i.test(lower)) {
|
|
300
|
+
semanticBag = 'invoice';
|
|
301
|
+
} else if (/\b(propost|proposal|angebot|devis)/i.test(lower)) {
|
|
302
|
+
semanticBag = 'proposal';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if ((wantsReadEmail || semanticBag) && !actions.some(a => a.action?.startsWith('imap_') || a.action === 'list_emails')) {
|
|
281
306
|
try {
|
|
282
307
|
const { listAccounts: _la } = await import('../../services/email-db.mjs');
|
|
283
308
|
const imapAccs = _la();
|
|
284
309
|
if (imapAccs.length > 0) {
|
|
285
310
|
const firstAcc = imapAccs[0];
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
311
|
+
if (semanticBag) {
|
|
312
|
+
// Push one imap_search per keyword variant in the bag. The
|
|
313
|
+
// synthesis step will dedupe overlapping hits. We default to a
|
|
314
|
+
// 60-day window (limit=80 per query) — much wider than the
|
|
315
|
+
// 5-email peek the old branch did.
|
|
316
|
+
const bag = QUOTE_KEYWORDS[semanticBag] || [];
|
|
317
|
+
const variants = [
|
|
318
|
+
...(QUOTE_KEYWORDS.offerta || []).slice(0, 3),
|
|
319
|
+
...(QUOTE_KEYWORDS.quote || []).slice(0, 3),
|
|
320
|
+
...bag,
|
|
321
|
+
];
|
|
322
|
+
const unique = [...new Set(variants)].slice(0, 6);
|
|
323
|
+
for (const q of unique) {
|
|
324
|
+
actions.push({ action: 'imap_search', params: { accountId: firstAcc.id, query: q, limit: 50 } });
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
const limitMatch = msg.match(/\b(\d+)\b/);
|
|
328
|
+
const limit = limitMatch ? Math.min(parseInt(limitMatch[1]), 50) : 20;
|
|
329
|
+
actions.push({ action: 'imap_list', params: { accountId: firstAcc.id, limit } });
|
|
330
|
+
}
|
|
289
331
|
}
|
|
290
332
|
} catch { /* fallback to LLM response */ }
|
|
291
333
|
}
|
|
@@ -383,24 +425,48 @@ export function register(router) {
|
|
|
383
425
|
const synthesisPrompt = `${enrichedPrompt}\n\n## DATA FROM TOOLS:\n${toolContext}\n\n## STRICT OUTPUT RULES:\n- Write ONLY plain prose or markdown (headers, bullets, bold)\n- NEVER use \`\`\`json, \`\`\`data, or any fenced code block containing data\n- NEVER output raw JSON, arrays, or objects\n- Format numbers/prices as plain text (e.g. "Bitcoin: $103,000")\n- Be concise and human-readable`;
|
|
384
426
|
const synthesisMsg = `${effectiveMsg}\n\nAnswer using ONLY the data above. Plain text/markdown only — zero JSON, zero code blocks.`;
|
|
385
427
|
sse('tool_synthesis', {});
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
428
|
+
// Keep the pre-synthesis prose around. If the synthesis call returns
|
|
429
|
+
// empty (provider error, content filter, model bailed), we fall back
|
|
430
|
+
// to "first-round prose + raw tool output" so the user never sees a
|
|
431
|
+
// blank message and can still read what the tools returned.
|
|
432
|
+
const preSynthesis = fullResponse;
|
|
433
|
+
let synthesized = '';
|
|
434
|
+
try {
|
|
435
|
+
synthesized = await callLLMStream(config, synthesisPrompt, synthesisMsg, (chunk) => {
|
|
436
|
+
sse('token', { content: chunk });
|
|
437
|
+
});
|
|
438
|
+
} catch (synthErr) {
|
|
439
|
+
sse('error', { message: `Synthesis failed: ${synthErr.message}` });
|
|
440
|
+
}
|
|
441
|
+
if (synthesized && synthesized.trim()) {
|
|
442
|
+
fullResponse = synthesized;
|
|
443
|
+
} else {
|
|
444
|
+
// Fallback: stream the raw tool context so the user gets the data
|
|
445
|
+
// even when the LLM round failed silently.
|
|
446
|
+
const fallback = (preSynthesis && preSynthesis.trim() ? preSynthesis.trim() + '\n\n' : '') +
|
|
447
|
+
toolResults.map(t => `**${t.action}**\n${cleanResult(t.action, t.result)}`).join('\n\n');
|
|
448
|
+
sse('token', { content: fallback });
|
|
449
|
+
fullResponse = fallback;
|
|
450
|
+
}
|
|
390
451
|
}
|
|
391
452
|
|
|
453
|
+
// Strip orphan tool-fence blocks that the LLM may have emitted as
|
|
454
|
+
// a "no-more-tools" marker (e.g. empty ```json ``` or '''json ''').
|
|
455
|
+
// They leaked into the chat panel as visible noise.
|
|
456
|
+
const cleanFullResponse = stripOrphanFences(fullResponse);
|
|
457
|
+
|
|
392
458
|
// Persist to conversation
|
|
393
459
|
if (body.conversationId) {
|
|
394
460
|
try {
|
|
395
461
|
const conv = loadConversation(body.conversationId);
|
|
396
462
|
if (conv) {
|
|
397
|
-
addMessages(conv, msg,
|
|
463
|
+
addMessages(conv, msg, cleanFullResponse);
|
|
398
464
|
}
|
|
399
465
|
} catch {}
|
|
400
466
|
}
|
|
401
467
|
|
|
402
468
|
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
|
|
403
|
-
sse('done', { content:
|
|
469
|
+
sse('done', { content: cleanFullResponse });
|
|
404
470
|
res.write('data: [DONE]\n\n');
|
|
405
471
|
res.end();
|
|
406
472
|
} catch (e) {
|
|
@@ -455,7 +455,15 @@ TOOLS:
|
|
|
455
455
|
Only call this if you need to refresh or the section is missing.
|
|
456
456
|
|
|
457
457
|
66. imap_read(messageId: string)
|
|
458
|
-
Read a full email message from the local DB by its id. Returns subject, from, to, body_text, body_html,
|
|
458
|
+
Read a full email message from the local DB by its id. Returns subject, from, to, body_text, body_html, and a numbered ATTACHMENTS list.
|
|
459
|
+
If the email has attachments, follow up with imap_attachment_read to read each one's content.
|
|
460
|
+
|
|
461
|
+
66b. imap_attachment_read(messageId: string, filename?: string, index?: number, attachmentId?: string)
|
|
462
|
+
Download and parse an attachment of a given email. messageId is required; pick the attachment by filename (substring match,
|
|
463
|
+
case-insensitive), 1-based index, or attachmentId (exact). Returns extracted text for PDF (text-based POs/quotes/invoices),
|
|
464
|
+
DOCX (Word), and any text/* type. For image-based PDFs or unsupported binary types, returns metadata and instructs the
|
|
465
|
+
user to share the relevant section as text. Use this whenever the user says "leggi l'allegato", "read the attachment",
|
|
466
|
+
"estrai dati dall'allegato", "what's in the PDF", etc.
|
|
459
467
|
|
|
460
468
|
67. imap_send(accountId: string, to: string, subject: string, bodyHtml: string, cc?: string, inReplyTo?: string)
|
|
461
469
|
Send an email via SMTP from a configured IMAP account. ALWAYS confirm with user before sending.
|
|
@@ -820,9 +828,12 @@ export function parseActions(text) {
|
|
|
820
828
|
const actions = [];
|
|
821
829
|
const textParts = [];
|
|
822
830
|
|
|
823
|
-
// Normalize: some LLMs output "json ... "
|
|
824
|
-
//
|
|
831
|
+
// Normalize: some LLMs output "json ... ", 'json ... ' or '''json ... '''
|
|
832
|
+
// (Python-style triple-quote) instead of ```json ... ```. We rewrite all
|
|
833
|
+
// these to proper triple-backtick fences before the main regex runs.
|
|
825
834
|
const normalized = text
|
|
835
|
+
.replace(/'''json\s*\n?([\s\S]*?)\n?\s*'''/g, (_, body) => '```json\n' + body.trim() + '\n```')
|
|
836
|
+
.replace(/"""json\s*\n?([\s\S]*?)\n?\s*"""/g, (_, body) => '```json\n' + body.trim() + '\n```')
|
|
826
837
|
.replace(/"json\s*\n([\s\S]*?)\n\s*"/g, (_, body) => '```json\n' + body.trim() + '\n```')
|
|
827
838
|
.replace(/'json\s*\n([\s\S]*?)\n\s*'/g, (_, body) => '```json\n' + body.trim() + '\n```');
|
|
828
839
|
|
|
@@ -914,11 +925,112 @@ export function parseActions(text) {
|
|
|
914
925
|
return { textParts, actions };
|
|
915
926
|
}
|
|
916
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Strip orphan tool-fence blocks from a finished LLM response. These appear
|
|
930
|
+
* when the model emits an empty `'''json '''` (or backtick-fenced) block
|
|
931
|
+
* as a no-op marker — the parser doesn't pick them up as actions but they
|
|
932
|
+
* leak into the UI as visible noise. Run this on the final assistant text
|
|
933
|
+
* before showing it to the user.
|
|
934
|
+
*/
|
|
935
|
+
export function stripOrphanFences(text) {
|
|
936
|
+
if (!text || typeof text !== 'string') return text;
|
|
937
|
+
return text
|
|
938
|
+
// Triple-backtick, triple-single, triple-double quote fences with `json`
|
|
939
|
+
// marker — empty or with a body. We strip the whole block.
|
|
940
|
+
.replace(/```json\s*\n?[\s\S]*?```/g, '')
|
|
941
|
+
.replace(/'''json\s*\n?[\s\S]*?'''/g, '')
|
|
942
|
+
.replace(/"""json\s*\n?[\s\S]*?"""/g, '')
|
|
943
|
+
// Bare action JSON that the parser already consumed but the synthesis
|
|
944
|
+
// step regurgitated (rare but happens with Liara). Only strip if the
|
|
945
|
+
// JSON shape matches "action":"...".
|
|
946
|
+
.replace(/\{\s*"action"\s*:\s*"[^"]+"\s*,?\s*("params"\s*:\s*\{[\s\S]*?\}\s*)?\}/g, '')
|
|
947
|
+
// Collapse blank-line clusters produced by the stripping.
|
|
948
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
949
|
+
.trim();
|
|
950
|
+
}
|
|
951
|
+
|
|
917
952
|
// ── Formatting Helpers ───────────────────────────────────────────────────────
|
|
918
953
|
|
|
919
954
|
/**
|
|
920
955
|
* Format an ISO timestamp into a human-readable time string.
|
|
921
956
|
*/
|
|
957
|
+
// ── Attachment Parsers (zero-deps, best-effort) ────────────────────────────
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Extract text from a PDF buffer using a naïve pattern scan. Catches text
|
|
961
|
+
* inside `BT ... (text) Tj ... ET` blocks of uncompressed content streams.
|
|
962
|
+
* Won't work on PDFs whose content streams are FlateDecode-compressed — those
|
|
963
|
+
* need a real parser (pdfjs-dist). For typical ERP-generated POs/quotes the
|
|
964
|
+
* content stream is usually plain enough that this catches the line items.
|
|
965
|
+
*/
|
|
966
|
+
function _naivePdfText(buf) {
|
|
967
|
+
if (!Buffer.isBuffer(buf)) return '';
|
|
968
|
+
// PDFs can store text in (...) strings, sometimes split across multiple Tj
|
|
969
|
+
// calls. We collect them all, decode the basic escapes, and join with spaces.
|
|
970
|
+
const raw = buf.toString('latin1');
|
|
971
|
+
const out = [];
|
|
972
|
+
// BT ... ET text blocks. Inside: (text) Tj or [(a)(b)] TJ.
|
|
973
|
+
const blockRe = /BT[\s\S]*?ET/g;
|
|
974
|
+
let m;
|
|
975
|
+
while ((m = blockRe.exec(raw))) {
|
|
976
|
+
const block = m[0];
|
|
977
|
+
const strRe = /\(((?:\\.|[^\\)])*)\)/g;
|
|
978
|
+
let sm;
|
|
979
|
+
const parts = [];
|
|
980
|
+
while ((sm = strRe.exec(block))) {
|
|
981
|
+
const s = sm[1]
|
|
982
|
+
.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t')
|
|
983
|
+
.replace(/\\\(/g, '(').replace(/\\\)/g, ')').replace(/\\\\/g, '\\')
|
|
984
|
+
.replace(/\\([0-7]{1,3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
985
|
+
parts.push(s);
|
|
986
|
+
}
|
|
987
|
+
if (parts.length) out.push(parts.join(' '));
|
|
988
|
+
}
|
|
989
|
+
// Cleanup: collapse whitespace, drop control chars.
|
|
990
|
+
return out.join('\n').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '').replace(/[ \t]+/g, ' ').trim();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Extract text from a DOCX buffer. DOCX = zip with `word/document.xml`. We
|
|
995
|
+
* use Node's built-in zlib to decompress the central directory, find the
|
|
996
|
+
* document.xml entry, decompress it, and pull <w:t>...</w:t> text runs.
|
|
997
|
+
*/
|
|
998
|
+
async function _naiveDocxText(buf) {
|
|
999
|
+
if (!Buffer.isBuffer(buf)) return '';
|
|
1000
|
+
const zlib = await import('zlib');
|
|
1001
|
+
const { promisify } = await import('util');
|
|
1002
|
+
const inflateRaw = promisify(zlib.inflateRaw);
|
|
1003
|
+
// ZIP local file headers start with 0x504b0304. We scan for them and pick
|
|
1004
|
+
// out the entry whose filename is "word/document.xml".
|
|
1005
|
+
let i = 0;
|
|
1006
|
+
while (i < buf.length - 30) {
|
|
1007
|
+
if (buf.readUInt32LE(i) !== 0x04034b50) { i++; continue; }
|
|
1008
|
+
const compMethod = buf.readUInt16LE(i + 8);
|
|
1009
|
+
const compSize = buf.readUInt32LE(i + 18);
|
|
1010
|
+
const nameLen = buf.readUInt16LE(i + 26);
|
|
1011
|
+
const extraLen = buf.readUInt16LE(i + 28);
|
|
1012
|
+
const name = buf.slice(i + 30, i + 30 + nameLen).toString('utf8');
|
|
1013
|
+
const dataStart = i + 30 + nameLen + extraLen;
|
|
1014
|
+
if (name === 'word/document.xml') {
|
|
1015
|
+
const compData = buf.slice(dataStart, dataStart + compSize);
|
|
1016
|
+
let xml = '';
|
|
1017
|
+
try {
|
|
1018
|
+
if (compMethod === 0) xml = compData.toString('utf8');
|
|
1019
|
+
else if (compMethod === 8) xml = (await inflateRaw(compData)).toString('utf8');
|
|
1020
|
+
else return '';
|
|
1021
|
+
} catch { return ''; }
|
|
1022
|
+
// Extract <w:t>...</w:t> runs (and the xml: space="preserve" variant).
|
|
1023
|
+
const parts = [];
|
|
1024
|
+
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
|
|
1025
|
+
let m;
|
|
1026
|
+
while ((m = re.exec(xml))) parts.push(m[1].replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'"));
|
|
1027
|
+
return parts.join(' ').replace(/[ \t]+/g, ' ').trim();
|
|
1028
|
+
}
|
|
1029
|
+
i = dataStart + compSize;
|
|
1030
|
+
}
|
|
1031
|
+
return '';
|
|
1032
|
+
}
|
|
1033
|
+
|
|
922
1034
|
export function formatTime(isoStr) {
|
|
923
1035
|
try {
|
|
924
1036
|
const d = new Date(isoStr);
|
|
@@ -1325,7 +1437,76 @@ export async function executeTool(action, params, config) {
|
|
|
1325
1437
|
imapMarkRead(params.messageId, true);
|
|
1326
1438
|
const to = (() => { try { const a = JSON.parse(msg.to_addresses || '[]'); return a.map(x => x.address || x).join(', '); } catch { return msg.to_addresses || ''; } })();
|
|
1327
1439
|
const body = msg.body_reply_only || msg.body_text || msg.body_preview || '(empty)';
|
|
1328
|
-
|
|
1440
|
+
// Surface attachments in the tool response so the LLM can decide whether
|
|
1441
|
+
// to follow up with imap_attachment_read. Without this it has no way to
|
|
1442
|
+
// know an attachment exists.
|
|
1443
|
+
const atts = (msg.attachments || []).map((a, i) =>
|
|
1444
|
+
`[${i + 1}] "${a.filename || 'unnamed'}" — ${a.content_type || 'application/octet-stream'} — ${Math.round((a.size_bytes || 0) / 1024)} KB — id:${a.id}`
|
|
1445
|
+
).join('\n');
|
|
1446
|
+
const attBlock = atts
|
|
1447
|
+
? `\n\n--- ATTACHMENTS (${msg.attachments.length}) ---\n${atts}\n\nTo read the content of an attachment, call: imap_attachment_read with messageId="${msg.id}" and either filename or index.`
|
|
1448
|
+
: '';
|
|
1449
|
+
return `Subject: ${msg.subject}\nFrom: ${msg.from_name ? msg.from_name + ' <' + msg.from_address + '>' : msg.from_address}\nTo: ${to}\nDate: ${msg.internal_date}\n\n${body.slice(0, 3000)}${attBlock}`;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
case 'imap_attachment_read': {
|
|
1453
|
+
if (!params.messageId) return 'messageId required. Use imap_read first to get the messageId and the list of attachments.';
|
|
1454
|
+
const { getMessage: imapGetMsgForAtt } = await import('./email-db.mjs');
|
|
1455
|
+
const msg = imapGetMsgForAtt(params.messageId);
|
|
1456
|
+
if (!msg) return 'Message not found.';
|
|
1457
|
+
const attachments = msg.attachments || [];
|
|
1458
|
+
if (!attachments.length) return 'This message has no attachments.';
|
|
1459
|
+
// Resolution: explicit attachmentId > filename match > index > first
|
|
1460
|
+
let chosen = null;
|
|
1461
|
+
if (params.attachmentId) {
|
|
1462
|
+
chosen = attachments.find(a => a.id === params.attachmentId);
|
|
1463
|
+
} else if (params.filename) {
|
|
1464
|
+
const needle = String(params.filename).toLowerCase();
|
|
1465
|
+
chosen = attachments.find(a => (a.filename || '').toLowerCase().includes(needle));
|
|
1466
|
+
} else if (typeof params.index === 'number') {
|
|
1467
|
+
chosen = attachments[Math.max(0, params.index - 1)] || null;
|
|
1468
|
+
}
|
|
1469
|
+
if (!chosen) chosen = attachments[0];
|
|
1470
|
+
if (!chosen) return 'Could not resolve which attachment to read.';
|
|
1471
|
+
const { fetchAttachmentContent } = await import('./email-imap.mjs');
|
|
1472
|
+
let result;
|
|
1473
|
+
try {
|
|
1474
|
+
result = await fetchAttachmentContent(msg.account_id, msg.imap_folder_path, msg.uid, chosen.part_id);
|
|
1475
|
+
} catch (e) {
|
|
1476
|
+
return `Failed to fetch attachment "${chosen.filename}" from server: ${e.message}`;
|
|
1477
|
+
}
|
|
1478
|
+
if (!result?.buffer) return `Attachment "${chosen.filename}" returned no content.`;
|
|
1479
|
+
const buf = result.buffer;
|
|
1480
|
+
const ct = (chosen.content_type || result.contentType || '').toLowerCase();
|
|
1481
|
+
const head = `Attachment: ${chosen.filename}\nType: ${ct || 'unknown'}\nSize: ${Math.round(buf.length / 1024)} KB\n\n`;
|
|
1482
|
+
|
|
1483
|
+
// Text-ish — return up to 10k chars of UTF-8.
|
|
1484
|
+
if (/^text\/|application\/(json|xml|csv|x-yaml)/i.test(ct) || /\.(txt|csv|json|xml|log|md|html)$/i.test(chosen.filename || '')) {
|
|
1485
|
+
return head + buf.toString('utf8').slice(0, 10000);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// PDF — naïve text extraction from the raw stream. Catches text-based
|
|
1489
|
+
// PDFs (invoices, purchase orders, quotes generated by ERP software).
|
|
1490
|
+
// Doesn't handle scanned/OCR PDFs — for those the model gets a clear
|
|
1491
|
+
// "image-based PDF" hint so it can ask the user.
|
|
1492
|
+
if (/pdf/i.test(ct) || /\.pdf$/i.test(chosen.filename || '')) {
|
|
1493
|
+
const text = _naivePdfText(buf);
|
|
1494
|
+
if (text && text.length > 30) {
|
|
1495
|
+
return head + `--- Extracted text (best-effort, ${text.length} chars) ---\n${text.slice(0, 10000)}`;
|
|
1496
|
+
}
|
|
1497
|
+
return head + `PDF appears to be image-based or compressed (no extractable text found). ` +
|
|
1498
|
+
`Tell the user the PDF can't be auto-read — they can open it manually or share the relevant section as text.`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// DOCX — minimal text extraction from word/document.xml inside the zip.
|
|
1502
|
+
if (/wordprocessingml|msword/i.test(ct) || /\.docx?$/i.test(chosen.filename || '')) {
|
|
1503
|
+
const text = await _naiveDocxText(buf);
|
|
1504
|
+
if (text) return head + `--- Extracted text (${text.length} chars) ---\n${text.slice(0, 10000)}`;
|
|
1505
|
+
return head + 'Could not extract text from DOCX (possibly malformed or password-protected).';
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Unsupported — return metadata only.
|
|
1509
|
+
return head + `Tipo "${ct}" non supportato per lettura automatica. Allegato disponibile nella casella email; chiedi all'utente di condividere il contenuto rilevante come testo.`;
|
|
1329
1510
|
}
|
|
1330
1511
|
|
|
1331
1512
|
case 'imap_send': {
|