@iola_adm/iola-cli 0.2.24 → 0.2.25

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 +187 -21
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.25",
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);
@@ -4142,26 +4169,93 @@ function parseImapFetchSummaries(text, options = {}) {
4142
4169
  for (const chunk of chunks) {
4143
4170
  const uid = Number(chunk.match(/UID (\d+)/iu)?.[1] || 0);
4144
4171
  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] || "";
4172
+ const headers = parseMailHeaders(chunk);
4173
+ const subject = headers.subject || "";
4174
+ const from = headers.from || "";
4175
+ const date = headers.date || "";
4148
4176
  const body = options.full ? stripMailBody(chunk) : stripMailBody(chunk).slice(0, 800);
4149
4177
  rows.push({ uid, date, from, subject, snippet: body.replace(/\s+/g, " ").trim().slice(0, options.full ? 12000 : 500) });
4150
4178
  }
4151
4179
  return rows;
4152
4180
  }
4153
4181
 
4182
+ function parseMailHeaders(value) {
4183
+ const normalized = String(value || "").replace(/\r/g, "").replace(/\n BODY\[[\s\S]*$/u, "");
4184
+ const headerValue = (name) => {
4185
+ const match = normalized.match(new RegExp(`^${name}:\\s*([^\\n]*(?:\\n[\\t ][^\\n]*)*)`, "imu"));
4186
+ return match ? decodeMimeHeader(match[1].replace(/\n[\t ]+/g, " ").trim()) : "";
4187
+ };
4188
+ return {
4189
+ date: headerValue("Date"),
4190
+ from: headerValue("From"),
4191
+ subject: headerValue("Subject"),
4192
+ };
4193
+ }
4194
+
4154
4195
  function decodeMimeHeader(value) {
4155
- return String(value || "").replace(/=\?UTF-8\?B\?([^?]+)\?=/giu, (_, data) => Buffer.from(data, "base64").toString("utf8"));
4196
+ return String(value || "")
4197
+ .replace(/\?=\s+=\?/gu, "?==?")
4198
+ .replace(/=\?UTF-8\?B\?([^?]+)\?=/giu, (_, data) => Buffer.from(data, "base64").toString("utf8"))
4199
+ .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
4200
  }
4157
4201
 
4158
4202
  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();
4203
+ const raw = String(value || "").replace(/\r/g, "");
4204
+ const withoutFetch = raw
4205
+ .replace(/^\* \d+ FETCH[^\n]*\n?/u, "")
4206
+ .replace(/^BODY\[[^\n]*\]\s*\{\d+\}\n?/imu, "")
4207
+ .replace(/\n\)\s*$/u, "");
4208
+ const bodyMatch = withoutFetch.match(/BODY\[TEXT\]\s*\{\d+\}\s*([\s\S]*)/iu);
4209
+ if (bodyMatch?.[1]) return extractMimeText(bodyMatch[1]);
4210
+ const headerEnd = withoutFetch.indexOf("\n\n");
4211
+ if (headerEnd < 0) return withoutFetch.replace(/[^\S\n]+/g, " ").trim();
4212
+ const headers = withoutFetch.slice(0, headerEnd);
4213
+ const body = withoutFetch.slice(headerEnd + 2).replace(/^BODY\[[^\n]*\]\s*\{\d+\}\n?/imu, "");
4214
+ return extractMimeText(`${headers}\n\n${body}`);
4215
+ }
4216
+
4217
+ function extractMimeText(source, depth = 0) {
4218
+ if (depth > 5) return decodeMailPart(source);
4219
+ const boundary = String(source || "").match(/boundary="?([^"\n;]+)"?/iu)?.[1];
4220
+ if (!boundary || !source.includes(`--${boundary}`)) return decodeMailPart(source);
4221
+ const parts = source.slice(source.indexOf(`--${boundary}`)).split(`--${boundary}`).filter((part) => part.trim() && part.trim() !== "--");
4222
+ const plain = parts.find((part) => /^Content-Type:\s*text\/plain/im.test(part));
4223
+ if (plain) return decodeMailPart(plain);
4224
+ for (const part of parts) {
4225
+ if (/^Content-Type:\s*multipart\//im.test(part) || /boundary="?([^"\n;]+)"?/iu.test(part)) {
4226
+ const nested = extractMimeText(part, depth + 1);
4227
+ if (nested) return nested;
4228
+ }
4229
+ }
4230
+ const html = parts.find((part) => /^Content-Type:\s*text\/html/im.test(part));
4231
+ if (html) return decodeMailPart(html);
4232
+ return decodeMailPart(parts[0] || source);
4233
+ }
4234
+
4235
+ function decodeMailPart(part) {
4236
+ const text = String(part || "").replace(/\r/g, "");
4237
+ let headerEnd = text.indexOf("\n\n");
4238
+ let headers = headerEnd >= 0 ? text.slice(0, headerEnd) : "";
4239
+ let body = headerEnd >= 0 ? text.slice(headerEnd + 2) : text;
4240
+ if (headerEnd < 0) {
4241
+ const transferMatch = text.match(/^(.*Content-Transfer-Encoding:\s*(?:base64|quoted-printable)[^\n]*\n)([\s\S]*)$/imu);
4242
+ if (transferMatch) {
4243
+ headers = transferMatch[1];
4244
+ body = transferMatch[2];
4245
+ }
4246
+ }
4247
+ const encoding = headers.match(/^Content-Transfer-Encoding:\s*([^\n]+)/imu)?.[1]?.trim().toLocaleLowerCase("en-US") || "";
4248
+ let decoded = body;
4249
+ if (encoding === "base64") {
4250
+ decoded = Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8");
4251
+ } else if (encoding === "quoted-printable") {
4252
+ decoded = decodeQuotedPrintable(body);
4253
+ }
4254
+ return decoded.replace(/<[^>]+>/g, " ").replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
4255
+ }
4256
+
4257
+ function decodeQuotedPrintable(value) {
4258
+ return Buffer.from(String(value || "").replace(/=\n/gu, "").replace(/=([A-F0-9]{2})/giu, (_, hex) => String.fromCharCode(parseInt(hex, 16))), "binary").toString("utf8");
4165
4259
  }
