@iola_adm/iola-cli 0.2.24 → 0.2.26

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +198 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -1606,8 +1606,7 @@ function getSlashVisibleLimit() {
1606
1606
  function renderAgentInput(state) {
1607
1607
  clearAgentInputArea(state);
1608
1608
  const prompt = "> ";
1609
- const lines = state.buffer.split("\n");
1610
- const inputLines = [`${prompt}${lines[0] || ""}`, ...lines.slice(1)];
1609
+ const inputLines = buildAgentInputDisplayLines(state.buffer, prompt);
1611
1610
  const menuLines = [];
1612
1611
  if (state.slashOpen) {
1613
1612
  const matches = currentSlashMatches(state);
@@ -1633,13 +1632,41 @@ function renderAgentInput(state) {
1633
1632
  const renderedLines = [...menuLines, ...inputLines];
1634
1633
  output.write(renderedLines.join("\n"));
1635
1634
  if (output.isTTY) {
1636
- const cursorColumn = visibleLength(inputLines[inputLines.length - 1]);
1635
+ const cursorColumn = Math.min(visibleLength(inputLines[inputLines.length - 1]), Math.max(1, Number(output.columns || 100)) - 1);
1637
1636
  output.write(`\x1b[${cursorColumn + 1}G`);
1638
1637
  }
1639
1638
  state.renderedInputLines = inputLines.length;
1640
1639
  state.renderedLines = renderedLines.length;
1641
1640
  }
1642
1641
 
1642
+ function buildAgentInputDisplayLines(buffer, prompt = "> ") {
1643
+ const columns = Math.max(20, Number(output.columns || 100));
1644
+ const logicalLines = String(buffer || "").split("\n");
1645
+ const result = [];
1646
+ for (let index = 0; index < logicalLines.length; index += 1) {
1647
+ const prefix = index === 0 ? prompt : "";
1648
+ const width = Math.max(1, columns - visibleLength(prefix));
1649
+ const chunks = wrapTerminalText(logicalLines[index] || "", width);
1650
+ if (chunks.length === 0) {
1651
+ result.push(prefix);
1652
+ continue;
1653
+ }
1654
+ result.push(`${prefix}${chunks[0]}`);
1655
+ for (const chunk of chunks.slice(1)) result.push(chunk);
1656
+ }
1657
+ return result.length ? result : [prompt];
1658
+ }
1659
+
1660
+ function wrapTerminalText(value, width) {
1661
+ const chars = [...String(value || "")];
1662
+ if (!chars.length) return [];
1663
+ const rows = [];
1664
+ for (let index = 0; index < chars.length; index += width) {
1665
+ rows.push(chars.slice(index, index + width).join(""));
1666
+ }
1667
+ return rows;
1668
+ }
1669
+
1643
1670
  function clearAgentInputArea(state = null) {
1644
1671
  if (!output.isTTY) return;
1645
1672
  const renderedLines = Math.max(1, Number(state?.renderedLines || state?.renderedInputLines || 1));
@@ -3968,8 +3995,8 @@ async function yandexMailList(options = {}) {
3968
3995
  const search = await imapCommand(session, `UID SEARCH ${criterion}`);
3969
3996
  const uids = parseImapSearchUids(search).slice(-Number(options.limit || 10));
3970
3997
  if (!uids.length) return [];
3971
- const fetch = await imapCommand(session, `UID FETCH ${uids.join(",")} (UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[TEXT]<0.800>)`, { timeout: 45000 });
3972
- return parseImapFetchSummaries(fetch);
3998
+ const fetch = await imapCommand(session, `UID FETCH ${uids.join(",")} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)] BODY.PEEK[TEXT]<0.800>)`, { timeout: 45000 });
3999
+ return parseImapFetchSummaries(fetch).sort((left, right) => Number(right.uid || 0) - Number(left.uid || 0));
3973
4000
  } finally {
3974
4001
  await imapClose(session);
3975
4002
  }
@@ -3989,7 +4016,7 @@ async function yandexMailRead(uid, options = {}) {
3989
4016
  try {
3990
4017
  await imapAuthenticate(session, email, token);
3991
4018
  await imapCommand(session, `SELECT ${quoteImapMailbox(options.mailbox || "INBOX")}`);
3992
- const fetch = await imapCommand(session, `UID FETCH ${Number(uid)} (UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[])`, { timeout: 60000 });
4019
+ const fetch = await imapCommand(session, `UID FETCH ${Number(uid)} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)] BODY.PEEK[TEXT])`, { timeout: 60000 });
3993
4020
  return parseImapFetchSummaries(fetch, { full: true })[0] || { uid, status: "not-found" };
3994
4021
  } finally {
3995
4022
  await imapClose(session);
@@ -4011,7 +4038,7 @@ async function yandexMailSend(args = {}) {
4011
4038
  await smtpCommand(session, `MAIL FROM:<${email}>`);
4012
4039
  for (const recipient of to) await smtpCommand(session, `RCPT TO:<${recipient}>`);
4013
4040
  await smtpCommand(session, "DATA", { expect: /^354/u });
4014
- await smtpCommand(session, `${buildMimeMessage({ from: email, to, subject, text })}\r\n.`);
4041
+ await smtpCommand(session, `${dotStuffSmtpData(buildMimeMessage({ from: email, to, subject, text }))}\r\n.`);
4015
4042
  await smtpCommand(session, "QUIT").catch(() => {});
4016
4043
  return { from: email, to, subject, status: "sent" };
4017
4044
  } finally {
@@ -4025,18 +4052,28 @@ function buildXoauth2(email, token) {
4025
4052
 
4026
4053
  function buildMimeMessage({ from, to, subject, text }) {
4027
4054
  const encodedSubject = Buffer.from(subject, "utf8").toString("base64");
4055
+ const messageIdDomain = String(from || "").split("@")[1] || "localhost";
4056
+ const messageId = `${randomUUID()}@${messageIdDomain}`;
4028
4057
  return [
4029
4058
  `From: ${from}`,
4030
4059
  `To: ${to.join(", ")}`,
4060
+ `Reply-To: ${from}`,
4031
4061
  `Subject: =?UTF-8?B?${encodedSubject}?=`,
4062
+ `Date: ${new Date().toUTCString()}`,
4063
+ `Message-ID: <${messageId}>`,
4032
4064
  "MIME-Version: 1.0",
4033
4065
  "Content-Type: text/plain; charset=utf-8",
4034
4066
  "Content-Transfer-Encoding: base64",
4067
+ "X-Mailer: IOLA CLI",
4035
4068
  "",
4036
4069
  Buffer.from(text.replace(/\r?\n/g, "\r\n"), "utf8").toString("base64").replace(/.{1,76}/g, "$&\r\n").trim(),
4037
4070
  ].join("\r\n");
4038
4071
  }
4039
4072
 
4073
+ function dotStuffSmtpData(message) {
4074
+ return String(message || "").replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1..");
4075
+ }
4076
+
4040
4077
  function imapConnect() {
4041
4078
  return tlsLineSession("imap.yandex.ru", 993, "* OK");
4042
4079
  }
@@ -4142,26 +4179,93 @@ function parseImapFetchSummaries(text, options = {}) {
4142
4179
  for (const chunk of chunks) {
4143
4180
  const uid = Number(chunk.match(/UID (\d+)/iu)?.[1] || 0);
4144
4181
  if (!uid) continue;
4145
- const subject = decodeMimeHeader(chunk.match(/ENVELOPE \([^\n]*?"([^"]*)"/iu)?.[1] || chunk.match(/Subject:\s*([^\r\n]+)/iu)?.[1] || "");
4146
- const from = decodeMimeHeader(chunk.match(/From:\s*([^\r\n]+)/iu)?.[1] || chunk.match(/NIL NIL "([^"]*)" "([^"]*)"/iu)?.slice(1, 3).filter(Boolean).join("@") || "");
4147
- const date = chunk.match(/INTERNALDATE "([^"]+)"/iu)?.[1] || chunk.match(/Date:\s*([^\r\n]+)/iu)?.[1] || "";
4182
+ const headers = parseMailHeaders(chunk);
4183
+ const subject = headers.subject || "";
4184
+ const from = headers.from || "";
4185
+ const date = headers.date || "";
4148
4186
  const body = options.full ? stripMailBody(chunk) : stripMailBody(chunk).slice(0, 800);
4149
4187
  rows.push({ uid, date, from, subject, snippet: body.replace(/\s+/g, " ").trim().slice(0, options.full ? 12000 : 500) });
4150
4188
  }
4151
4189
  return rows;
4152
4190
  }
4153
4191
 
4192
+ function parseMailHeaders(value) {
4193
+ const normalized = String(value || "").replace(/\r/g, "").replace(/\n BODY\[[\s\S]*$/u, "");
4194
+ const headerValue = (name) => {
4195
+ const match = normalized.match(new RegExp(`^${name}:\\s*([^\\n]*(?:\\n[\\t ][^\\n]*)*)`, "imu"));
4196
+ return match ? decodeMimeHeader(match[1].replace(/\n[\t ]+/g, " ").trim()) : "";
4197
+ };
4198
+ return {
4199
+ date: headerValue("Date"),
4200
+ from: headerValue("From"),
4201
+ subject: headerValue("Subject"),
4202
+ };
4203
+ }
4204
+
4154
4205
  function decodeMimeHeader(value) {
4155
- return String(value || "").replace(/=\?UTF-8\?B\?([^?]+)\?=/giu, (_, data) => Buffer.from(data, "base64").toString("utf8"));
4206
+ return String(value || "")
4207
+ .replace(/\?=\s+=\?/gu, "?==?")
4208
+ .replace(/=\?UTF-8\?B\?([^?]+)\?=/giu, (_, data) => Buffer.from(data, "base64").toString("utf8"))
4209
+ .replace(/=\?UTF-8\?Q\?([^?]+)\?=/giu, (_, data) => Buffer.from(data.replace(/_/g, " ").replace(/=([A-F0-9]{2})/giu, (_, hex) => String.fromCharCode(parseInt(hex, 16))), "binary").toString("utf8"));
4156
4210
  }
4157
4211
 
4158
4212
  function stripMailBody(value) {
4159
- return String(value || "")
4160
- .replace(/\r/g, "")
4161
- .split(/\n\)/u)[0]
4162
- .replace(/^[\s\S]*?\n\n/u, "")
4163
- .replace(/[^\S\n]+/g, " ")
4164
- .trim();
4213
+ const raw = String(value || "").replace(/\r/g, "");
4214
+ const withoutFetch = raw
4215
+ .replace(/^\* \d+ FETCH[^\n]*\n?/u, "")
4216
+ .replace(/^BODY\[[^\n]*\]\s*\{\d+\}\n?/imu, "")
4217
+ .replace(/\n\)\s*$/u, "");
4218
+ const bodyMatch = withoutFetch.match(/BODY\[TEXT\]\s*\{\d+\}\s*([\s\S]*)/iu);
4219
+ if (bodyMatch?.[1]) return extractMimeText(bodyMatch[1]);
4220
+ const headerEnd = withoutFetch.indexOf("\n\n");
4221
+ if (headerEnd < 0) return withoutFetch.replace(/[^\S\n]+/g, " ").trim();
4222
+ const headers = withoutFetch.slice(0, headerEnd);
4223
+ const body = withoutFetch.slice(headerEnd + 2).replace(/^BODY\[[^\n]*\]\s*\{\d+\}\n?/imu, "");
4224
+ return extractMimeText(`${headers}\n\n${body}`);
4225
+ }
4226
+
4227
+ function extractMimeText(source, depth = 0) {
4228
+ if (depth > 5) return decodeMailPart(source);
4229
+ const boundary = String(source || "").match(/boundary="?([^"\n;]+)"?/iu)?.[1];
4230
+ if (!boundary || !source.includes(`--${boundary}`)) return decodeMailPart(source);
4231
+ const parts = source.slice(source.indexOf(`--${boundary}`)).split(`--${boundary}`).filter((part) => part.trim() && part.trim() !== "--");
4232
+ const plain = parts.find((part) => /^Content-Type:\s*text\/plain/im.test(part));
4233
+ if (plain) return decodeMailPart(plain);
4234
+ for (const part of parts) {
4235
+ if (/^Content-Type:\s*multipart\//im.test(part) || /boundary="?([^"\n;]+)"?/iu.test(part)) {
4236
+ const nested = extractMimeText(part, depth + 1);
4237
+ if (nested) return nested;
4238
+ }
4239
+ }
4240
+ const html = parts.find((part) => /^Content-Type:\s*text\/html/im.test(part));
4241
+ if (html) return decodeMailPart(html);
4242
+ return decodeMailPart(parts[0] || source);
4243
+ }
4244
+
4245
+ function decodeMailPart(part) {
4246
+ const text = String(part || "").replace(/\r/g, "");
4247
+ let headerEnd = text.indexOf("\n\n");
4248
+ let headers = headerEnd >= 0 ? text.slice(0, headerEnd) : "";
4249
+ let body = headerEnd >= 0 ? text.slice(headerEnd + 2) : text;
4250
+ if (headerEnd < 0) {
4251
+ const transferMatch = text.match(/^(.*Content-Transfer-Encoding:\s*(?:base64|quoted-printable)[^\n]*\n)([\s\S]*)$/imu);
4252
+ if (transferMatch) {
4253
+ headers = transferMatch[1];
4254
+ body = transferMatch[2];
4255
+ }
4256
+ }
4257
+ const encoding = headers.match(/^Content-Transfer-Encoding:\s*([^\n]+)/imu)?.[1]?.trim().toLocaleLowerCase("en-US") || "";
4258
+ let decoded = body;
4259
+ if (encoding === "base64") {
4260
+ decoded = Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8");
4261
+ } else if (encoding === "quoted-printable") {
4262
+ decoded = decodeQuotedPrintable(body);
4263
+ }
4264
+ return decoded.replace(/<[^>]+>/g, " ").replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
4265
+ }
4266
+
4267
+ function decodeQuotedPrintable(value) {
4268
+ return Buffer.from(String(value || "").replace(/=\n/gu, "").replace(/=([A-F0-9]{2})/giu, (_, hex) => String.fromCharCode(parseInt(hex, 16))), "binary").toString("utf8");
4165
4269
  }
4166
4270
 
4167
4271
  async function yandexDavRequest(url, token, options = {}) {
@@ -9352,7 +9456,7 @@ async function aiAsk(args, context = {}) {
9352
9456
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
9353
9457
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
9354
9458
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
9355
- const yandexAnswer = await buildYandexDirectAnswer(question);
9459
+ const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
9356
9460
  if (yandexAnswer) {
9357
9461
  if (historyEnabled) {
9358
9462
  recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
@@ -9480,10 +9584,19 @@ async function buildDirectDataAnswer(question, dataContext) {
9480
9584
  ].join("\n");
9481
9585
  }
9482
9586
 
9483
- async function buildYandexDirectAnswer(question) {
9587
+ async function buildYandexDirectAnswer(question, history = []) {
9484
9588
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
9485
- if (!/(яндекс|yandex|почт|письм|календар|контакт|телемост)/iu.test(normalized)) return "";
9589
+ const previousAssistantText = [...(history || [])].reverse().find((item) => item.role === "assistant")?.content || "";
9590
+ const mailContext = /Яндекс Почта|Письмо #|\bUID\b|#\d{3,}/iu.test(previousAssistantText);
9591
+ if (!/(яндекс|yandex|почт|письм|календар|контакт|телемост)/iu.test(normalized) && !(/^\s*\d{3,}\s*$/u.test(question) && mailContext)) return "";
9486
9592
  try {
9593
+ if (/^\s*\d{3,}\s*$/u.test(question) && mailContext) {
9594
+ const uid = extractYandexMailUid(question);
9595
+ const row = await yandexMailRead(uid);
9596
+ if (!row || row.status === "not-found") return `Письмо #${uid} не найдено.`;
9597
+ return formatYandexMailRead(row);
9598
+ }
9599
+
9487
9600
  if (/(аккаунт|профил|логин|кто подключен)/iu.test(normalized) && /(яндекс|yandex)/iu.test(normalized)) {
9488
9601
  const profile = await getYandexIdentityProfile();
9489
9602
  return [
@@ -9499,11 +9612,26 @@ async function buildYandexDirectAnswer(question) {
9499
9612
  const result = await yandexMailStatus();
9500
9613
  return `Яндекс Почта подключена: ${result.email}. Входящие: ${result.inbox?.exists ?? "-"}.`;
9501
9614
  }
9615
+ if (/(отправ|напиши|пошли)/iu.test(normalized)) {
9616
+ const draft = parseYandexMailSendRequest(question);
9617
+ if (!draft.to.length || !draft.text) {
9618
+ return "Для отправки письма укажите получателя и текст. Пример: отправь письмо user@example.com тема: Привет текст: Проверка.";
9619
+ }
9620
+ const result = await yandexMailSend({ ...draft, confirm: true });
9621
+ return `Письмо отправлено: ${result.to.join(", ")}. Тема: ${result.subject}.`;
9622
+ }
9623
+ if (isYandexMailReadRequest(normalized)) {
9624
+ const uid = extractYandexMailUid(question) || await getLatestYandexMailUid({ unread: /непрочитан/iu.test(normalized) });
9625
+ if (!uid) return "Писем для чтения не найдено.";
9626
+ const row = await yandexMailRead(uid);
9627
+ if (!row || row.status === "not-found") return `Письмо #${uid} не найдено.`;
9628
+ return formatYandexMailRead(row);
9629
+ }
9502
9630
  const rows = /(найди|поиск)/iu.test(normalized)
9503
9631
  ? await yandexMailSearch(cleanupYandexQuery(question), { limit: 10 })
9504
9632
  : await yandexMailList({ limit: 10, unread: /непрочитан/iu.test(normalized) });
9505
9633
  if (!rows.length) return "Писем по запросу не найдено.";
9506
- return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. #${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`)].join("\n");
9634
+ return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. ${formatYandexMailSummary(row)}`)].join("\n");
9507
9635
  }
9508
9636
 
9509
9637
  if (/(календар|событи)/iu.test(normalized) && !/(создай|добавь|запланируй)/iu.test(normalized)) {
@@ -9540,6 +9668,54 @@ function cleanupYandexQuery(question) {
9540
9668
  .trim();
9541
9669
  }
9542
9670
 
9671
+ function extractYandexMailUid(question) {
9672
+ const text = String(question || "");
9673
+ const explicit = text.match(/(?:#|uid\s*|письм[оа]?\s*)?(\d{3,})/iu)?.[1];
9674
+ return explicit ? Number(explicit) : 0;
9675
+ }
9676
+
9677
+ function isYandexMailReadRequest(normalizedQuestion) {
9678
+ return /(^|\s)(прочитай|прочти|открой|раскрой)(\s|$)/iu.test(normalizedQuestion)
9679
+ || /(покажи\s+содерж|о чем|о чём)/iu.test(normalizedQuestion);
9680
+ }
9681
+
9682
+ async function getLatestYandexMailUid(options = {}) {
9683
+ const rows = await yandexMailList({ limit: 1, unread: Boolean(options.unread) });
9684
+ return rows[0]?.uid || 0;
9685
+ }
9686
+
9687
+ function parseYandexMailSendRequest(question) {
9688
+ const text = String(question || "").replace(/\s+/g, " ").trim();
9689
+ const emails = [...text.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu)].map((match) => match[0]);
9690
+ const subjectMatch = text.match(/(?:тема|subject)\s*:\s*(.*?)(?=\s+(?:текст|body|сообщение)\s*:|$)/iu);
9691
+ const bodyMatch = text.match(/(?:текст|body|сообщение)\s*:\s*(.*)$/iu);
9692
+ const withoutCommand = text
9693
+ .replace(/^(?:отправь|отправить|напиши|пошли)\s+(?:письмо\s+)?/iu, "")
9694
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, "")
9695
+ .trim();
9696
+ return {
9697
+ to: emails,
9698
+ subject: (subjectMatch?.[1] || "Сообщение от IOLA CLI").trim(),
9699
+ text: (bodyMatch?.[1] || (!subjectMatch ? withoutCommand : "")).trim(),
9700
+ };
9701
+ }
9702
+
9703
+ function formatYandexMailSummary(row) {
9704
+ return `#${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}${row.date ? `, ${row.date}` : ""}`;
9705
+ }
9706
+
9707
+ function formatYandexMailRead(row) {
9708
+ const body = String(row.snippet || "").trim();
9709
+ return [
9710
+ `Письмо #${row.uid}`,
9711
+ `От: ${row.from || "-"}`,
9712
+ `Тема: ${row.subject || "(без темы)"}`,
9713
+ row.date ? `Дата: ${row.date}` : "",
9714
+ "",
9715
+ body ? `Текст: ${body.slice(0, 2000)}` : "Текст письма пустой или не распознан.",
9716
+ ].filter((line) => line !== "").join("\n");
9717
+ }
9718
+
9543
9719
  async function buildCloudDirectAnswer(question) {
9544
9720
  if (!isCloudQuestion(question)) return "";
9545
9721
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
@@ -9936,7 +10112,7 @@ async function localToolAsk(question, providerConfig, options) {
9936
10112
  if (!options.quiet) console.log(casualAnswer);
9937
10113
  return casualAnswer;
9938
10114
  }
9939
- const yandexAnswer = await buildYandexDirectAnswer(question);
10115
+ const yandexAnswer = await buildYandexDirectAnswer(question, []);
9940
10116
  if (yandexAnswer) {
9941
10117
  if (!options.quiet) console.log(yandexAnswer);
9942
10118
  return yandexAnswer;