@iola_adm/iola-cli 0.2.20 → 0.2.22

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
@@ -199,7 +199,9 @@ iola yandex status
199
199
 
200
200
  Yandex Connector использует две встроенные OAuth-группы: `IOLA CLI A` для Yandex ID, Диска, Почты и документов через Диск; `IOLA CLI B` для Календаря, Контактов и Телемоста через календарь. Такси, Маркет и Доставка записаны в backlog только как сценарии подготовки ссылки/маршрута/списка без заказа и оплаты.
201
201
 
202
- В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. Там же есть пункт `Удалить подключение-коннектор`, который чистит локальные токены и настройки Yandex Connector. Мастер считает коннектор готовым только после токенов обеих групп. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
202
+ В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. Там же есть пункт `Удалить подключение-коннектор`, который чистит локальные токены и настройки Yandex Connector. Мастер считает коннектор готовым только после токенов обеих групп.
203
+
204
+ Первый набор Yandex tools уже доступен: профиль Yandex ID, список/поиск/папки/ссылки Яндекс Диска, статус/список/поиск/чтение/отправка Яндекс Почты, события Календаря, Контакты и подготовка встречи через календарь. Отправка письма, удаление файлов, публикация ссылок и создание событий требуют явного подтверждения.
203
205
 
204
206
  Инструкция: [Yandex Connector](https://github.com/adm-iola/iola-cli/wiki/Yandex-Connector).
205
207
 
@@ -252,8 +254,8 @@ iola version --check
252
254
  - поиск и выгрузка открытых данных;
253
255
  - локальная SQLite-БД, история, сессии и FTS-поиск;
254
256
  - AI-профили для IOLA local, Ollama, YandexGPT, GigaChat, OpenAI, OpenRouter и Codex CLI;
255
- - Yandex Connector: единая точка подключения пользовательских сервисов Яндекса с локальным хранением OAuth-токена;
256
- - локальный tool-agent для модели IOLA с tools `search_data`, `search_entities`, `resolve_entity_field`, `get_card`, `export_report`, `file_read`, `browser_open`;
257
+ - Yandex Connector: единая точка подключения пользовательских сервисов Яндекса с локальным хранением OAuth-токенов;
258
+ - локальный tool-agent для модели IOLA с tools открытых данных, файлов, браузера и сервисов Яндекса;
257
259
  - ленивые skills, toolsets, permissions, memory, hooks и готовые agents;
258
260
  - личные облачные диски: Яндекс Диск и Облако Mail.ru для сохранения отчетов, backup и документов;
259
261
  - subagents, skill bundles, layered settings, usage/budget accounting и trajectory export;
@@ -35,15 +35,15 @@ const canAnimate = process.stdout.isTTY && process.env.CI !== "true";
35
35
  const setupStarted = process.hrtime.bigint();
36
36
 
37
37
  console.log("");
38
- console.log("IOLA CLI: настройка после установки npm-пакета");
39
- console.log("Время ниже считает только настройку CLI, без скачивания и распаковки npm-пакета.");
38
+ console.log("IOLA CLI: настройка после скачивания npm-пакета");
39
+ console.log("Важно: это не полное время npm install. Скачивание, распаковку и служебные действия npm этот скрипт измерить не может.");
40
40
 
41
41
  for (let index = 0; index < steps.length; index += 1) {
42
42
  const step = steps[index];
43
43
  await runStep(step, index + 1, steps.length);
44
44
  }
45
45
 
46
- console.log(`IOLA CLI готова за ${formatDuration(elapsedMs(setupStarted))}. Запуск: iola`);
46
+ console.log(`Настройка CLI после скачивания заняла ${formatDuration(elapsedMs(setupStarted))}. Запуск: iola`);
47
47
 
48
48
  async function runStep(step, current, total) {
49
49
  const started = process.hrtime.bigint();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: yandex-services
3
+ description: Сервисы Яндекса через Yandex Connector: ID, Диск, Почта, Календарь, Контакты и Телемост.
4
+ ---
5
+
6
+ Используй этот skill, когда пользователь явно просит работать с сервисами Яндекса: Яндекс Диск, почта, письма, календарь, встречи, контакты, Телемост или профиль Яндекса.
7
+
8
+ Не смешивай Яндекс Диск с локальными файлами на компьютере. Если пользователь говорит просто "файл" или "папка" без слов "Яндекс", "Диск", "облако", уточни: "Где выполнить действие: на компьютере или на Яндекс Диске?"
9
+
10
+ Доступные tools:
11
+
12
+ - `yandex_identity_me` - проверить подключенный аккаунт Яндекса.
13
+ - `yandex_disk_info` - посмотреть место на Яндекс Диске.
14
+ - `yandex_disk_ls` - показать папку на Яндекс Диске.
15
+ - `yandex_disk_mkdir` - создать папку на Яндекс Диске.
16
+ - `yandex_disk_find` - найти файл или папку на Яндекс Диске.
17
+ - `yandex_disk_save_text` - сохранить текстовый результат в файл на Яндекс Диске.
18
+ - `yandex_disk_upload` - загрузить локальный файл на Яндекс Диск.
19
+ - `yandex_disk_download` - скачать файл с Яндекс Диска.
20
+ - `yandex_disk_share` - сделать публичную ссылку.
21
+ - `yandex_disk_unshare` - снять публичную ссылку.
22
+ - `yandex_disk_delete` - удалить файл или папку.
23
+ - `yandex_mail_status` - проверить доступ к Яндекс Почте.
24
+ - `yandex_mail_list` - показать последние письма.
25
+ - `yandex_mail_search` - найти письма.
26
+ - `yandex_mail_read` - прочитать письмо по UID.
27
+ - `yandex_mail_send` - отправить письмо.
28
+ - `yandex_calendar_status` - проверить доступ к Яндекс Календарю.
29
+ - `yandex_calendar_list` - показать события.
30
+ - `yandex_calendar_create_event` - создать событие.
31
+ - `yandex_contacts_status` - проверить доступ к Яндекс Контактам.
32
+ - `yandex_contacts_list` - показать контакты.
33
+ - `yandex_contacts_search` - найти контакт.
34
+ - `yandex_telemost_create_event` - создать календарное событие для встречи.
35
+
36
+ Безопасность:
37
+
38
+ - Для отправки письма, удаления файлов, публикации ссылки и создания событий нужен явный запрос пользователя и `confirm=true`.
39
+ - Не отправляй письма, не удаляй файлы и не публикуй ссылки по косвенному намерению.
40
+ - Не выводи OAuth-токены, API-ключи, пароли и секреты.
41
+ - Если сервис не подключен, скажи запустить `iola yandex setup` или открыть `/master`.
package/src/cli.js CHANGED
@@ -151,7 +151,32 @@ const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
151
151
  const LOCAL_TOOLS = ["search_data", "search_entities", "resolve_entity_field", "get_card", "export_report", "file_read", "browser_open", "get_current_date"];
152
152
  const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
153
153
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
154
- const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
154
+ const YANDEX_TOOLS = [
155
+ "yandex_identity_me",
156
+ "yandex_disk_info",
157
+ "yandex_disk_ls",
158
+ "yandex_disk_mkdir",
159
+ "yandex_disk_find",
160
+ "yandex_disk_save_text",
161
+ "yandex_disk_upload",
162
+ "yandex_disk_download",
163
+ "yandex_disk_share",
164
+ "yandex_disk_unshare",
165
+ "yandex_disk_delete",
166
+ "yandex_mail_status",
167
+ "yandex_mail_list",
168
+ "yandex_mail_search",
169
+ "yandex_mail_read",
170
+ "yandex_mail_send",
171
+ "yandex_calendar_status",
172
+ "yandex_calendar_create_event",
173
+ "yandex_calendar_list",
174
+ "yandex_contacts_status",
175
+ "yandex_contacts_list",
176
+ "yandex_contacts_search",
177
+ "yandex_telemost_create_event",
178
+ ];
179
+ const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS, ...YANDEX_TOOLS];
155
180
  const ALL_TOOL_ALIASES = [...ALL_LOCAL_TOOLS, ...LEGACY_LOCAL_TOOLS];
156
181
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
157
182
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
@@ -176,6 +201,13 @@ const TOOLSETS = {
176
201
  description: "Внешние AI-провайдеры и Codex CLI.",
177
202
  permissions: { externalAi: true, codex: true },
178
203
  },
204
+ yandex: {
205
+ description: "Сервисы Яндекса через Yandex Connector: ID, Диск, Почта, Календарь, Контакты.",
206
+ permissions: {
207
+ externalApi: true,
208
+ localTools: Object.fromEntries(YANDEX_TOOLS.map((tool) => [tool, true])),
209
+ },
210
+ },
179
211
  "local-files-read": {
180
212
  description: "Чтение файлов, дерево папок и поиск внутри workspace.",
181
213
  permissions: { readFiles: true, localTools: { files_tree: true, files_read: true, files_search: true } },
@@ -306,6 +338,29 @@ const DEFAULT_AI_CONFIG = {
306
338
  export_report: true,
307
339
  file_read: false,
308
340
  browser_open: true,
341
+ yandex_identity_me: true,
342
+ yandex_disk_info: true,
343
+ yandex_disk_ls: true,
344
+ yandex_disk_mkdir: true,
345
+ yandex_disk_find: true,
346
+ yandex_disk_save_text: true,
347
+ yandex_disk_upload: false,
348
+ yandex_disk_download: false,
349
+ yandex_disk_share: false,
350
+ yandex_disk_unshare: false,
351
+ yandex_disk_delete: false,
352
+ yandex_mail_status: true,
353
+ yandex_mail_list: true,
354
+ yandex_mail_search: true,
355
+ yandex_mail_read: true,
356
+ yandex_mail_send: false,
357
+ yandex_calendar_status: true,
358
+ yandex_calendar_create_event: false,
359
+ yandex_calendar_list: true,
360
+ yandex_contacts_status: true,
361
+ yandex_contacts_list: true,
362
+ yandex_contacts_search: true,
363
+ yandex_telemost_create_event: false,
309
364
  files_tree: false,
310
365
  files_read: false,
311
366
  files_search: false,
@@ -322,7 +377,7 @@ const DEFAULT_AI_CONFIG = {
322
377
  codex: true,
323
378
  },
324
379
  toolsets: {
325
- enabled: ["data-read", "reports", "sync", "ai"],
380
+ enabled: ["data-read", "reports", "sync", "ai", "yandex"],
326
381
  },
327
382
  files: {
328
383
  mode: "locked",
@@ -336,7 +391,7 @@ const DEFAULT_AI_CONFIG = {
336
391
  suggestions: true,
337
392
  },
338
393
  skills: {
339
- enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent"],
394
+ enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent", "yandex-services"],
340
395
  },
341
396
  cloud: {
342
397
  activeProvider: "",
@@ -3795,6 +3850,563 @@ async function yandexUserInfo(token) {
3795
3850
  return response.json();
3796
3851
  }
3797
3852
 
3853
+ async function getYandexOAuthToken(appId = "core") {
3854
+ if (process.env.YANDEX_OAUTH_TOKEN) return process.env.YANDEX_OAUTH_TOKEN;
3855
+ const secrets = await loadSecrets();
3856
+ return secrets.yandex?.oauthApps?.[appId]?.token
3857
+ || (appId === "core" ? secrets.yandex?.oauthToken || secrets.cloud?.["yandex-disk"]?.token : "")
3858
+ || "";
3859
+ }
3860
+
3861
+ async function requireYandexOAuthToken(appId = "core", service = "Yandex Connector") {
3862
+ const token = await getYandexOAuthToken(appId);
3863
+ if (!token) throw new Error(`${service} не подключен. Запустите: iola yandex setup`);
3864
+ return token;
3865
+ }
3866
+
3867
+ async function getYandexIdentityProfile() {
3868
+ const token = await requireYandexOAuthToken("core", "Yandex ID");
3869
+ const profile = await yandexUserInfo(token);
3870
+ return {
3871
+ login: profile.login || "",
3872
+ displayName: profile.display_name || profile.real_name || "",
3873
+ defaultEmail: profile.default_email || profile.default_email_alias || "",
3874
+ emails: profile.emails || [],
3875
+ };
3876
+ }
3877
+
3878
+ async function yandexDiskInfo() {
3879
+ const payload = await yandexDiskRequest("GET", "/", { query: { fields: "total_space,used_space,trash_size,system_folders" } });
3880
+ return {
3881
+ provider: "yandex-disk",
3882
+ totalSpace: payload.total_space || 0,
3883
+ usedSpace: payload.used_space || 0,
3884
+ trashSize: payload.trash_size || 0,
3885
+ };
3886
+ }
3887
+
3888
+ async function yandexDiskSaveText(text, remotePath) {
3889
+ if (!text) throw new Error("Текст для сохранения пустой.");
3890
+ const target = remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/notes/iola-${timestampForFile()}.txt`;
3891
+ const tempPath = path.join(CONFIG_DIR, `yandex-save-${Date.now()}.txt`);
3892
+ await mkdir(CONFIG_DIR, { recursive: true });
3893
+ await writeFile(tempPath, text, "utf8");
3894
+ try {
3895
+ return await yandexDiskUpload(tempPath, target, { overwrite: true });
3896
+ } finally {
3897
+ await rm(tempPath, { force: true }).catch(() => {});
3898
+ }
3899
+ }
3900
+
3901
+ async function yandexDiskUnshare(remotePath) {
3902
+ await yandexDiskRequest("PUT", "/resources/unpublish", { query: { path: normalizeYandexDiskPath(remotePath) } });
3903
+ return { provider: "yandex-disk", remote: remotePath, status: "unpublished" };
3904
+ }
3905
+
3906
+ async function yandexDiskDelete(remotePath, options = {}) {
3907
+ if (!options.confirm) throw new Error("Для удаления с Яндекс Диска нужен аргумент confirm=true.");
3908
+ await yandexDiskRequest("DELETE", "/resources", { query: { path: normalizeYandexDiskPath(remotePath), permanently: Boolean(options.permanently) } });
3909
+ return { provider: "yandex-disk", remote: remotePath, status: options.permanently ? "deleted-permanently" : "moved-to-trash" };
3910
+ }
3911
+
3912
+ async function executeYandexTool(tool, args = {}) {
3913
+ if (tool === "yandex_identity_me") return getYandexIdentityProfile();
3914
+ if (tool === "yandex_disk_info") return yandexDiskInfo();
3915
+ if (tool === "yandex_disk_ls") return yandexDiskList(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR, { allowMissingRoot: true });
3916
+ if (tool === "yandex_disk_mkdir") return cloudCreateFolder("yandex-disk", args.path || args.remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/Новая папка`);
3917
+ if (tool === "yandex_disk_find") return yandexDiskFind(args.query || args.name || "", { path: args.path || CLOUD_DEFAULT_REMOTE_DIR, depth: args.depth || 4, limit: args.limit || 20 });
3918
+ if (tool === "yandex_disk_save_text") return yandexDiskSaveText(args.text || args.content || "", args.path || args.remotePath);
3919
+ if (tool === "yandex_disk_upload") return yandexDiskUpload(args.localPath || args.file || args.path, args.remotePath || args.remote || `${CLOUD_DEFAULT_REMOTE_DIR}/${path.basename(args.localPath || args.file || "file.txt")}`, { overwrite: args.overwrite !== false });
3920
+ if (tool === "yandex_disk_download") return yandexDiskDownload(args.remotePath || args.path, args.outputPath || args.output || path.basename(args.remotePath || args.path || "download"));
3921
+ if (tool === "yandex_disk_share") {
3922
+ if (!args.confirm) throw new Error("Для публикации ссылки нужен аргумент confirm=true.");
3923
+ return yandexDiskShare(args.remotePath || args.path);
3924
+ }
3925
+ if (tool === "yandex_disk_unshare") return yandexDiskUnshare(args.remotePath || args.path);
3926
+ if (tool === "yandex_disk_delete") return yandexDiskDelete(args.remotePath || args.path, args);
3927
+ if (tool === "yandex_mail_status") return yandexMailStatus();
3928
+ if (tool === "yandex_mail_list") return yandexMailList({ mailbox: args.mailbox || "INBOX", limit: args.limit || 10, unread: Boolean(args.unread) });
3929
+ if (tool === "yandex_mail_search") return yandexMailSearch(args.query || "", { mailbox: args.mailbox || "INBOX", limit: args.limit || 20 });
3930
+ if (tool === "yandex_mail_read") return yandexMailRead(args.uid || args.id, { mailbox: args.mailbox || "INBOX" });
3931
+ if (tool === "yandex_mail_send") return yandexMailSend(args);
3932
+ if (tool === "yandex_calendar_status") return yandexCalendarStatus();
3933
+ if (tool === "yandex_calendar_create_event") return yandexCalendarCreateEvent(args);
3934
+ if (tool === "yandex_calendar_list") return yandexCalendarList(args);
3935
+ if (tool === "yandex_contacts_status") return yandexContactsStatus();
3936
+ if (tool === "yandex_contacts_list") return yandexContactsList(args);
3937
+ if (tool === "yandex_contacts_search") return yandexContactsSearch(args.query || "", args);
3938
+ if (tool === "yandex_telemost_create_event") return yandexTelemostCreateEvent(args);
3939
+ throw new Error(`Yandex tool неизвестен: ${tool}`);
3940
+ }
3941
+
3942
+ async function yandexMailCredentials() {
3943
+ const [token, profile] = await Promise.all([
3944
+ requireYandexOAuthToken("core", "Яндекс Почта"),
3945
+ getYandexIdentityProfile(),
3946
+ ]);
3947
+ const email = profile.defaultEmail || profile.emails?.[0] || (profile.login ? `${profile.login}@yandex.ru` : "");
3948
+ if (!email) throw new Error("Не удалось определить email Яндекс Почты.");
3949
+ return { token, email };
3950
+ }
3951
+
3952
+ async function yandexMailStatus() {
3953
+ const { email } = await yandexMailCredentials();
3954
+ const session = await imapConnect();
3955
+ try {
3956
+ await imapAuthenticate(session, email, await requireYandexOAuthToken("core", "Яндекс Почта"));
3957
+ const inbox = await imapCommand(session, "SELECT INBOX");
3958
+ return { email, status: "ok", inbox: parseImapSelect(inbox) };
3959
+ } finally {
3960
+ await imapClose(session);
3961
+ }
3962
+ }
3963
+
3964
+ async function yandexMailList(options = {}) {
3965
+ const { token, email } = await yandexMailCredentials();
3966
+ const session = await imapConnect();
3967
+ try {
3968
+ await imapAuthenticate(session, email, token);
3969
+ await imapCommand(session, `SELECT ${quoteImapMailbox(options.mailbox || "INBOX")}`);
3970
+ const criterion = options.unread ? "UNSEEN" : "ALL";
3971
+ const search = await imapCommand(session, `UID SEARCH ${criterion}`);
3972
+ const uids = parseImapSearchUids(search).slice(-Number(options.limit || 10));
3973
+ if (!uids.length) return [];
3974
+ const fetch = await imapCommand(session, `UID FETCH ${uids.join(",")} (UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[TEXT]<0.800>)`, { timeout: 45000 });
3975
+ return parseImapFetchSummaries(fetch);
3976
+ } finally {
3977
+ await imapClose(session);
3978
+ }
3979
+ }
3980
+
3981
+ async function yandexMailSearch(query, options = {}) {
3982
+ const normalized = normalizeGeoText(query);
3983
+ if (!normalized) return yandexMailList(options);
3984
+ const rows = await yandexMailList({ ...options, limit: Math.max(50, Number(options.limit || 20) * 3) });
3985
+ return rows.filter((row) => normalizeGeoText(`${row.from} ${row.subject} ${row.snippet}`).includes(normalized)).slice(0, Number(options.limit || 20));
3986
+ }
3987
+
3988
+ async function yandexMailRead(uid, options = {}) {
3989
+ if (!uid) throw new Error("UID письма обязателен.");
3990
+ const { token, email } = await yandexMailCredentials();
3991
+ const session = await imapConnect();
3992
+ try {
3993
+ await imapAuthenticate(session, email, token);
3994
+ await imapCommand(session, `SELECT ${quoteImapMailbox(options.mailbox || "INBOX")}`);
3995
+ const fetch = await imapCommand(session, `UID FETCH ${Number(uid)} (UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[])`, { timeout: 60000 });
3996
+ return parseImapFetchSummaries(fetch, { full: true })[0] || { uid, status: "not-found" };
3997
+ } finally {
3998
+ await imapClose(session);
3999
+ }
4000
+ }
4001
+
4002
+ async function yandexMailSend(args = {}) {
4003
+ if (!args.confirm) throw new Error("Для отправки письма нужен аргумент confirm=true.");
4004
+ const to = Array.isArray(args.to) ? args.to : String(args.to || "").split(/[;,]/).map((item) => item.trim()).filter(Boolean);
4005
+ if (!to.length) throw new Error("Получатель письма не указан.");
4006
+ const subject = args.subject || "Сообщение от IOLA CLI";
4007
+ const text = args.text || args.body || "";
4008
+ if (!text.trim()) throw new Error("Текст письма пустой.");
4009
+ const { token, email } = await yandexMailCredentials();
4010
+ const session = await smtpConnect();
4011
+ try {
4012
+ await smtpCommand(session, `EHLO ${os.hostname() || "iola.local"}`);
4013
+ await smtpCommand(session, `AUTH XOAUTH2 ${buildXoauth2(email, token)}`);
4014
+ await smtpCommand(session, `MAIL FROM:<${email}>`);
4015
+ for (const recipient of to) await smtpCommand(session, `RCPT TO:<${recipient}>`);
4016
+ await smtpCommand(session, "DATA", { expect: /^354/u });
4017
+ await smtpCommand(session, `${buildMimeMessage({ from: email, to, subject, text })}\r\n.`);
4018
+ await smtpCommand(session, "QUIT").catch(() => {});
4019
+ return { from: email, to, subject, status: "sent" };
4020
+ } finally {
4021
+ session.socket.destroy();
4022
+ }
4023
+ }
4024
+
4025
+ function buildXoauth2(email, token) {
4026
+ return Buffer.from(`user=${email}\x01auth=Bearer ${token}\x01\x01`).toString("base64");
4027
+ }
4028
+
4029
+ function buildMimeMessage({ from, to, subject, text }) {
4030
+ const encodedSubject = Buffer.from(subject, "utf8").toString("base64");
4031
+ return [
4032
+ `From: ${from}`,
4033
+ `To: ${to.join(", ")}`,
4034
+ `Subject: =?UTF-8?B?${encodedSubject}?=`,
4035
+ "MIME-Version: 1.0",
4036
+ "Content-Type: text/plain; charset=utf-8",
4037
+ "Content-Transfer-Encoding: base64",
4038
+ "",
4039
+ Buffer.from(text.replace(/\r?\n/g, "\r\n"), "utf8").toString("base64").replace(/.{1,76}/g, "$&\r\n").trim(),
4040
+ ].join("\r\n");
4041
+ }
4042
+
4043
+ function imapConnect() {
4044
+ return tlsLineSession("imap.yandex.ru", 993, "* OK");
4045
+ }
4046
+
4047
+ function smtpConnect() {
4048
+ return tlsLineSession("smtp.yandex.ru", 465, /^220/u);
4049
+ }
4050
+
4051
+ function tlsLineSession(host, port, greetingPattern) {
4052
+ return new Promise((resolve, reject) => {
4053
+ const socket = tls.connect({ host, port, servername: host, timeout: 30000 }, () => {});
4054
+ const session = { socket, buffer: "", lines: [], tag: 0 };
4055
+ const timer = setTimeout(() => {
4056
+ socket.destroy();
4057
+ reject(new Error(`${host}:${port} connection timeout`));
4058
+ }, 30000);
4059
+ socket.setEncoding("utf8");
4060
+ socket.on("data", (chunk) => {
4061
+ session.buffer += chunk;
4062
+ const parts = session.buffer.split(/\r?\n/);
4063
+ session.buffer = parts.pop() || "";
4064
+ session.lines.push(...parts.filter(Boolean));
4065
+ const text = session.lines.join("\n");
4066
+ const ok = typeof greetingPattern === "string" ? text.includes(greetingPattern) : greetingPattern.test(text);
4067
+ if (ok) {
4068
+ clearTimeout(timer);
4069
+ resolve(session);
4070
+ }
4071
+ });
4072
+ socket.on("error", (error) => {
4073
+ clearTimeout(timer);
4074
+ reject(error);
4075
+ });
4076
+ });
4077
+ }
4078
+
4079
+ async function imapAuthenticate(session, email, token) {
4080
+ await imapCommand(session, `AUTHENTICATE XOAUTH2 ${buildXoauth2(email, token)}`, { sensitive: token });
4081
+ }
4082
+
4083
+ function imapCommand(session, command, options = {}) {
4084
+ const tag = `A${++session.tag}`;
4085
+ session.lines = [];
4086
+ return sendTaggedCommand(session, `${tag} ${command}`, new RegExp(`^${tag} (OK|NO|BAD)`, "imu"), {
4087
+ timeout: options.timeout || 30000,
4088
+ sensitive: options.sensitive,
4089
+ ok: new RegExp(`^${tag} OK`, "imu"),
4090
+ });
4091
+ }
4092
+
4093
+ function smtpCommand(session, command, options = {}) {
4094
+ session.lines = [];
4095
+ return sendTaggedCommand(session, command, options.expect || /^[235]|\n[235]/u, {
4096
+ timeout: options.timeout || 30000,
4097
+ ok: options.expect || /^[235]|\n[235]/u,
4098
+ });
4099
+ }
4100
+
4101
+ function sendTaggedCommand(session, command, donePattern, options = {}) {
4102
+ return new Promise((resolve, reject) => {
4103
+ const timer = setTimeout(() => reject(new Error("Yandex mail command timeout")), options.timeout || 30000);
4104
+ const onData = () => {
4105
+ const text = session.lines.join("\n");
4106
+ if (!donePattern.test(text)) return;
4107
+ clearTimeout(timer);
4108
+ session.socket.off("data", onData);
4109
+ if (options.ok && !options.ok.test(text)) {
4110
+ reject(new Error(sanitizeSecretFromText(`Yandex mail command failed: ${text}`, options.sensitive)));
4111
+ } else {
4112
+ resolve(text);
4113
+ }
4114
+ };
4115
+ session.socket.on("data", onData);
4116
+ session.socket.write(`${command}\r\n`);
4117
+ });
4118
+ }
4119
+
4120
+ async function imapClose(session) {
4121
+ await imapCommand(session, "LOGOUT").catch(() => {});
4122
+ session.socket.destroy();
4123
+ }
4124
+
4125
+ function quoteImapMailbox(value) {
4126
+ return `"${String(value || "INBOX").replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4127
+ }
4128
+
4129
+ function parseImapSelect(text) {
4130
+ return {
4131
+ exists: Number(text.match(/\* (\d+) EXISTS/iu)?.[1] || 0),
4132
+ recent: Number(text.match(/\* (\d+) RECENT/iu)?.[1] || 0),
4133
+ unseen: Number(text.match(/UNSEEN (\d+)/iu)?.[1] || 0),
4134
+ };
4135
+ }
4136
+
4137
+ function parseImapSearchUids(text) {
4138
+ return [...String(text).matchAll(/\* SEARCH ([\d\s]+)/giu)]
4139
+ .flatMap((match) => match[1].trim().split(/\s+/).map(Number).filter(Boolean));
4140
+ }
4141
+
4142
+ function parseImapFetchSummaries(text, options = {}) {
4143
+ const rows = [];
4144
+ const chunks = String(text || "").split(/\n(?=\* \d+ FETCH)/u);
4145
+ for (const chunk of chunks) {
4146
+ const uid = Number(chunk.match(/UID (\d+)/iu)?.[1] || 0);
4147
+ if (!uid) continue;
4148
+ const subject = decodeMimeHeader(chunk.match(/ENVELOPE \([^\n]*?"([^"]*)"/iu)?.[1] || chunk.match(/Subject:\s*([^\r\n]+)/iu)?.[1] || "");
4149
+ const from = decodeMimeHeader(chunk.match(/From:\s*([^\r\n]+)/iu)?.[1] || chunk.match(/NIL NIL "([^"]*)" "([^"]*)"/iu)?.slice(1, 3).filter(Boolean).join("@") || "");
4150
+ const date = chunk.match(/INTERNALDATE "([^"]+)"/iu)?.[1] || chunk.match(/Date:\s*([^\r\n]+)/iu)?.[1] || "";
4151
+ const body = options.full ? stripMailBody(chunk) : stripMailBody(chunk).slice(0, 800);
4152
+ rows.push({ uid, date, from, subject, snippet: body.replace(/\s+/g, " ").trim().slice(0, options.full ? 12000 : 500) });
4153
+ }
4154
+ return rows;
4155
+ }
4156
+
4157
+ function decodeMimeHeader(value) {
4158
+ return String(value || "").replace(/=\?UTF-8\?B\?([^?]+)\?=/giu, (_, data) => Buffer.from(data, "base64").toString("utf8"));
4159
+ }
4160
+
4161
+ function stripMailBody(value) {
4162
+ return String(value || "")
4163
+ .replace(/\r/g, "")
4164
+ .split(/\n\)/u)[0]
4165
+ .replace(/^[\s\S]*?\n\n/u, "")
4166
+ .replace(/[^\S\n]+/g, " ")
4167
+ .trim();
4168
+ }
4169
+
4170
+ async function yandexDavRequest(url, token, options = {}) {
4171
+ const response = await fetch(url, {
4172
+ method: options.method || "GET",
4173
+ headers: {
4174
+ Authorization: `OAuth ${token}`,
4175
+ ...(options.xml ? { "content-type": "application/xml; charset=utf-8" } : {}),
4176
+ ...(options.ics ? { "content-type": "text/calendar; charset=utf-8" } : {}),
4177
+ ...(options.headers || {}),
4178
+ },
4179
+ body: options.body,
4180
+ signal: AbortSignal.timeout(Number(options.timeout || 30000)),
4181
+ });
4182
+ const text = await response.text().catch(() => "");
4183
+ if (!response.ok && response.status !== 207) {
4184
+ throw new Error(`Yandex DAV request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text.slice(0, 1000), token)}`);
4185
+ }
4186
+ return text;
4187
+ }
4188
+
4189
+ async function yandexCalendarStatus() {
4190
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4191
+ const url = await yandexCalendarBaseUrl(token);
4192
+ const text = await yandexDavRequest(url, token, { method: "PROPFIND", headers: { Depth: "0" }, xml: true, body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/></d:prop></d:propfind>" });
4193
+ return { status: "ok", url, displayName: stripXmlTags(text.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "") || "calendar" };
4194
+ }
4195
+
4196
+ async function yandexCalendarCreateEvent(args = {}) {
4197
+ if (!args.confirm) throw new Error("Для создания события в календаре нужен аргумент confirm=true.");
4198
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4199
+ const baseUrl = await yandexCalendarBaseUrl(token);
4200
+ const uid = `${randomUUID()}@iola-cli`;
4201
+ const start = toIcsDate(args.start || args.date || new Date(Date.now() + 3600000).toISOString());
4202
+ const end = toIcsDate(args.end || new Date(Date.now() + 7200000).toISOString());
4203
+ const summary = args.title || args.summary || "Событие IOLA";
4204
+ const description = args.description || "";
4205
+ const ics = buildIcsEvent({ uid, start, end, summary, description, location: args.location || "" });
4206
+ const url = `${baseUrl}${encodeURIComponent(uid)}.ics`;
4207
+ await yandexDavRequest(url, token, { method: "PUT", ics: true, body: ics, timeout: 45000 });
4208
+ return { status: "created", uid, title: summary, start: args.start || args.date || "", url };
4209
+ }
4210
+
4211
+ async function yandexCalendarList(args = {}) {
4212
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4213
+ const baseUrl = await yandexCalendarBaseUrl(token);
4214
+ const start = toIcsDate(args.start || new Date().toISOString());
4215
+ const end = toIcsDate(args.end || new Date(Date.now() + 14 * 86400000).toISOString());
4216
+ const body = `<?xml version="1.0"?>
4217
+ <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
4218
+ <d:prop><d:getetag/><c:calendar-data/></d:prop>
4219
+ <c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${start}" end="${end}"/></c:comp-filter></c:comp-filter></c:filter>
4220
+ </c:calendar-query>`;
4221
+ const text = await yandexDavRequest(baseUrl, token, { method: "REPORT", headers: { Depth: "1" }, xml: true, body, timeout: 45000 });
4222
+ return parseIcsEvents(text).slice(0, Number(args.limit || 20));
4223
+ }
4224
+
4225
+ async function yandexCalendarBaseUrl(token) {
4226
+ const root = "https://caldav.yandex.ru/";
4227
+ const principalXml = await yandexDavRequest(root, token, {
4228
+ method: "PROPFIND",
4229
+ headers: { Depth: "0" },
4230
+ xml: true,
4231
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:current-user-principal/></d:prop></d:propfind>",
4232
+ });
4233
+ const principalHref = extractDavHref(principalXml, "current-user-principal");
4234
+ if (!principalHref) throw new Error("Яндекс Календарь не вернул current-user-principal.");
4235
+ const homeXml = await yandexDavRequest(new URL(principalHref, root).toString(), token, {
4236
+ method: "PROPFIND",
4237
+ headers: { Depth: "0" },
4238
+ xml: true,
4239
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop><c:calendar-home-set/></d:prop></d:propfind>",
4240
+ });
4241
+ const homeHref = extractDavHref(homeXml, "calendar-home-set");
4242
+ if (!homeHref) throw new Error("Яндекс Календарь не вернул calendar-home-set.");
4243
+ const listXml = await yandexDavRequest(new URL(homeHref, root).toString(), token, {
4244
+ method: "PROPFIND",
4245
+ headers: { Depth: "1" },
4246
+ xml: true,
4247
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>",
4248
+ });
4249
+ const calendarHref = extractCalendarCollectionHref(listXml);
4250
+ if (!calendarHref) throw new Error("В Яндекс Календаре не найдена календарная коллекция.");
4251
+ return new URL(calendarHref, root).toString();
4252
+ }
4253
+
4254
+ async function yandexContactsStatus() {
4255
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
4256
+ const url = await yandexContactsBaseUrl(token);
4257
+ const text = await yandexDavRequest(url, token, { method: "PROPFIND", headers: { Depth: "0" }, xml: true, body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/></d:prop></d:propfind>" });
4258
+ return { status: "ok", url, displayName: stripXmlTags(text.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "") || "contacts" };
4259
+ }
4260
+
4261
+ async function yandexContactsList(args = {}) {
4262
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
4263
+ const url = await yandexContactsBaseUrl(token);
4264
+ const body = "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:getetag/><d:getcontenttype/></d:prop></d:propfind>";
4265
+ const text = await yandexDavRequest(url, token, { method: "PROPFIND", headers: { Depth: "1" }, xml: true, body, timeout: 45000 });
4266
+ const hrefs = extractVcardHrefs(text).slice(0, Number(args.limit || 50));
4267
+ const rows = [];
4268
+ for (const href of hrefs) {
4269
+ const card = await yandexDavRequest(new URL(href, "https://carddav.yandex.ru/").toString(), token, { method: "GET", timeout: 30000 }).catch(() => "");
4270
+ rows.push(...parseVCards(card));
4271
+ }
4272
+ return rows.slice(0, Number(args.limit || 50));
4273
+ }
4274
+
4275
+ async function yandexContactsSearch(query, args = {}) {
4276
+ const normalized = normalizeGeoText(query);
4277
+ const rows = await yandexContactsList({ limit: Math.max(100, Number(args.limit || 20) * 4) });
4278
+ if (!normalized) return rows.slice(0, Number(args.limit || 20));
4279
+ return rows.filter((row) => normalizeGeoText(`${row.name} ${row.email} ${row.phone}`).includes(normalized)).slice(0, Number(args.limit || 20));
4280
+ }
4281
+
4282
+ async function yandexContactsBaseUrl(token) {
4283
+ const root = "https://carddav.yandex.ru/";
4284
+ const principalXml = await yandexDavRequest(root, token, {
4285
+ method: "PROPFIND",
4286
+ headers: { Depth: "0" },
4287
+ xml: true,
4288
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:current-user-principal/></d:prop></d:propfind>",
4289
+ });
4290
+ const principalHref = extractDavHref(principalXml, "current-user-principal");
4291
+ if (!principalHref) throw new Error("Яндекс Контакты не вернули current-user-principal.");
4292
+ const homeXml = await yandexDavRequest(new URL(principalHref, root).toString(), token, {
4293
+ method: "PROPFIND",
4294
+ headers: { Depth: "0" },
4295
+ xml: true,
4296
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\" xmlns:card=\"urn:ietf:params:xml:ns:carddav\"><d:prop><card:addressbook-home-set/></d:prop></d:propfind>",
4297
+ });
4298
+ const homeHref = extractDavHref(homeXml, "addressbook-home-set");
4299
+ if (!homeHref) throw new Error("Яндекс Контакты не вернули addressbook-home-set.");
4300
+ const listXml = await yandexDavRequest(new URL(homeHref, root).toString(), token, {
4301
+ method: "PROPFIND",
4302
+ headers: { Depth: "1" },
4303
+ xml: true,
4304
+ body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>",
4305
+ });
4306
+ const addressBookHref = extractAddressBookCollectionHref(listXml);
4307
+ if (!addressBookHref) throw new Error("В Яндекс Контактах не найдена адресная книга.");
4308
+ return new URL(addressBookHref, root).toString();
4309
+ }
4310
+
4311
+ async function yandexTelemostCreateEvent(args = {}) {
4312
+ const description = [
4313
+ args.description || "",
4314
+ "",
4315
+ "Телемост: создайте ссылку в Яндекс Календаре, если интерфейс календаря предложит видеовстречу.",
4316
+ ].join("\n").trim();
4317
+ return yandexCalendarCreateEvent({ ...args, description });
4318
+ }
4319
+
4320
+ function buildIcsEvent({ uid, start, end, summary, description, location }) {
4321
+ const escape = (value) => String(value || "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
4322
+ return [
4323
+ "BEGIN:VCALENDAR",
4324
+ "VERSION:2.0",
4325
+ "PRODID:-//IOLA CLI//Yandex Calendar//RU",
4326
+ "BEGIN:VEVENT",
4327
+ `UID:${uid}`,
4328
+ `DTSTAMP:${toIcsDate(new Date().toISOString())}`,
4329
+ `DTSTART:${start}`,
4330
+ `DTEND:${end}`,
4331
+ `SUMMARY:${escape(summary)}`,
4332
+ description ? `DESCRIPTION:${escape(description)}` : "",
4333
+ location ? `LOCATION:${escape(location)}` : "",
4334
+ "END:VEVENT",
4335
+ "END:VCALENDAR",
4336
+ "",
4337
+ ].filter(Boolean).join("\r\n");
4338
+ }
4339
+
4340
+ function toIcsDate(value) {
4341
+ const date = new Date(value);
4342
+ if (Number.isNaN(date.getTime())) throw new Error(`Некорректная дата: ${value}`);
4343
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/u, "Z");
4344
+ }
4345
+
4346
+ function parseIcsEvents(xmlOrIcs) {
4347
+ const decoded = decodeXml(stripXmlTags(xmlOrIcs));
4348
+ return decoded.split("BEGIN:VEVENT").slice(1).map((chunk) => ({
4349
+ uid: chunk.match(/\nUID:([^\n\r]+)/u)?.[1] || "",
4350
+ title: chunk.match(/\nSUMMARY:([^\n\r]+)/u)?.[1] || "",
4351
+ start: chunk.match(/\nDTSTART[^:]*:([^\n\r]+)/u)?.[1] || "",
4352
+ end: chunk.match(/\nDTEND[^:]*:([^\n\r]+)/u)?.[1] || "",
4353
+ location: chunk.match(/\nLOCATION:([^\n\r]+)/u)?.[1] || "",
4354
+ })).filter((item) => item.uid || item.title);
4355
+ }
4356
+
4357
+ function extractDavHref(xml, propertyName) {
4358
+ const pattern = new RegExp(`<[^:>]*:?${propertyName}[^>]*>[\\s\\S]*?<[^:>]*:?href[^>]*>([\\s\\S]*?)<\\/[^:>]*:?href>`, "iu");
4359
+ const value = String(xml || "").match(pattern)?.[1] || "";
4360
+ return decodeXml(stripXmlTags(value)).trim();
4361
+ }
4362
+
4363
+ function extractCalendarCollectionHref(xml) {
4364
+ const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
4365
+ for (const response of responses) {
4366
+ if (!/<[^:>]*:?calendar(?:\s|>|\/)/iu.test(response)) continue;
4367
+ const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || "")).trim();
4368
+ if (href && !/\/(?:inbox|outbox)\//iu.test(href)) return href;
4369
+ }
4370
+ return "";
4371
+ }
4372
+
4373
+ function extractAddressBookCollectionHref(xml) {
4374
+ const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
4375
+ for (const response of responses) {
4376
+ if (!/<[^:>]*:?addressbook(?:\s|>|\/)/iu.test(response)) continue;
4377
+ const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || "")).trim();
4378
+ if (href) return href;
4379
+ }
4380
+ return "";
4381
+ }
4382
+
4383
+ function extractVcardHrefs(xml) {
4384
+ const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
4385
+ const rows = [];
4386
+ for (const response of responses) {
4387
+ if (!/text\/x-vcard|text\/vcard/iu.test(response)) continue;
4388
+ const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || "")).trim();
4389
+ if (href) rows.push(href);
4390
+ }
4391
+ return rows;
4392
+ }
4393
+
4394
+ function parseVCards(xmlOrCards) {
4395
+ const decoded = decodeXml(stripXmlTags(xmlOrCards)).replace(/\r/g, "");
4396
+ return decoded.split("BEGIN:VCARD").slice(1).map((chunk) => {
4397
+ const email = chunk.match(/^EMAIL[^:]*:([^\n]+)/mu)?.[1] || "";
4398
+ const phone = chunk.match(/^TEL[^:]*:([^\n]+)/mu)?.[1] || "";
4399
+ const name = cleanVcardName(chunk.match(/^FN:([^\n]+)/mu)?.[1] || chunk.match(/^N:([^\n]+)/mu)?.[1] || "", email);
4400
+ return { name, email, phone };
4401
+ }).filter((item) => item.name || item.email || item.phone);
4402
+ }
4403
+
4404
+ function cleanVcardName(value, fallback = "") {
4405
+ const text = String(value || "").replace(/;/g, " ").replace(/\s+/g, " ").trim();
4406
+ if (!text || /^[\s;]+$/u.test(String(value || ""))) return fallback || "";
4407
+ return text;
4408
+ }
4409
+
3798
4410
  function normalizeYandexServiceList(values) {
3799
4411
  const aliases = {
3800
4412
  id: "identity",
@@ -4872,6 +5484,11 @@ async function handlePermissions(args) {
4872
5484
  value: permissions.localTools?.[tool] === true ? "allow" : "deny",
4873
5485
  scope: "file-tool",
4874
5486
  })),
5487
+ ...YANDEX_TOOLS.map((tool) => ({
5488
+ permission: `localTools.${tool}`,
5489
+ value: permissions.localTools?.[tool] === true ? "allow" : "deny",
5490
+ scope: "yandex-tool",
5491
+ })),
4875
5492
  { permission: "readFiles", value: permissions.readFiles === true ? "allow" : "deny", scope: "filesystem" },
4876
5493
  { permission: "editFiles", value: permissions.editFiles === true ? "allow" : "deny", scope: "filesystem" },
4877
5494
  { permission: "deleteFiles", value: permissions.deleteFiles === true ? "allow" : "deny", scope: "filesystem" },
@@ -8717,6 +9334,20 @@ async function aiAsk(args, context = {}) {
8717
9334
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
8718
9335
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
8719
9336
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
9337
+ const yandexAnswer = await buildYandexDirectAnswer(question);
9338
+ if (yandexAnswer) {
9339
+ if (historyEnabled) {
9340
+ recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
9341
+ appendSessionExchange(sessionId, question, yandexAnswer, dataContext, "");
9342
+ }
9343
+ emitEvent(options, "answer", { length: yandexAnswer.length, sessionId, direct: true, yandex: true });
9344
+ if (options.output) {
9345
+ await assertPermission("writeFiles");
9346
+ await writeFile(options.output, yandexAnswer, "utf8");
9347
+ }
9348
+ if (!options.quiet) console.log(yandexAnswer);
9349
+ return yandexAnswer;
9350
+ }
8720
9351
  const cloudAnswer = await buildCloudDirectAnswer(question);
8721
9352
  if (cloudAnswer) {
8722
9353
  if (historyEnabled) {
@@ -8831,6 +9462,66 @@ async function buildDirectDataAnswer(question, dataContext) {
8831
9462
  ].join("\n");
8832
9463
  }
8833
9464
 
9465
+ async function buildYandexDirectAnswer(question) {
9466
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
9467
+ if (!/(яндекс|yandex|почт|письм|календар|контакт|телемост)/iu.test(normalized)) return "";
9468
+ try {
9469
+ if (/(аккаунт|профил|логин|кто подключен)/iu.test(normalized) && /(яндекс|yandex)/iu.test(normalized)) {
9470
+ const profile = await getYandexIdentityProfile();
9471
+ return [
9472
+ "Подключен Yandex ID:",
9473
+ `Логин: ${profile.login || "-"}`,
9474
+ `Имя: ${profile.displayName || "-"}`,
9475
+ `Email: ${profile.defaultEmail || "-"}`,
9476
+ ].join("\n");
9477
+ }
9478
+
9479
+ if (/(почт|письм|email|e-mail)/iu.test(normalized)) {
9480
+ if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
9481
+ const result = await yandexMailStatus();
9482
+ return `Яндекс Почта подключена: ${result.email}. Входящие: ${result.inbox?.exists ?? "-"}.`;
9483
+ }
9484
+ const rows = /(найди|поиск)/iu.test(normalized)
9485
+ ? await yandexMailSearch(cleanupYandexQuery(question), { limit: 10 })
9486
+ : await yandexMailList({ limit: 10, unread: /непрочитан/iu.test(normalized) });
9487
+ if (!rows.length) return "Писем по запросу не найдено.";
9488
+ return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. #${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`)].join("\n");
9489
+ }
9490
+
9491
+ if (/(календар|событи)/iu.test(normalized) && !/(создай|добавь|запланируй)/iu.test(normalized)) {
9492
+ const rows = await yandexCalendarList({ limit: 10 });
9493
+ if (!rows.length) return "В ближайшие дни событий в Яндекс Календаре не найдено.";
9494
+ return ["Яндекс Календарь:", ...rows.map((row, index) => `${index + 1}. ${row.title || "(без названия)"} — ${row.start || "-"}`)].join("\n");
9495
+ }
9496
+
9497
+ if (/(контакт|адресн)/iu.test(normalized)) {
9498
+ if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
9499
+ const result = await yandexContactsStatus();
9500
+ return `Яндекс Контакты подключены: ${result.displayName || result.url}.`;
9501
+ }
9502
+ const query = cleanupYandexQuery(question);
9503
+ const rows = /(найди|поиск)/iu.test(normalized) && query
9504
+ ? await yandexContactsSearch(query, { limit: 10 })
9505
+ : await yandexContactsList({ limit: 20 });
9506
+ if (!rows.length) return "Контакты по запросу не найдены.";
9507
+ return ["Яндекс Контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
9508
+ }
9509
+ } catch (error) {
9510
+ return `Не смог выполнить запрос к сервисам Яндекса: ${error instanceof Error ? error.message : String(error)}`;
9511
+ }
9512
+ return "";
9513
+ }
9514
+
9515
+ function cleanupYandexQuery(question) {
9516
+ const stop = /^(?:в|на|у|из|для|по|яндекс|yandex|найди|поиск|покажи|посмотри|проверь|почт\p{L}*|письм\p{L}*|календар\p{L}*|контакт\p{L}*)$/iu;
9517
+ return String(question || "")
9518
+ .replace(/[?.!]+$/u, "")
9519
+ .split(/[^\p{L}\p{N}@._+-]+/gu)
9520
+ .filter((token) => token && !stop.test(token))
9521
+ .join(" ")
9522
+ .trim();
9523
+ }
9524
+
8834
9525
  async function buildCloudDirectAnswer(question) {
8835
9526
  if (!isCloudQuestion(question)) return "";
8836
9527
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
@@ -9227,6 +9918,11 @@ async function localToolAsk(question, providerConfig, options) {
9227
9918
  if (!options.quiet) console.log(casualAnswer);
9228
9919
  return casualAnswer;
9229
9920
  }
9921
+ const yandexAnswer = await buildYandexDirectAnswer(question);
9922
+ if (yandexAnswer) {
9923
+ if (!options.quiet) console.log(yandexAnswer);
9924
+ return yandexAnswer;
9925
+ }
9230
9926
  const cloudAnswer = await buildCloudDirectAnswer(question);
9231
9927
  if (cloudAnswer) {
9232
9928
  if (!options.quiet) console.log(cloudAnswer);
@@ -9435,6 +10131,8 @@ async function buildLocalToolPlan(question, providerConfig, options) {
9435
10131
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
9436
10132
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
9437
10133
  "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
10134
+ "Yandex tools: yandex_identity_me {}, yandex_disk_ls {path}, yandex_disk_mkdir {path}, yandex_disk_find {query,path}, yandex_disk_save_text {path,text}, yandex_mail_list {limit,unread}, yandex_mail_search {query}, yandex_mail_read {uid}, yandex_calendar_list {start,end}, yandex_contacts_search {query}.",
10135
+ "Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_delete, yandex_mail_send, yandex_calendar_create_event, yandex_telemost_create_event.",
9438
10136
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
9439
10137
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
9440
10138
  `Вопрос: ${question}`,
@@ -9492,6 +10190,27 @@ function parseJsonObject(text) {
9492
10190
 
9493
10191
  function inferToolPlan(question, options = {}) {
9494
10192
  const normalized = question.toLocaleLowerCase("ru-RU");
10193
+ if (/(яндекс|yandex)/iu.test(normalized) && /(аккаунт|профил|логин|почт[аы]|email|e-mail|кто подключен)/iu.test(normalized)) {
10194
+ return { steps: [{ tool: "yandex_identity_me", args: {} }] };
10195
+ }
10196
+ if (/(яндекс|диск|облак)/iu.test(normalized)) {
10197
+ if (/(создай|сделай).{0,30}папк/iu.test(normalized)) {
10198
+ const folder = question.match(/папк[ауи]?\s+["«]?([^"»\n]+)["»]?/iu)?.[1]?.trim() || "Новая папка";
10199
+ return { steps: [{ tool: "yandex_disk_mkdir", args: { path: `${CLOUD_DEFAULT_REMOTE_DIR}/${folder}` } }] };
10200
+ }
10201
+ if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_find", args: { query: question, path: CLOUD_DEFAULT_REMOTE_DIR, limit: 20 } }] };
10202
+ return { steps: [{ tool: "yandex_disk_ls", args: { path: CLOUD_DEFAULT_REMOTE_DIR } }] };
10203
+ }
10204
+ if (/(почт|письм|email|e-mail)/iu.test(normalized)) {
10205
+ if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_search", args: { query: question, limit: 20 } }] };
10206
+ return { steps: [{ tool: "yandex_mail_list", args: { limit: 10, unread: /непрочитан/iu.test(normalized) } }] };
10207
+ }
10208
+ if (/(календар|событи|встреч|телемост)/iu.test(normalized)) {
10209
+ return { steps: [{ tool: normalized.includes("телемост") ? "yandex_telemost_create_event" : "yandex_calendar_list", args: { limit: 20 } }] };
10210
+ }
10211
+ if (/(контакт|адресн)/iu.test(normalized)) {
10212
+ return { steps: [{ tool: "yandex_contacts_search", args: { query: question, limit: 20 } }] };
10213
+ }
9495
10214
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
9496
10215
  const steps = [];
9497
10216
  if (normalized.includes("без телефона")) {
@@ -9845,7 +10564,10 @@ function formatToolExecutionError(error, plan) {
9845
10564
  }
9846
10565
 
9847
10566
  function availableToolNames(options = {}) {
9848
- const names = new Set(LOCAL_TOOLS);
10567
+ const names = new Set([...LOCAL_TOOLS, ...YANDEX_TOOLS]);
10568
+ if (options.files) {
10569
+ for (const tool of FILE_TOOLS) names.add(tool);
10570
+ }
9849
10571
  for (const tool of getLocalMcpToolNames()) names.add(tool);
9850
10572
  return [...names];
9851
10573
  }
@@ -9906,6 +10628,11 @@ async function executeToolPlan(plan, options = {}) {
9906
10628
  } else if (step.tool === "get_current_date") {
9907
10629
  current = [getCurrentDateInfo()];
9908
10630
  outputs.push({ tool: step.tool, rows: current.length });
10631
+ } else if (YANDEX_TOOLS.includes(step.tool)) {
10632
+ await assertPermission("externalApi");
10633
+ const result = await executeYandexTool(step.tool, step.args || {});
10634
+ current = Array.isArray(result) ? result : [result];
10635
+ outputs.push({ tool: step.tool, rows: current.length });
9909
10636
  } else if (String(step.tool || "").startsWith("mcp:")) {
9910
10637
  const result = await callConfiguredMcpTool(step.tool, step.args || {});
9911
10638
  current = Array.isArray(result) ? result : [result];
@@ -10037,10 +10764,21 @@ function formatToolResult(result, options) {
10037
10764
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
10038
10765
  }
10039
10766
  if (row.date && row.time) return `Сегодня ${row.date}, ${row.time}.`;
10767
+ if (row.provider === "yandex-disk" && row.publicUrl) return `Публичная ссылка: ${row.publicUrl}`;
10768
+ if (row.provider === "yandex-disk" && row.remote) return `Яндекс Диск: ${row.status || "ok"} ${row.remote}`;
10769
+ if (row.uid && (row.subject || row.from)) return `Письмо #${row.uid}: ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`;
10770
+ if (row.login || row.defaultEmail) return `Yandex ID: ${row.displayName || row.login || "-"}${row.defaultEmail ? `, ${row.defaultEmail}` : ""}`;
10771
+ if (row.title && (row.start || row.end)) return `${row.title}: ${row.start || "-"}${row.end ? ` - ${row.end}` : ""}`;
10772
+ if (row.email || row.phone) return formatYandexContact(row);
10040
10773
  return `${row.name || row.check || row.inn || "строка"}: ${row.address || row.phone || row.email || row.website || row.count || ""}`;
10041
10774
  }).join("\n");
10042
10775
  }
10043
10776
 
10777
+ function formatYandexContact(row) {
10778
+ const name = row.name && row.name !== row.email ? row.name : "";
10779
+ return [name || row.email || "Контакт", name && row.email ? row.email : "", row.phone || ""].filter(Boolean).join(", ");
10780
+ }
10781
+
10044
10782
  function applyRuntimeConfig(target, value) {
10045
10783
  if (!value) {
10046
10784
  return;
@@ -11794,6 +12532,7 @@ function selectSkillsForPrompt(config, question = "", options = {}) {
11794
12532
  if (enabled.has("geo") && isGeoQuestion(normalized)) selected.add("geo");
11795
12533
  const cloudQuestion = isCloudQuestion(normalized);
11796
12534
  if (enabled.has("personal-docs") && cloudQuestion) selected.add("personal-docs");
12535
+ if (enabled.has("yandex-services") && /(яндекс|диск|почт|письм|календар|контакт|телемост|облако)/iu.test(normalized)) selected.add("yandex-services");
11797
12536
  if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
11798
12537
  if (enabled.has("local-files") && !cloudQuestion && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
11799
12538
  if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
@@ -13523,6 +14262,12 @@ function sanitizeConfig(config) {
13523
14262
  if (Array.isArray(next.yandex?.authorizedServices)) {
13524
14263
  next.yandex.authorizedServices = next.yandex.authorizedServices.filter((service) => Boolean(YANDEX_CONNECTOR_SERVICES[service]));
13525
14264
  }
14265
+ if (Array.isArray(next.yandex?.enabledServices) && next.yandex.enabledServices.length > 0) {
14266
+ next.toolsets = next.toolsets || {};
14267
+ next.toolsets.enabled = [...new Set([...(next.toolsets.enabled || []), "yandex"])];
14268
+ next.skills = next.skills || {};
14269
+ next.skills.enabled = [...new Set([...(next.skills.enabled || []), "yandex-services"])];
14270
+ }
13526
14271
  const localProfile = next.ai?.profiles?.local;
13527
14272
  if (localProfile?.provider === "iola") {
13528
14273
  if (!localProfile.runtime || localProfile.model === "iola-router-1b") {
@@ -81,8 +81,8 @@ if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
81
81
  throw new Error("package files should include the Yandex OAuth icon");
82
82
  }
83
83
  assertIncludes(postinstallSource, "process.hrtime.bigint()", "postinstall should use a monotonic timer");
84
- assertIncludes(postinstallSource, "без скачивания и распаковки npm-пакета", "postinstall timing should not imply full npm install time");
85
- assertIncludes(postinstallSource, "IOLA CLI готова за", "postinstall should print total setup duration");
84
+ assertIncludes(postinstallSource, "это не полное время npm install", "postinstall timing should not imply full npm install time");
85
+ assertIncludes(postinstallSource, "Настройка CLI после скачивания заняла", "postinstall should print setup-only duration");
86
86
 
87
87
  const commands = await runCli(["commands"]);
88
88
  assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
@@ -8,7 +8,8 @@ Skills не подмешиваются в каждый запрос целико
8
8
  - `reports` - когда нужен отчет, выгрузка, CSV/XLSX или проверка качества;
9
9
  - `local-files` - когда пользователь просит работать с локальными файлами, папками, архивами или документами;
10
10
  - `browser-agent` - когда запрос связан с сайтом, URL, страницей, скриншотом или браузером;
11
- - `local-model` - инструкции для локальных компактных моделей и tool-планирования.
11
+ - `local-model` - инструкции для локальных компактных моделей и tool-планирования;
12
+ - `yandex-services` - когда запрос связан с Yandex Connector: Яндекс Диск, Почта, Календарь, Контакты, Телемост или Yandex ID.
12
13
 
13
14
  Обычный диалог вроде `привет` не получает инструкции про слои, отчеты, файлы и браузер.
14
15
 
@@ -38,3 +39,16 @@ iola tools profile full
38
39
 
39
40
  Режим `safe` подходит для чтения и анализа без записи файлов и без запуска sync.
40
41
  Режим `full` предназначен для доверенного локального пользователя.
42
+
43
+ ## Yandex toolset
44
+
45
+ Toolset `yandex` включает локальные tools для пользовательских сервисов Яндекса:
46
+
47
+ - Yandex ID: профиль, логин, email;
48
+ - Яндекс Диск: список, создание папок, поиск, сохранение текста, загрузка, скачивание, публичные ссылки;
49
+ - Яндекс Почта: статус, список писем, поиск, чтение, отправка;
50
+ - Яндекс Календарь: статус, список событий, создание события;
51
+ - Яндекс Контакты: статус, список, поиск;
52
+ - Телемост: подготовка встречи через календарное событие.
53
+
54
+ Опасные действия ограничены: отправка письма, удаление файлов, публикация ссылок и создание событий требуют явного подтверждения в tool-вызове. Токены хранятся локально в `~/.iola/secrets.json`.
@@ -35,15 +35,12 @@ iola yandex token delete
35
35
  Готово к первому контуру:
36
36
 
37
37
  - `identity` - Yandex ID, профиль пользователя, логин и email;
38
- - `disk` - Яндекс Диск, папка `/IOLA`, файлы, папки, загрузка, скачивание и публичные ссылки.
39
-
40
- Исследуется:
41
-
42
38
  - `mail` - Яндекс Почта, чтение и поиск писем, отправка только после явного подтверждения;
43
39
  - `calendar` - Яндекс Календарь;
44
40
  - `contacts` - Яндекс Контакты;
41
+ - `disk` - Яндекс Диск, папка `/IOLA`, файлы, папки, загрузка, скачивание и публичные ссылки;
45
42
  - `docs` - Яндекс Документы / 360 через Диск;
46
- - `telemost` - Яндекс Телемост через календарь, если сценарий подтвердится.
43
+ - `telemost` - Яндекс Телемост через календарное событие.
47
44
 
48
45
  Отдельные ключи, не обычный OAuth бытового Яндекса:
49
46
 
@@ -112,17 +109,33 @@ iola cloud doctor
112
109
 
113
110
  ## Что значит включить сервис
114
111
 
115
- OAuth-права дают CLI разрешение обращаться к сервису Яндекса. Но для практической работы под каждый сервис нужны отдельные команды и тулы:
116
-
117
- - `identity` - проверить пользователя и email;
118
- - `disk` - папки, загрузка, скачивание, поиск файлов, публичные ссылки;
119
- - `mail` - список писем, поиск, чтение письма, отправка письма после явного подтверждения;
120
- - `calendar` - список событий, создание события, напоминания;
121
- - `contacts` - поиск контактов и карточки контактов;
122
- - `docs` - поиск и работа с документами через Диск;
123
- - `telemost` - подготовка встречи через календарь, если подтвердится.
124
-
125
- Включение сервиса в `/yandex` только разрешает CLI использовать соответствующую категорию. Если прав или тула еще нет, CLI должен честно показать это, а не имитировать работу.
112
+ OAuth-права дают CLI разрешение обращаться к сервису Яндекса. Практическая работа идет через локальные tools:
113
+
114
+ - `yandex_identity_me` - проверить пользователя и email;
115
+ - `yandex_disk_info` - место на Яндекс Диске;
116
+ - `yandex_disk_ls` - список файлов и папок;
117
+ - `yandex_disk_mkdir` - создать папку;
118
+ - `yandex_disk_find` - найти файл или папку;
119
+ - `yandex_disk_save_text` - сохранить текстовый результат на Диск;
120
+ - `yandex_disk_upload` - загрузить локальный файл на Диск;
121
+ - `yandex_disk_download` - скачать файл с Диска;
122
+ - `yandex_disk_share` - создать публичную ссылку;
123
+ - `yandex_disk_unshare` - снять публичную ссылку;
124
+ - `yandex_disk_delete` - удалить файл или папку;
125
+ - `yandex_mail_status` - проверить доступ к Почте;
126
+ - `yandex_mail_list` - показать последние письма;
127
+ - `yandex_mail_search` - найти письма;
128
+ - `yandex_mail_read` - прочитать письмо по UID;
129
+ - `yandex_mail_send` - отправить письмо;
130
+ - `yandex_calendar_status` - проверить доступ к Календарю;
131
+ - `yandex_calendar_list` - показать события;
132
+ - `yandex_calendar_create_event` - создать событие;
133
+ - `yandex_contacts_status` - проверить доступ к Контактам;
134
+ - `yandex_contacts_list` - показать контакты;
135
+ - `yandex_contacts_search` - найти контакт;
136
+ - `yandex_telemost_create_event` - создать календарное событие для встречи.
137
+
138
+ Включение сервиса в `/yandex` разрешает CLI использовать соответствующую категорию. Отправка письма, удаление файлов, публикация ссылок и создание событий требуют явного намерения пользователя и подтверждения в tool-вызове.
126
139
 
127
140
  ## Иконка приложения
128
141
 
@@ -147,9 +147,57 @@ iola geo services "Йошкар-Ола, улица Петрова, 15"
147
147
 
148
148
  Помочь разобрать личные документы: найти старые отчеты, крупные файлы, дубликаты и предложить структуру папок.
149
149
 
150
+ ## Yandex services skills
151
+
152
+ Эти skills работают через Yandex Connector. Пользователь входит в свой Яндекс-аккаунт, а токены хранятся локально на компьютере.
153
+
154
+ ### yandex-identity
155
+
156
+ Проверить, какой Yandex ID подключен: логин, имя и email.
157
+
158
+ ### yandex-disk
159
+
160
+ Работать с Яндекс Диском:
161
+
162
+ - показать файлы и папки в `/IOLA`;
163
+ - создать папку;
164
+ - найти файл или папку;
165
+ - сохранить текстовый результат;
166
+ - создать или снять публичную ссылку;
167
+ - загрузить или скачать файл.
168
+
169
+ ### yandex-mail
170
+
171
+ Работать с Яндекс Почтой:
172
+
173
+ - проверить доступ к почте;
174
+ - показать последние или непрочитанные письма;
175
+ - найти письмо;
176
+ - прочитать письмо по UID;
177
+ - отправить письмо только после явного подтверждения.
178
+
179
+ ### yandex-calendar
180
+
181
+ Работать с Яндекс Календарем:
182
+
183
+ - показать ближайшие события;
184
+ - создать событие только после явного подтверждения.
185
+
186
+ ### yandex-contacts
187
+
188
+ Работать с Яндекс Контактами:
189
+
190
+ - проверить доступ к адресной книге;
191
+ - показать контакты;
192
+ - найти контакт по имени, email или телефону.
193
+
194
+ ### yandex-telemost
195
+
196
+ Подготовить встречу через календарное событие. CLI не должен сам нажимать финальные кнопки в интерфейсе Яндекса без явного действия пользователя.
197
+
150
198
  ## Yandex Connector backlog
151
199
 
152
- Эти сценарии зафиксированы для следующего этапа после базового Yandex Connector. Они не должны оформлять заказ, списывать деньги или нажимать финальную кнопку вместо пользователя.
200
+ Эти сценарии зафиксированы для следующего этапа. Они не должны оформлять заказ, списывать деньги или нажимать финальную кнопку вместо пользователя.
153
201
 
154
202
  ### taxi-prepare-ride
155
203