4166
4260
 
4167
4261
  async function yandexDavRequest(url, token, options = {}) {
@@ -9352,7 +9446,7 @@ async function aiAsk(args, context = {}) {
9352
9446
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
9353
9447
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
9354
9448
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
9355
- const yandexAnswer = await buildYandexDirectAnswer(question);
9449
+ const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
9356
9450
  if (yandexAnswer) {
9357
9451
  if (historyEnabled) {
9358
9452
  recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
@@ -9480,10 +9574,19 @@ async function buildDirectDataAnswer(question, dataContext) {
9480
9574
  ].join("\n");
9481
9575
  }
9482
9576
 
9483
- async function buildYandexDirectAnswer(question) {
9577
+ async function buildYandexDirectAnswer(question, history = []) {
9484
9578
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
9485
- if (!/(яндекс|yandex|почт|письм|календар|контакт|телемост)/iu.test(normalized)) return "";
9579
+ const previousAssistantText = [...(history || [])].reverse().find((item) => item.role === "assistant")?.content || "";
9580
+ const mailContext = /Яндекс Почта|Письмо #|\bUID\b|#\d{3,}/iu.test(previousAssistantText);
9581
+ if (!/(яндекс|yandex|почт|письм|календар|контакт|телемост)/iu.test(normalized) && !(/^\s*\d{3,}\s*$/u.test(question) && mailContext)) return "";
9486
9582
  try {
9583
+ if (/^\s*\d{3,}\s*$/u.test(question) && mailContext) {
9584
+ const uid = extractYandexMailUid(question);
9585
+ const row = await yandexMailRead(uid);
9586
+ if (!row || row.status === "not-found") return `Письмо #${uid} не найдено.`;
9587
+ return formatYandexMailRead(row);
9588
+ }
9589
+
9487
9590
  if (/(аккаунт|профил|логин|кто подключен)/iu.test(normalized) && /(яндекс|yandex)/iu.test(normalized)) {
9488
9591
  const profile = await getYandexIdentityProfile();
9489
9592
  return [
@@ -9499,11 +9602,26 @@ async function buildYandexDirectAnswer(question) {
9499
9602
  const result = await yandexMailStatus();
9500
9603
  return `Яндекс Почта подключена: ${result.email}. Входящие: ${result.inbox?.exists ?? "-"}.`;
9501
9604
  }
9605
+ if (/(отправ|напиши|пошли)/iu.test(normalized)) {
9606
+ const draft = parseYandexMailSendRequest(question);
9607
+ if (!draft.to.length || !draft.text) {
9608
+ return "Для отправки письма укажите получателя и текст. Пример: отправь письмо user@example.com тема: Привет текст: Проверка.";
9609
+ }
9610
+ const result = await yandexMailSend({ ...draft, confirm: true });
9611
+ return `Письмо отправлено: ${result.to.join(", ")}. Тема: ${result.subject}.`;
9612
+ }
9613
+ if (isYandexMailReadRequest(normalized)) {
9614
+ const uid = extractYandexMailUid(question) || await getLatestYandexMailUid({ unread: /непрочитан/iu.test(normalized) });
9615
+ if (!uid) return "Писем для чтения не найдено.";
9616
+ const row = await yandexMailRead(uid);
9617
+ if (!row || row.status === "not-found") return `Письмо #${uid} не найдено.`;
9618
+ return formatYandexMailRead(row);
9619
+ }
9502
9620
  const rows = /(найди|поиск)/iu.test(normalized)
9503
9621
  ? await yandexMailSearch(cleanupYandexQuery(question), { limit: 10 })
9504
9622
  : await yandexMailList({ limit: 10, unread: /непрочитан/iu.test(normalized) });
9505
9623
  if (!rows.length) return "Писем по запросу не найдено.";
9506
- return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. #${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`)].join("\n");
9624
+ return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. ${formatYandexMailSummary(row)}`)].join("\n");
9507
9625
  }
9508
9626
 
9509
9627
  if (/(календар|событи)/iu.test(normalized) && !/(создай|добавь|запланируй)/iu.test(normalized)) {
@@ -9540,6 +9658,54 @@ function cleanupYandexQuery(question) {
9540
9658
  .trim();
9541
9659
  }
9542
9660
 
9661
+ function extractYandexMailUid(question) {
9662
+ const text = String(question || "");
9663
+ const explicit = text.match(/(?:#|uid\s*|письм[оа]?\s*)?(\d{3,})/iu)?.[1];
9664
+ return explicit ? Number(explicit) : 0;
9665
+ }
9666
+
9667
+ function isYandexMailReadRequest(normalizedQuestion) {
9668
+ return /(^|\s)(прочитай|прочти|открой|раскрой)(\s|$)/iu.test(normalizedQuestion)
9669
+ || /(покажи\s+содерж|о чем|о чём)/iu.test(normalizedQuestion);
9670
+ }
9671
+
9672
+ async function getLatestYandexMailUid(options = {}) {
9673
+ const rows = await yandexMailList({ limit: 1, unread: Boolean(options.unread) });
9674
+ return rows[0]?.uid || 0;
9675
+ }
9676
+
9677
+ function parseYandexMailSendRequest(question) {
9678
+ const text = String(question || "").replace(/\s+/g, " ").trim();
9679
+ const emails = [...text.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu)].map((match) => match[0]);
9680
+ const subjectMatch = text.match(/(?:тема|subject)\s*:\s*(.*?)(?=\s+(?:текст|body|сообщение)\s*:|$)/iu);
9681
+ const bodyMatch = text.match(/(?:текст|body|сообщение)\s*:\s*(.*)$/iu);
9682
+ const withoutCommand = text
9683
+ .replace(/^(?:отправь|отправить|напиши|пошли)\s+(?:письмо\s+)?/iu, "")
9684
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, "")
9685
+ .trim();
9686
+ return {
9687
+ to: emails,
9688
+ subject: (subjectMatch?.[1] || "Сообщение от IOLA CLI").trim(),
9689
+ text: (bodyMatch?.[1] || (!subjectMatch ? withoutCommand : "")).trim(),
9690
+ };
9691
+ }
9692
+
9693
+ function formatYandexMailSummary(row) {
9694
+ return `#${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}${row.date ? `, ${row.date}` : ""}`;
9695
+ }
9696
+
9697
+ function formatYandexMailRead(row) {
9698
+ const body = String(row.snippet || "").trim();
9699
+ return [
9700
+ `Письмо #${row.uid}`,
9701
+ `От: ${row.from || "-"}`,
9702
+ `Тема: ${row.subject || "(без темы)"}`,
9703
+ row.date ? `Дата: ${row.date}` : "",
9704
+ "",
9705
+ body ? `Текст: ${body.slice(0, 2000)}` : "Текст письма пустой или не распознан.",
9706
+ ].filter((line) => line !== "").join("\n");
9707
+ }
9708
+
9543
9709
  async function buildCloudDirectAnswer(question) {
9544
9710
  if (!isCloudQuestion(question)) return "";
9545
9711
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
@@ -9936,7 +10102,7 @@ async function localToolAsk(question, providerConfig, options) {
9936
10102
  if (!options.quiet) console.log(casualAnswer);
9937
10103
  return casualAnswer;
9938
10104
  }
9939
- const yandexAnswer = await buildYandexDirectAnswer(question);
10105
+ const yandexAnswer = await buildYandexDirectAnswer(question, []);
9940
10106
  if (yandexAnswer) {
9941
10107
  if (!options.quiet) console.log(yandexAnswer);
9942
10108
  return yandexAnswer;