@iola_adm/iola-cli 0.2.33 → 0.2.35

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/src/cli.js CHANGED
@@ -151,18 +151,31 @@ 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 USER_SKILL_TOOLS = ["user_skill_create", "user_skill_enable", "user_skill_disable", "user_skill_delete", "user_skill_list"];
154
155
  const YANDEX_TOOLS = [
155
156
  "yandex_identity_me",
156
157
  "yandex_disk_info",
157
158
  "yandex_disk_ls",
158
159
  "yandex_disk_mkdir",
159
160
  "yandex_disk_find",
161
+ "yandex_disk_stat",
162
+ "yandex_disk_exists",
163
+ "yandex_disk_read_text",
160
164
  "yandex_disk_save_text",
161
165
  "yandex_disk_upload",
162
166
  "yandex_disk_download",
167
+ "yandex_disk_move",
168
+ "yandex_disk_copy",
169
+ "yandex_disk_rename",
163
170
  "yandex_disk_share",
171
+ "yandex_disk_share_qr",
172
+ "yandex_disk_share_email",
173
+ "yandex_disk_package_share_email",
164
174
  "yandex_disk_unshare",
165
175
  "yandex_disk_delete",
176
+ "yandex_disk_trash_list",
177
+ "yandex_disk_restore",
178
+ "yandex_disk_empty_trash",
166
179
  "yandex_mail_status",
167
180
  "yandex_mail_folders",
168
181
  "yandex_mail_list",
@@ -180,16 +193,58 @@ const YANDEX_TOOLS = [
180
193
  "yandex_mail_map_addresses",
181
194
  "yandex_mail_create_task",
182
195
  "yandex_calendar_status",
196
+ "yandex_calendar_calendars",
183
197
  "yandex_calendar_create_event",
184
198
  "yandex_calendar_list",
199
+ "yandex_calendar_get",
200
+ "yandex_calendar_search",
201
+ "yandex_calendar_update",
202
+ "yandex_calendar_move",
203
+ "yandex_calendar_delete",
204
+ "yandex_calendar_create_recurring_event",
205
+ "yandex_calendar_add_reminder",
206
+ "yandex_docs_status",
207
+ "yandex_docs_list",
208
+ "yandex_docs_find",
209
+ "yandex_docs_create_text",
210
+ "yandex_docs_read",
211
+ "yandex_docs_share",
212
+ "yandex_docs_rename",
213
+ "yandex_docs_delete",
214
+ "yandex_docs_save_answer",
185
215
  "yandex_contacts_status",
186
216
  "yandex_contacts_list",
187
217
  "yandex_contacts_search",
218
+ "yandex_contacts_get",
188
219
  "yandex_contacts_create",
220
+ "yandex_contacts_update",
221
+ "yandex_contacts_delete",
189
222
  "yandex_contacts_add_email",
223
+ "yandex_contacts_add_phone",
224
+ "yandex_contacts_add_address",
225
+ "yandex_contacts_add_note",
226
+ "yandex_contacts_add_birthday",
227
+ "yandex_contacts_add_org",
228
+ "yandex_contacts_remove_email",
229
+ "yandex_contacts_remove_phone",
230
+ "yandex_contacts_export_vcard",
231
+ "yandex_contacts_export_csv",
232
+ "yandex_contacts_import_vcard",
233
+ "yandex_contacts_import_csv",
234
+ "yandex_contacts_find_incomplete",
235
+ "yandex_contacts_find_duplicates",
236
+ "yandex_contacts_backup_to_disk",
237
+ "yandex_contacts_birthdays_to_calendar",
238
+ "yandex_contact_send_mail",
239
+ "yandex_contact_send_disk_link_qr",
240
+ "yandex_contact_create_disk_folder",
241
+ "yandex_contact_create_calendar_event",
242
+ "yandex_contact_create_telemost_event",
243
+ "yandex_contact_from_public_entity",
244
+ "yandex_telemost_status",
190
245
  "yandex_telemost_create_event",
191
246
  ];
192
- const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS, ...YANDEX_TOOLS];
247
+ const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS, ...YANDEX_TOOLS, ...USER_SKILL_TOOLS];
193
248
  const ALL_TOOL_ALIASES = [...ALL_LOCAL_TOOLS, ...LEGACY_LOCAL_TOOLS];
194
249
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
195
250
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
@@ -247,6 +302,13 @@ const TOOLSETS = {
247
302
  localTools: Object.fromEntries(ALL_LOCAL_TOOLS.map((tool) => [tool, true])),
248
303
  },
249
304
  },
305
+ "user-skills": {
306
+ description: "Создание и управление пользовательскими skills на базе встроенных tools.",
307
+ permissions: {
308
+ writeFiles: true,
309
+ localTools: Object.fromEntries(USER_SKILL_TOOLS.map((tool) => [tool, true])),
310
+ },
311
+ },
250
312
  };
251
313
  const FEATURES = {
252
314
  "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
@@ -390,7 +452,7 @@ const DEFAULT_AI_CONFIG = {
390
452
  codex: true,
391
453
  },
392
454
  toolsets: {
393
- enabled: ["data-read", "reports", "sync", "ai", "yandex"],
455
+ enabled: ["data-read", "reports", "sync", "ai", "yandex", "user-skills"],
394
456
  },
395
457
  files: {
396
458
  mode: "locked",
@@ -404,7 +466,7 @@ const DEFAULT_AI_CONFIG = {
404
466
  suggestions: true,
405
467
  },
406
468
  skills: {
407
- enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent", "yandex-services"],
469
+ enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent", "yandex-services", "user-skills"],
408
470
  },
409
471
  cloud: {
410
472
  activeProvider: "",
@@ -768,7 +830,7 @@ Usage:
768
830
  iola settings list|get|validate|doctor|init
769
831
  iola wiki [open|links]
770
832
  iola context list|show|init
771
- iola skills list|show|paths|enable|disable|bundles|bundle|doctor
833
+ iola skills list|show|paths|create|enable|disable|delete|bundles|bundle|doctor
772
834
  iola tools list|toolsets|enable|disable|profile
773
835
  iola files status|mode|approvals|tree|read|search|write|patch
774
836
  iola cloud setup|status|ls|find|upload|download|share|save|backup
@@ -2949,20 +3011,7 @@ async function handleSkills(args) {
2949
3011
  const config = await loadConfig();
2950
3012
 
2951
3013
  if (action === "list" || action === "ls") {
2952
- const rows = listSkills(config).map((skill) => ({
2953
- enabled: isSkillEnabled(config, skill.name) ? "yes" : "no",
2954
- name: skill.name,
2955
- source: skill.source,
2956
- description: skill.description,
2957
- file: skill.file,
2958
- }));
2959
- printTable(rows, [
2960
- ["enabled", "Вкл"],
2961
- ["name", "Skill"],
2962
- ["source", "Источник"],
2963
- ["description", "Описание"],
2964
- ["file", "Файл"],
2965
- ]);
3014
+ printSkillsList(listSkills(config), config);
2966
3015
  return;
2967
3016
  }
2968
3017
 
@@ -3016,6 +3065,30 @@ async function handleSkills(args) {
3016
3065
  return;
3017
3066
  }
3018
3067
 
3068
+ if (action === "create" || action === "new") {
3069
+ const options = parseOptions(args.slice(2));
3070
+ const result = await userSkillCreate({
3071
+ name,
3072
+ description: options.description || "",
3073
+ instructions: options.instructions || options.text || options.prompt || options._.join(" "),
3074
+ tools: parseCommaList(options["allowed-tools"] || options.tool || options.uses || ""),
3075
+ enable: Boolean(options.enable),
3076
+ overwrite: Boolean(options.force),
3077
+ confirm: true,
3078
+ });
3079
+ console.log(`Skill создан: ${result.name}`);
3080
+ console.log(`Файл: ${result.file}`);
3081
+ if (result.enabled) console.log("Skill включен.");
3082
+ return;
3083
+ }
3084
+
3085
+ if (action === "delete" || action === "remove" || action === "rm") {
3086
+ const options = parseOptions(args.slice(2));
3087
+ const result = await userSkillDelete(name, { confirm: Boolean(options.yes || options.force) });
3088
+ console.log(`Skill удален: ${result.name}`);
3089
+ return;
3090
+ }
3091
+
3019
3092
  if (action === "enable" || action === "disable") {
3020
3093
  if (!name) throw new Error("Имя skill обязательно.");
3021
3094
  const enabled = new Set(config.skills?.enabled || []);
@@ -3026,7 +3099,7 @@ async function handleSkills(args) {
3026
3099
  return;
3027
3100
  }
3028
3101
 
3029
- throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME, bundles, bundle enable NAME, doctor.");
3102
+ throw new Error("Команды skills: list, paths, show NAME, create NAME --description TEXT --instructions TEXT [--enable], enable NAME, disable NAME, delete NAME --yes, bundles, bundle enable NAME, doctor.");
3030
3103
  }
3031
3104
 
3032
3105
  async function handleTools(args) {
@@ -3322,6 +3395,11 @@ async function handleYandex(args) {
3322
3395
  return;
3323
3396
  }
3324
3397
 
3398
+ if (action === "contacts-maintenance" || action === "contacts-watch" || action === "contacts-doctor") {
3399
+ await handleYandexContactsMaintenance([target, ...rest].filter(Boolean));
3400
+ return;
3401
+ }
3402
+
3325
3403
  if (action === "enable" || action === "disable") {
3326
3404
  const services = [target, ...rest].filter((item) => item && !String(item).startsWith("--"));
3327
3405
  if (services.length === 0) throw new Error("Укажите сервисы. Пример: iola yandex enable disk mail calendar");
@@ -3358,6 +3436,7 @@ async function handleYandex(args) {
3358
3436
  iola yandex status|doctor
3359
3437
  iola yandex services
3360
3438
  iola yandex mail-watch on|off|status|tick [--minutes 5]
3439
+ iola yandex contacts-maintenance on|off|status|tick [--days 7] [--backup]
3361
3440
  iola yandex enable disk mail calendar
3362
3441
  iola yandex disable mail
3363
3442
  iola yandex oauth-url [disk mail calendar] [--client-id ID] [--open]
@@ -3428,6 +3507,55 @@ async function handleYandexMailWatch(args = []) {
3428
3507
  throw new Error("Команды: iola yandex mail-watch on --minutes 5 | off | status | tick");
3429
3508
  }
3430
3509
 
3510
+ async function handleYandexContactsMaintenance(args = []) {
3511
+ const [action = "status", ...rest] = args;
3512
+ const options = parseOptions(rest);
3513
+ if (action === "on" || action === "enable" || action === "start" || action === "вкл") {
3514
+ const days = Math.max(1, Number(options.days || options.interval || rest.find((item) => /^\d+$/u.test(String(item))) || 7));
3515
+ const result = await yandexContactsMaintenanceEnable(days, { backup: Boolean(options.backup) });
3516
+ console.log(`Проверка контактов включена: каждые ${days} дней.`);
3517
+ console.log(`Backup на Диск: ${result.backup ? "yes" : "no"}.`);
3518
+ console.log("Для работы по расписанию должен запускаться cron tick: вручную, через daemon или Windows Task Scheduler.");
3519
+ return;
3520
+ }
3521
+ if (action === "off" || action === "disable" || action === "stop" || action === "выкл") {
3522
+ await yandexContactsMaintenanceDisable();
3523
+ console.log("Проверка контактов выключена.");
3524
+ return;
3525
+ }
3526
+ if (action === "tick" || action === "run" || action === "check") {
3527
+ const result = await yandexContactsMaintenanceTick({ force: true, backup: options.backup });
3528
+ if (!result.enabled) {
3529
+ console.log("Проверка контактов выключена.");
3530
+ } else {
3531
+ printKeyValue({
3532
+ status: "ok",
3533
+ contacts: result.total,
3534
+ incomplete: result.incomplete,
3535
+ duplicateGroups: result.duplicateGroups,
3536
+ backup: result.backupRemote || "-",
3537
+ });
3538
+ }
3539
+ return;
3540
+ }
3541
+ if (action === "status" || action === "doctor") {
3542
+ const config = await loadConfig();
3543
+ const maintenance = config.yandex?.contactsMaintenance || {};
3544
+ printKeyValue({
3545
+ enabled: maintenance.enabled ? "yes" : "no",
3546
+ days: maintenance.days || "-",
3547
+ backup: maintenance.backup ? "yes" : "no",
3548
+ lastRunAt: maintenance.lastRunAt || "-",
3549
+ lastTotal: maintenance.lastTotal || "-",
3550
+ lastIncomplete: maintenance.lastIncomplete || "-",
3551
+ lastDuplicateGroups: maintenance.lastDuplicateGroups || "-",
3552
+ cron: listCronJobs().some((job) => job.command === "yandex contacts-maintenance tick") ? "yes" : "no",
3553
+ });
3554
+ return;
3555
+ }
3556
+ throw new Error("Команды: iola yandex contacts-maintenance on --days 7 [--backup] | off | status | tick");
3557
+ }
3558
+
3431
3559
  async function setupYandexConnector(args = []) {
3432
3560
  const options = parseOptions(args);
3433
3561
  const config = await loadConfig();
@@ -3999,15 +4127,27 @@ async function executeYandexTool(tool, args = {}) {
3999
4127
  if (tool === "yandex_disk_ls") return yandexDiskList(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR, { allowMissingRoot: true });
4000
4128
  if (tool === "yandex_disk_mkdir") return cloudCreateFolder("yandex-disk", args.path || args.remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/Новая папка`);
4001
4129
  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 });
4130
+ if (tool === "yandex_disk_stat") return yandexDiskStat(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR);
4131
+ if (tool === "yandex_disk_exists") return yandexDiskExists(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR);
4132
+ if (tool === "yandex_disk_read_text") return yandexDiskReadText(args.path || args.remotePath, { maxBytes: args.maxBytes || args["max-bytes"] });
4002
4133
  if (tool === "yandex_disk_save_text") return yandexDiskSaveText(args.text || args.content || "", args.path || args.remotePath);
4003
4134
  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 });
4004
4135
  if (tool === "yandex_disk_download") return yandexDiskDownload(args.remotePath || args.path, args.outputPath || args.output || path.basename(args.remotePath || args.path || "download"));
4136
+ if (tool === "yandex_disk_move") return yandexDiskMove(args.from || args.source || args.path, args.to || args.target || args.remotePath, args);
4137
+ if (tool === "yandex_disk_copy") return yandexDiskCopy(args.from || args.source || args.path, args.to || args.target || args.remotePath, args);
4138
+ if (tool === "yandex_disk_rename") return yandexDiskRename(args.path || args.remotePath, args.name || args.newName || args.to, args);
4005
4139
  if (tool === "yandex_disk_share") {
4006
4140
  if (!args.confirm) throw new Error("Для публикации ссылки нужен аргумент confirm=true.");
4007
4141
  return yandexDiskShare(args.remotePath || args.path);
4008
4142
  }
4143
+ if (tool === "yandex_disk_share_qr") return yandexDiskShareWithQr(args.remotePath || args.path, args);
4144
+ if (tool === "yandex_disk_share_email") return yandexDiskShareEmail(args);
4145
+ if (tool === "yandex_disk_package_share_email") return yandexDiskPackageShareEmail(args);
4009
4146
  if (tool === "yandex_disk_unshare") return yandexDiskUnshare(args.remotePath || args.path);
4010
4147
  if (tool === "yandex_disk_delete") return yandexDiskDelete(args.remotePath || args.path, args);
4148
+ if (tool === "yandex_disk_trash_list") return yandexDiskTrashList(args.path || args.remotePath || "", args);
4149
+ if (tool === "yandex_disk_restore") return yandexDiskRestore(args.path || args.remotePath, args);
4150
+ if (tool === "yandex_disk_empty_trash") return yandexDiskEmptyTrash(args);
4011
4151
  if (tool === "yandex_mail_status") return yandexMailStatus();
4012
4152
  if (tool === "yandex_mail_folders") return yandexMailFolders();
4013
4153
  if (tool === "yandex_mail_list") return yandexMailList({ mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), limit: args.limit || 10, unread: Boolean(args.unread) });
@@ -4025,13 +4165,55 @@ async function executeYandexTool(tool, args = {}) {
4025
4165
  if (tool === "yandex_mail_map_addresses") return yandexMailMapAddresses(args.uid || args.id, args);
4026
4166
  if (tool === "yandex_mail_create_task") return yandexMailCreateTask(args.uid || args.id, args);
4027
4167
  if (tool === "yandex_calendar_status") return yandexCalendarStatus();
4168
+ if (tool === "yandex_calendar_calendars") return yandexCalendarCalendars(args);
4028
4169
  if (tool === "yandex_calendar_create_event") return yandexCalendarCreateEvent(args);
4029
4170
  if (tool === "yandex_calendar_list") return yandexCalendarList(args);
4171
+ if (tool === "yandex_calendar_get") return yandexCalendarGet(args.query || args.uid || args.title || "", args);
4172
+ if (tool === "yandex_calendar_search") return yandexCalendarSearch(args.query || args.title || "", args);
4173
+ if (tool === "yandex_calendar_update") return yandexCalendarUpdate(args.query || args.uid || args.title || "", args);
4174
+ if (tool === "yandex_calendar_move") return yandexCalendarMove(args.query || args.uid || args.title || "", args);
4175
+ if (tool === "yandex_calendar_delete") return yandexCalendarDelete(args.query || args.uid || args.title || "", args);
4176
+ if (tool === "yandex_calendar_create_recurring_event") return yandexCalendarCreateRecurringEvent(args);
4177
+ if (tool === "yandex_calendar_add_reminder") return yandexCalendarAddReminder(args.query || args.uid || args.title || "", args);
4178
+ if (tool === "yandex_docs_status") return yandexDocsStatus();
4179
+ if (tool === "yandex_docs_list") return yandexDocsList(args);
4180
+ if (tool === "yandex_docs_find") return yandexDocsFind(args.query || args.name || "", args);
4181
+ if (tool === "yandex_docs_create_text") return yandexDocsCreateText(args);
4182
+ if (tool === "yandex_docs_read") return yandexDocsRead(args.path || args.remotePath || args.query || args.name, args);
4183
+ if (tool === "yandex_docs_share") return yandexDocsShare(args.path || args.remotePath || args.query || args.name, args);
4184
+ if (tool === "yandex_docs_rename") return yandexDocsRename(args.path || args.remotePath || args.query || args.name, args.name || args.newName || args.to, args);
4185
+ if (tool === "yandex_docs_delete") return yandexDocsDelete(args.path || args.remotePath || args.query || args.name, args);
4186
+ if (tool === "yandex_docs_save_answer") return yandexDocsCreateText({ ...args, text: args.text || args.answer || args.content });
4030
4187
  if (tool === "yandex_contacts_status") return yandexContactsStatus();
4031
4188
  if (tool === "yandex_contacts_list") return yandexContactsList(args);
4032
4189
  if (tool === "yandex_contacts_search") return yandexContactsSearch(args.query || "", args);
4190
+ if (tool === "yandex_contacts_get") return yandexContactsGet(args.query || args.name || args.email || args.phone || "", args);
4033
4191
  if (tool === "yandex_contacts_create") return yandexContactsCreate(args);
4192
+ if (tool === "yandex_contacts_update") return yandexContactsUpdate(args.query || args.name || args.email || "", args);
4193
+ if (tool === "yandex_contacts_delete") return yandexContactsDelete(args.query || args.name || args.email || "", args);
4034
4194
  if (tool === "yandex_contacts_add_email") return yandexContactsAddEmail(args.query || args.name || "", args.email, args);
4195
+ if (tool === "yandex_contacts_add_phone") return yandexContactsUpdate(args.query || args.name || "", { ...args, phone: args.phone, mode: "add-phone" });
4196
+ if (tool === "yandex_contacts_add_address") return yandexContactsUpdate(args.query || args.name || "", { ...args, address: args.address, mode: "add-address" });
4197
+ if (tool === "yandex_contacts_add_note") return yandexContactsUpdate(args.query || args.name || "", { ...args, note: args.note || args.text, mode: "add-note" });
4198
+ if (tool === "yandex_contacts_add_birthday") return yandexContactsUpdate(args.query || args.name || "", { ...args, birthday: args.birthday || args.date, mode: "add-birthday" });
4199
+ if (tool === "yandex_contacts_add_org") return yandexContactsUpdate(args.query || args.name || "", { ...args, org: args.org || args.organization, title: args.title || args.position, mode: "add-org" });
4200
+ if (tool === "yandex_contacts_remove_email") return yandexContactsUpdate(args.query || args.name || "", { ...args, removeEmail: args.email || true, mode: "remove-email" });
4201
+ if (tool === "yandex_contacts_remove_phone") return yandexContactsUpdate(args.query || args.name || "", { ...args, removePhone: args.phone || true, mode: "remove-phone" });
4202
+ if (tool === "yandex_contacts_export_vcard") return yandexContactsExport("vcard", args);
4203
+ if (tool === "yandex_contacts_export_csv") return yandexContactsExport("csv", args);
4204
+ if (tool === "yandex_contacts_import_vcard") return yandexContactsImport("vcard", args);
4205
+ if (tool === "yandex_contacts_import_csv") return yandexContactsImport("csv", args);
4206
+ if (tool === "yandex_contacts_find_incomplete") return yandexContactsFindIncomplete(args);
4207
+ if (tool === "yandex_contacts_find_duplicates") return yandexContactsFindDuplicates(args);
4208
+ if (tool === "yandex_contacts_backup_to_disk") return yandexContactsBackupToDisk(args);
4209
+ if (tool === "yandex_contacts_birthdays_to_calendar") return yandexContactsBirthdaysToCalendar(args);
4210
+ if (tool === "yandex_contact_send_mail") return yandexContactSendMail(args);
4211
+ if (tool === "yandex_contact_send_disk_link_qr") return yandexContactSendDiskLinkQr(args);
4212
+ if (tool === "yandex_contact_create_disk_folder") return yandexContactCreateDiskFolder(args);
4213
+ if (tool === "yandex_contact_create_calendar_event") return yandexContactCreateCalendarEvent(args);
4214
+ if (tool === "yandex_contact_create_telemost_event") return yandexContactCreateTelemostEvent(args);
4215
+ if (tool === "yandex_contact_from_public_entity") return yandexContactFromPublicEntity(args);
4216
+ if (tool === "yandex_telemost_status") return yandexTelemostStatus();
4035
4217
  if (tool === "yandex_telemost_create_event") return yandexTelemostCreateEvent(args);
4036
4218
  throw new Error(`Yandex tool неизвестен: ${tool}`);
4037
4219
  }
@@ -4203,6 +4385,78 @@ async function yandexMailWatchDisable() {
4203
4385
  return { enabled: false };
4204
4386
  }
4205
4387
 
4388
+ async function yandexContactsMaintenanceEnable(days = 7, options = {}) {
4389
+ const config = await loadConfig();
4390
+ const safeDays = Math.max(1, Number(days || 7));
4391
+ await saveConfig({
4392
+ yandex: {
4393
+ ...(config.yandex || {}),
4394
+ contactsMaintenance: {
4395
+ ...(config.yandex?.contactsMaintenance || {}),
4396
+ enabled: true,
4397
+ days: safeDays,
4398
+ backup: Boolean(options.backup),
4399
+ updatedAt: new Date().toISOString(),
4400
+ },
4401
+ },
4402
+ });
4403
+ await upsertCronJob(`каждые ${safeDays} дней`, "yandex contacts-maintenance tick", { replaceCommand: true });
4404
+ return { enabled: true, days: safeDays, backup: Boolean(options.backup) };
4405
+ }
4406
+
4407
+ async function yandexContactsMaintenanceDisable() {
4408
+ const config = await loadConfig();
4409
+ await saveConfig({
4410
+ yandex: {
4411
+ ...(config.yandex || {}),
4412
+ contactsMaintenance: {
4413
+ ...(config.yandex?.contactsMaintenance || {}),
4414
+ enabled: false,
4415
+ updatedAt: new Date().toISOString(),
4416
+ },
4417
+ },
4418
+ });
4419
+ deleteCronJobsByCommand("yandex contacts-maintenance tick");
4420
+ return { enabled: false };
4421
+ }
4422
+
4423
+ async function yandexContactsMaintenanceTick(options = {}) {
4424
+ const config = await loadConfig();
4425
+ const maintenance = config.yandex?.contactsMaintenance || {};
4426
+ if (!maintenance.enabled && !options.force) return { enabled: false };
4427
+ const contacts = await yandexContactsList({ limit: Number(options.limit || 1000) });
4428
+ const incomplete = await yandexContactsFindIncomplete({ limit: Number(options.limit || 1000) });
4429
+ const duplicates = await yandexContactsFindDuplicates({ limit: Number(options.limit || 1000) });
4430
+ let backupRemote = "";
4431
+ if (options.backup || maintenance.backup) {
4432
+ const backup = await yandexContactsBackupToDisk({ format: "csv", confirm: true, limit: Number(options.limit || 1000) });
4433
+ backupRemote = backup.remote || "";
4434
+ }
4435
+ await saveConfig({
4436
+ yandex: {
4437
+ ...(config.yandex || {}),
4438
+ contactsMaintenance: {
4439
+ ...maintenance,
4440
+ enabled: maintenance.enabled !== false,
4441
+ lastRunAt: new Date().toISOString(),
4442
+ lastTotal: contacts.length,
4443
+ lastIncomplete: incomplete.length,
4444
+ lastDuplicateGroups: duplicates.length,
4445
+ lastBackupRemote: backupRemote || maintenance.lastBackupRemote || "",
4446
+ },
4447
+ },
4448
+ });
4449
+ return {
4450
+ enabled: true,
4451
+ total: contacts.length,
4452
+ incomplete: incomplete.length,
4453
+ duplicateGroups: duplicates.length,
4454
+ backupRemote,
4455
+ incompletePreview: incomplete.slice(0, 10),
4456
+ duplicatePreview: duplicates.slice(0, 10),
4457
+ };
4458
+ }
4459
+
4206
4460
  async function yandexMailRead(uid, options = {}) {
4207
4461
  if (!uid) throw new Error("UID письма обязателен.");
4208
4462
  const { token, email } = await yandexMailCredentials();
@@ -4762,29 +5016,53 @@ async function yandexDavRequest(url, token, options = {}) {
4762
5016
 
4763
5017
  async function yandexCalendarStatus() {
4764
5018
  const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4765
- const url = await yandexCalendarBaseUrl(token);
5019
+ const calendars = await yandexCalendarCollections(token);
5020
+ const selected = pickYandexCalendar(calendars, {});
5021
+ const url = selected.url;
4766
5022
  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>" });
4767
- return { status: "ok", url, displayName: stripXmlTags(text.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "") || "calendar" };
5023
+ return { status: "ok", url, displayName: stripXmlTags(text.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "") || selected.name || "calendar", calendars: calendars.length };
5024
+ }
5025
+
5026
+ async function yandexCalendarCalendars(args = {}) {
5027
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
5028
+ const calendars = await yandexCalendarCollections(token);
5029
+ return calendars.slice(0, Number(args.limit || 50));
4768
5030
  }
4769
5031
 
4770
5032
  async function yandexCalendarCreateEvent(args = {}) {
4771
5033
  if (!args.confirm) throw new Error("Для создания события в календаре нужен аргумент confirm=true.");
4772
5034
  const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4773
- const baseUrl = await yandexCalendarBaseUrl(token);
5035
+ const baseUrl = await yandexCalendarBaseUrl(token, args);
4774
5036
  const uid = `${randomUUID()}@iola-cli`;
4775
5037
  const start = toIcsDate(args.start || args.date || new Date(Date.now() + 3600000).toISOString());
4776
5038
  const end = toIcsDate(args.end || new Date(Date.now() + 7200000).toISOString());
4777
5039
  const summary = args.title || args.summary || "Событие IOLA";
4778
5040
  const description = args.description || "";
4779
- const ics = buildIcsEvent({ uid, start, end, summary, description, location: args.location || "" });
5041
+ const ics = buildIcsEvent({
5042
+ uid,
5043
+ start,
5044
+ end,
5045
+ summary,
5046
+ description,
5047
+ location: args.location || "",
5048
+ attendees: args.attendees || args.to || [],
5049
+ rrule: args.rrule || buildIcsRrule(args),
5050
+ reminders: normalizeCalendarReminders(args.reminders || args.reminder || args.alarm),
5051
+ });
4780
5052
  const url = `${baseUrl}${encodeURIComponent(uid)}.ics`;
4781
5053
  await yandexDavRequest(url, token, { method: "PUT", ics: true, body: ics, timeout: 45000 });
4782
- return { status: "created", uid, title: summary, start: args.start || args.date || "", url };
5054
+ return { status: "calendar-event-created", uid, title: summary, start: args.start || args.date || "", end: args.end || "", url };
5055
+ }
5056
+
5057
+ async function yandexCalendarCreateRecurringEvent(args = {}) {
5058
+ if (!args.confirm) throw new Error("Для создания повторяющегося события нужен аргумент confirm=true.");
5059
+ const rrule = args.rrule || buildIcsRrule({ ...args, repeat: args.repeat || args.frequency || "weekly" });
5060
+ return yandexCalendarCreateEvent({ ...args, rrule, confirm: true });
4783
5061
  }
4784
5062
 
4785
5063
  async function yandexCalendarList(args = {}) {
4786
5064
  const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
4787
- const baseUrl = await yandexCalendarBaseUrl(token);
5065
+ const baseUrl = await yandexCalendarBaseUrl(token, args);
4788
5066
  const start = toIcsDate(args.start || new Date().toISOString());
4789
5067
  const end = toIcsDate(args.end || new Date(Date.now() + 14 * 86400000).toISOString());
4790
5068
  const body = `<?xml version="1.0"?>
@@ -4793,10 +5071,113 @@ async function yandexCalendarList(args = {}) {
4793
5071
  <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>
4794
5072
  </c:calendar-query>`;
4795
5073
  const text = await yandexDavRequest(baseUrl, token, { method: "REPORT", headers: { Depth: "1" }, xml: true, body, timeout: 45000 });
4796
- return parseIcsEvents(text).slice(0, Number(args.limit || 20));
5074
+ return parseIcsEvents(text, { baseUrl }).slice(0, Number(args.limit || 20));
4797
5075
  }
4798
5076
 
4799
- async function yandexCalendarBaseUrl(token) {
5077
+ async function yandexCalendarSearch(query, args = {}) {
5078
+ const needle = normalizeGeoText(query || args.query || args.title || "");
5079
+ const rows = await yandexCalendarList({
5080
+ ...args,
5081
+ start: args.start || new Date(Date.now() - 90 * 86400000).toISOString(),
5082
+ end: args.end || new Date(Date.now() + 365 * 86400000).toISOString(),
5083
+ limit: Math.max(200, Number(args.limit || 20) * 10),
5084
+ });
5085
+ if (!needle) return rows.slice(0, Number(args.limit || 20));
5086
+ return rows.filter((row) => normalizeGeoText([
5087
+ row.title,
5088
+ row.description,
5089
+ row.location,
5090
+ row.uid,
5091
+ ].filter(Boolean).join(" ")).includes(needle)).slice(0, Number(args.limit || 20));
5092
+ }
5093
+
5094
+ async function yandexCalendarGet(query, args = {}) {
5095
+ const resolved = await resolveYandexCalendarEvent(query, args);
5096
+ if (resolved.status !== "ok") return resolved;
5097
+ return stripCalendarPrivateFields(resolved.event);
5098
+ }
5099
+
5100
+ async function yandexCalendarUpdate(query, args = {}) {
5101
+ if (!args.confirm) throw new Error("Для изменения события нужен аргумент confirm=true.");
5102
+ const resolved = await resolveYandexCalendarEvent(query, args);
5103
+ if (resolved.status !== "ok") return resolved;
5104
+ const event = resolved.event;
5105
+ const start = toIcsDate(args.start || args.date || event.startIso || event.start);
5106
+ const end = toIcsDate(args.end || event.endIso || event.end || new Date(new Date(args.start || event.startIso || Date.now()).getTime() + 3600000).toISOString());
5107
+ const next = buildIcsEvent({
5108
+ uid: event.uid || `${randomUUID()}@iola-cli`,
5109
+ start,
5110
+ end,
5111
+ summary: args.title || args.summary || event.title || "Событие IOLA",
5112
+ description: args.description ?? event.description ?? "",
5113
+ location: args.location ?? event.location ?? "",
5114
+ attendees: args.attendees || args.to || event.attendees || [],
5115
+ rrule: args.rrule === null ? "" : (args.rrule || event.rrule || buildIcsRrule(args)),
5116
+ reminders: args.reminders || args.reminder || args.alarm ? normalizeCalendarReminders(args.reminders || args.reminder || args.alarm) : event.reminders || [],
5117
+ });
5118
+ await yandexDavRequest(event.url, await requireYandexOAuthToken("organizer", "Яндекс Календарь"), { method: "PUT", ics: true, body: next, timeout: 45000 });
5119
+ return { status: "calendar-event-updated", uid: event.uid, title: args.title || event.title, href: event.href };
5120
+ }
5121
+
5122
+ async function yandexCalendarMove(query, args = {}) {
5123
+ if (!args.confirm) throw new Error("Для переноса события нужен аргумент confirm=true.");
5124
+ const dateTime = args.start || args.date ? {} : extractDateTimeFromText(args.text || args.source_question || "");
5125
+ const start = args.start || args.date || dateTime.start;
5126
+ if (!start) throw new Error("Укажите новую дату/время события.");
5127
+ const end = args.end || dateTime.end || new Date(new Date(start).getTime() + 3600000).toISOString();
5128
+ return yandexCalendarUpdate(query, { ...args, start, end, confirm: true });
5129
+ }
5130
+
5131
+ async function yandexCalendarAddReminder(query, args = {}) {
5132
+ if (!args.confirm) throw new Error("Для добавления напоминания нужен аргумент confirm=true.");
5133
+ const minutes = Number(args.minutes || args.beforeMinutes || String(args.reminder || "").match(/\d+/u)?.[0] || 15);
5134
+ return yandexCalendarUpdate(query, { ...args, reminders: [`-${minutes}`], confirm: true });
5135
+ }
5136
+
5137
+ async function yandexCalendarDelete(query, args = {}) {
5138
+ if (!args.confirm) throw new Error("Для удаления события нужен аргумент confirm=true.");
5139
+ const resolved = await resolveYandexCalendarEvent(query, args);
5140
+ if (resolved.status !== "ok") return resolved;
5141
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
5142
+ await yandexDavRequest(resolved.event.url, token, { method: "DELETE", timeout: 45000 });
5143
+ return { status: "calendar-event-deleted", uid: resolved.event.uid, title: resolved.event.title, href: resolved.event.href };
5144
+ }
5145
+
5146
+ async function resolveYandexCalendarEvent(query, args = {}) {
5147
+ const uid = String(args.uid || "").trim();
5148
+ const href = String(args.href || "").trim();
5149
+ if (href) {
5150
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Календарь");
5151
+ const url = new URL(href, "https://caldav.yandex.ru/").toString();
5152
+ const ics = await yandexDavRequest(url, token, { method: "GET", timeout: 45000 });
5153
+ const event = parseIcsEvents(ics, { baseUrl: url.replace(/[^/]+$/u, "") })[0];
5154
+ return event ? { status: "ok", event: { ...event, href, url, ics } } : { status: "not-found", query: href };
5155
+ }
5156
+ const needle = normalizeGeoText(uid || query || args.query || args.title || "");
5157
+ const rows = await yandexCalendarSearch("", {
5158
+ ...args,
5159
+ start: args.startRange || args.start || new Date(Date.now() - 365 * 86400000).toISOString(),
5160
+ end: args.endRange || args.end || new Date(Date.now() + 365 * 86400000).toISOString(),
5161
+ limit: 500,
5162
+ });
5163
+ const matches = rows.filter((row) => {
5164
+ if (uid && row.uid === uid) return true;
5165
+ const haystack = normalizeGeoText([row.title, row.description, row.location, row.start, row.startIso, row.end, row.endIso, row.uid, row.href].filter(Boolean).join(" "));
5166
+ return needle && haystack.includes(needle);
5167
+ });
5168
+ if (!matches.length) return { status: "not-found", kind: "calendar-event", query: query || uid };
5169
+ if (matches.length > 1 && !args.selectFirst) return { status: "ambiguous", kind: "calendar-event", query: query || uid, events: matches.slice(0, 10).map(stripCalendarPrivateFields) };
5170
+ return { status: "ok", event: matches[0] };
5171
+ }
5172
+
5173
+ async function yandexCalendarBaseUrl(token, args = {}) {
5174
+ const calendars = await yandexCalendarCollections(token);
5175
+ const selected = pickYandexCalendar(calendars, args);
5176
+ if (!selected) throw new Error("В Яндекс Календаре не найдена календарная коллекция.");
5177
+ return selected.url;
5178
+ }
5179
+
5180
+ async function yandexCalendarCollections(token) {
4800
5181
  const root = "https://caldav.yandex.ru/";
4801
5182
  const principalXml = await yandexDavRequest(root, token, {
4802
5183
  method: "PROPFIND",
@@ -4820,9 +5201,99 @@ async function yandexCalendarBaseUrl(token) {
4820
5201
  xml: true,
4821
5202
  body: "<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>",
4822
5203
  });
4823
- const calendarHref = extractCalendarCollectionHref(listXml);
4824
- if (!calendarHref) throw new Error("В Яндекс Календаре не найдена календарная коллекция.");
4825
- return new URL(calendarHref, root).toString();
5204
+ return extractCalendarCollections(listXml).map((row) => ({ ...row, url: new URL(row.href, root).toString() }));
5205
+ }
5206
+
5207
+ function pickYandexCalendar(calendars, args = {}) {
5208
+ const query = normalizeGeoText(args.calendar || args.calendarName || args.name || "");
5209
+ if (query) {
5210
+ const found = calendars.find((row) => normalizeGeoText(`${row.name} ${row.href}`).includes(query));
5211
+ if (found) return found;
5212
+ }
5213
+ return calendars.find((row) => !/\/(?:inbox|outbox)\//iu.test(row.href)) || calendars[0] || null;
5214
+ }
5215
+
5216
+ async function yandexDocsStatus() {
5217
+ const info = await yandexDiskInfo();
5218
+ await ensureYandexDiskDir(`${CLOUD_DEFAULT_REMOTE_DIR}/docs`, { allowExisting: true });
5219
+ return { status: "ok", provider: "yandex-disk", folder: `${CLOUD_DEFAULT_REMOTE_DIR}/docs`, totalSpace: info.totalSpace, usedSpace: info.usedSpace };
5220
+ }
5221
+
5222
+ async function yandexDocsList(args = {}) {
5223
+ const folder = normalizeCloudUserPath(args.path || args.folder || `${CLOUD_DEFAULT_REMOTE_DIR}/docs`, "yandex-disk");
5224
+ const rows = await yandexDiskListRecursive(folder, { depth: Number(args.depth || 3), limit: Number(args.limit || 100) }).catch(() => []);
5225
+ return rows.filter(isYandexDocumentResource).slice(0, Number(args.limit || 50));
5226
+ }
5227
+
5228
+ async function yandexDocsFind(query, args = {}) {
5229
+ const needle = normalizeGeoText(query || args.query || "");
5230
+ const rows = await yandexDocsList({ ...args, limit: Math.max(200, Number(args.limit || 20) * 10) });
5231
+ if (!needle) return rows.slice(0, Number(args.limit || 20));
5232
+ return rows.filter((row) => normalizeGeoText(`${row.name} ${row.path}`).includes(needle)).slice(0, Number(args.limit || 20));
5233
+ }
5234
+
5235
+ async function yandexDocsCreateText(args = {}) {
5236
+ if (!args.confirm) throw new Error("Для создания документа нужен аргумент confirm=true.");
5237
+ const text = String(args.text || args.content || "").trim();
5238
+ if (!text) throw new Error("Текст документа пустой.");
5239
+ const ext = normalizeYandexDocExtension(args.format || args.ext || path.extname(args.path || args.remotePath || ""));
5240
+ const title = slugYandexDiskName(args.title || args.name || `document-${timestampForFile()}`);
5241
+ const remotePath = normalizeCloudUserPath(args.path || args.remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/docs/${title}${title.endsWith(ext) ? "" : ext}`, "yandex-disk");
5242
+ const saved = await yandexDiskSaveText(text, remotePath);
5243
+ return { ...saved, status: "document-created", title: path.posix.basename(remotePath), remote: remotePath };
5244
+ }
5245
+
5246
+ async function yandexDocsRead(target, args = {}) {
5247
+ const doc = await resolveYandexDoc(target, args);
5248
+ if (doc.status !== "ok") return doc;
5249
+ if (!/\.(txt|md|csv|json|html)$/iu.test(doc.path)) {
5250
+ return { status: "binary-document", remote: doc.path, name: doc.name, message: "Этот документ не текстовый. Его можно скачать, переименовать, удалить или опубликовать ссылкой." };
5251
+ }
5252
+ return yandexDiskReadText(doc.path, args);
5253
+ }
5254
+
5255
+ async function yandexDocsShare(target, args = {}) {
5256
+ if (!args.confirm) throw new Error("Для публикации документа нужен аргумент confirm=true.");
5257
+ const doc = await resolveYandexDoc(target, args);
5258
+ if (doc.status !== "ok") return doc;
5259
+ return yandexDiskShareWithQr(doc.path, { ...args, confirm: true });
5260
+ }
5261
+
5262
+ async function yandexDocsRename(target, newName, args = {}) {
5263
+ if (!args.confirm) throw new Error("Для переименования документа нужен аргумент confirm=true.");
5264
+ const doc = await resolveYandexDoc(target, args);
5265
+ if (doc.status !== "ok") return doc;
5266
+ if (!newName) throw new Error("Укажите новое имя документа.");
5267
+ return yandexDiskRename(doc.path, newName, { ...args, confirm: true, overwrite: args.overwrite !== false });
5268
+ }
5269
+
5270
+ async function yandexDocsDelete(target, args = {}) {
5271
+ if (!args.confirm) throw new Error("Для удаления документа нужен аргумент confirm=true.");
5272
+ const doc = await resolveYandexDoc(target, args);
5273
+ if (doc.status !== "ok") return doc;
5274
+ return yandexDiskDelete(doc.path, args);
5275
+ }
5276
+
5277
+ async function resolveYandexDoc(target, args = {}) {
5278
+ const value = String(target || "").trim();
5279
+ if (value.startsWith("/")) {
5280
+ const statRow = await yandexDiskStat(value);
5281
+ return isYandexDocumentResource(statRow) ? { status: "ok", ...statRow } : { status: "not-document", path: value };
5282
+ }
5283
+ const rows = await yandexDocsFind(value, { ...args, limit: 10 });
5284
+ if (!rows.length) return { status: "not-found", query: value };
5285
+ if (rows.length > 1 && !args.selectFirst) return { status: "ambiguous", query: value, docs: rows.slice(0, 10) };
5286
+ return { status: "ok", ...rows[0] };
5287
+ }
5288
+
5289
+ function isYandexDocumentResource(row = {}) {
5290
+ return row.type !== "dir" && /\.(docx|xlsx|pptx|pdf|txt|md|html|csv|json)$/iu.test(row.name || row.path || "");
5291
+ }
5292
+
5293
+ function normalizeYandexDocExtension(value) {
5294
+ const ext = String(value || "").replace(/^\./u, "").toLocaleLowerCase("en-US");
5295
+ if (["txt", "md", "html", "csv", "json"].includes(ext)) return `.${ext}`;
5296
+ return ".md";
4826
5297
  }
4827
5298
 
4828
5299
  async function yandexContactsStatus() {
@@ -4848,9 +5319,9 @@ async function yandexContactsList(args = {}) {
4848
5319
 
4849
5320
  async function yandexContactsSearch(query, args = {}) {
4850
5321
  const normalized = normalizeGeoText(query);
4851
- const rows = await yandexContactsList({ limit: Math.max(100, Number(args.limit || 20) * 4) });
5322
+ const rows = await yandexContactsList({ limit: Math.max(1000, Number(args.limit || 20) * 10) });
4852
5323
  if (!normalized) return rows.slice(0, Number(args.limit || 20));
4853
- return rows.filter((row) => normalizeGeoText(`${row.name} ${row.email} ${row.phone}`).includes(normalized)).slice(0, Number(args.limit || 20));
5324
+ return rows.filter((row) => contactMatchesQuery(row, normalized)).slice(0, Number(args.limit || 20));
4854
5325
  }
4855
5326
 
4856
5327
  async function resolveYandexMailRecipientFromContacts(query) {
@@ -4871,58 +5342,397 @@ async function yandexContactsAddEmail(query, email, args = {}) {
4871
5342
  if (!args.confirm) throw new Error("Для изменения контакта нужен аргумент confirm=true.");
4872
5343
  if (!query) throw new Error("Укажите имя или часть имени контакта.");
4873
5344
  if (!email || !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(String(email))) throw new Error("Укажите корректный email.");
4874
- const rows = await yandexContactsList({ limit: 500, full: true });
4875
- const matches = rows.filter((row) => contactMatchesQuery(row, normalizeContactLookupText(query))).slice(0, 10);
4876
- if (!matches.length) return { status: "not-found", query };
4877
- if (matches.length > 1 && !args.selectFirst) return { status: "ambiguous", query, contacts: matches.map(({ card, ...row }) => row) };
4878
- const contact = matches[0];
4879
- if (contact.email && !args.overwrite) return { status: "has-email", contact: { name: contact.name, email: contact.email } };
4880
- const updatedCard = upsertVcardEmail(contact.card, email, { overwrite: Boolean(args.overwrite) });
5345
+ const result = await yandexContactsUpdate(query, { ...args, email, mode: "add-email" });
5346
+ if (result.status === "updated") return { status: "updated", name: result.name, email };
5347
+ return result;
5348
+ }
5349
+
5350
+ async function yandexContactsGet(query, args = {}) {
5351
+ const resolved = await resolveYandexContact(query, args);
5352
+ if (resolved.status !== "ok") return resolved;
5353
+ const { card: _card, ...contact } = resolved.contact;
5354
+ return { status: "ok", ...contact };
5355
+ }
5356
+
5357
+ async function yandexContactsUpdate(query, args = {}) {
5358
+ if (!args.confirm) throw new Error("Для изменения контакта нужен аргумент confirm=true.");
5359
+ if (!query) throw new Error("Укажите имя, email или телефон контакта.");
5360
+ const resolved = await resolveYandexContact(query, args);
5361
+ if (resolved.status !== "ok") return resolved;
5362
+ const contact = resolved.contact;
5363
+ let card = contact.card;
5364
+ if (args.name || args.fullName) {
5365
+ card = upsertVcardProperty(card, "FN", args.name || args.fullName, { replace: true });
5366
+ card = upsertVcardProperty(card, "N", `${escapeVcardValue(args.name || args.fullName)};;;;`, { replace: true, raw: true });
5367
+ }
5368
+ if (args.email) card = upsertVcardProperty(card, "EMAIL;TYPE=INTERNET", String(args.email).trim(), { replace: args.overwriteEmail || args.overwrite || args.mode === "set-email" });
5369
+ if (args.phone) card = upsertVcardProperty(card, "TEL;TYPE=CELL", String(args.phone).trim(), { replace: args.overwritePhone || args.overwrite || args.mode === "set-phone" });
5370
+ if (args.address) card = upsertVcardProperty(card, "ADR;TYPE=HOME", `;;;;${escapeVcardValue(args.address)};;`, { replace: args.overwriteAddress || args.overwrite || args.mode === "set-address", raw: true });
5371
+ if (args.note) card = upsertVcardProperty(card, "NOTE", args.note, { replace: args.overwriteNote || args.overwrite || args.mode === "set-note" });
5372
+ if (args.birthday) card = upsertVcardProperty(card, "BDAY", normalizeVcardBirthday(args.birthday), { replace: true });
5373
+ if (args.org) card = upsertVcardProperty(card, "ORG", args.org, { replace: args.overwriteOrg || args.overwrite || args.mode === "set-org" });
5374
+ if (args.title) card = upsertVcardProperty(card, "TITLE", args.title, { replace: true });
5375
+ if (args.categories || args.group) card = upsertVcardProperty(card, "CATEGORIES", Array.isArray(args.categories) ? args.categories.join(",") : (args.categories || args.group), { replace: Boolean(args.overwriteCategories) });
5376
+ if (args.removeEmail) card = removeVcardProperty(card, "EMAIL", args.removeEmail === true ? "" : args.removeEmail);
5377
+ if (args.removePhone) card = removeVcardProperty(card, "TEL", args.removePhone === true ? "" : args.removePhone);
5378
+ if (card === contact.card) return { status: "unchanged", name: contact.name, email: contact.email, phone: contact.phone };
5379
+ await saveYandexContactCard(contact.href, card);
5380
+ const parsed = parseVCards(card)[0] || {};
5381
+ return { status: "updated", ...parsed, href: contact.href };
5382
+ }
5383
+
5384
+ async function yandexContactsDelete(query, args = {}) {
5385
+ if (!args.confirm) throw new Error("Для удаления контакта нужен аргумент confirm=true.");
5386
+ const resolved = await resolveYandexContact(query, args);
5387
+ if (resolved.status !== "ok") return resolved;
4881
5388
  const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
4882
- await yandexDavRequest(new URL(contact.href, "https://carddav.yandex.ru/").toString(), token, {
4883
- method: "PUT",
4884
- headers: { "content-type": "text/vcard; charset=utf-8" },
4885
- body: updatedCard,
4886
- timeout: 45000,
4887
- });
4888
- return { status: "updated", name: contact.name, email };
5389
+ await yandexDavRequest(new URL(resolved.contact.href, "https://carddav.yandex.ru/").toString(), token, { method: "DELETE", timeout: 45000 });
5390
+ return { status: "deleted", name: resolved.contact.name, email: resolved.contact.email, phone: resolved.contact.phone };
4889
5391
  }
4890
5392
 
4891
5393
  async function yandexContactsCreate(args = {}) {
4892
5394
  if (!args.confirm) throw new Error("Для создания контакта нужен аргумент confirm=true.");
4893
5395
  const name = String(args.name || args.email || "").trim();
4894
5396
  const email = String(args.email || "").trim();
5397
+ const phone = String(args.phone || "").trim();
4895
5398
  if (!name) throw new Error("Имя контакта обязательно.");
4896
- if (!email || !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email)) throw new Error("Корректный email обязателен.");
5399
+ if (email && !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email)) throw new Error("Укажите корректный email.");
5400
+ if (!email && !phone) throw new Error("Для контакта нужен хотя бы email или телефон.");
4897
5401
  const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
4898
5402
  const baseUrl = await yandexContactsBaseUrl(token);
4899
5403
  const uid = `${randomUUID()}@iola-cli`;
4900
- const card = [
4901
- "BEGIN:VCARD",
4902
- "VERSION:3.0",
4903
- `UID:${uid}`,
4904
- `FN:${escapeVcardValue(name)}`,
4905
- `EMAIL;TYPE=INTERNET:${email}`,
4906
- "END:VCARD",
4907
- "",
4908
- ].join("\r\n");
5404
+ const card = buildVcard({
5405
+ uid,
5406
+ name,
5407
+ email,
5408
+ phone,
5409
+ address: args.address,
5410
+ note: args.note,
5411
+ birthday: args.birthday,
5412
+ org: args.org || args.organization,
5413
+ title: args.title || args.position,
5414
+ categories: args.categories || args.group,
5415
+ });
4909
5416
  await yandexDavRequest(new URL(`${encodeURIComponent(uid)}.vcf`, baseUrl).toString(), token, {
4910
5417
  method: "PUT",
4911
5418
  headers: { "content-type": "text/vcard; charset=utf-8" },
4912
5419
  body: card,
4913
5420
  timeout: 45000,
4914
5421
  });
4915
- return { status: "created", name, email, uid };
5422
+ return { status: "created", name, email, phone, uid };
5423
+ }
5424
+
5425
+ async function yandexContactsExport(format, args = {}) {
5426
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000), full: true });
5427
+ const safeFormat = format === "csv" ? "csv" : "vcard";
5428
+ const output = args.output || path.join(CONFIG_DIR, `yandex-contacts-${timestampForFile()}.${safeFormat === "csv" ? "csv" : "vcf"}`);
5429
+ const text = safeFormat === "csv"
5430
+ ? contactsToCsv(rows)
5431
+ : rows.map((row) => row.card).filter(Boolean).join("\r\n");
5432
+ await mkdir(path.dirname(path.resolve(output)), { recursive: true });
5433
+ await writeFile(output, text, "utf8");
5434
+ saveArtifact("yandex-contacts-export", output, output, { rows: rows.length, format: safeFormat });
5435
+ return { status: "exported", output: path.resolve(output), rows: rows.length, format: safeFormat };
5436
+ }
5437
+
5438
+ async function yandexContactsFindIncomplete(args = {}) {
5439
+ const rows = await yandexContactsList({ limit: Number(args.limit || 500) });
5440
+ return rows.filter((row) => {
5441
+ if (args.field === "email") return !row.email;
5442
+ if (args.field === "phone") return !row.phone;
5443
+ if (args.field === "address") return !row.address;
5444
+ return !row.email || !row.phone || !row.name;
5445
+ }).slice(0, Number(args.limit || 50));
5446
+ }
5447
+
5448
+ async function yandexContactsFindDuplicates(args = {}) {
5449
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000) });
5450
+ const groups = new Map();
5451
+ for (const row of rows) {
5452
+ const keys = [
5453
+ row.email ? `email:${row.email.toLocaleLowerCase("en-US")}` : "",
5454
+ row.phone ? `phone:${normalizePhone(row.phone)}` : "",
5455
+ row.name ? `name:${normalizeContactLookupText(row.name)}` : "",
5456
+ ].filter((key) => key && !key.endsWith(":"));
5457
+ for (const key of keys) {
5458
+ if (!groups.has(key)) groups.set(key, []);
5459
+ groups.get(key).push(row);
5460
+ }
5461
+ }
5462
+ const seen = new Set();
5463
+ const duplicates = [];
5464
+ for (const [key, group] of groups.entries()) {
5465
+ if (group.length < 2) continue;
5466
+ const signature = group.map((row) => row.href || `${row.name}:${row.email}:${row.phone}`).sort().join("|");
5467
+ if (seen.has(signature)) continue;
5468
+ seen.add(signature);
5469
+ duplicates.push({ status: "duplicate-group", key, count: group.length, contacts: group.slice(0, 10) });
5470
+ }
5471
+ return duplicates.slice(0, Number(args.limit || 20));
5472
+ }
5473
+
5474
+ async function yandexContactsBackupToDisk(args = {}) {
5475
+ if (!args.confirm) throw new Error("Для резервной копии контактов на Диск нужен аргумент confirm=true.");
5476
+ const format = args.format === "csv" ? "csv" : "vcard";
5477
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000), full: true });
5478
+ const remotePath = args.path || `${CLOUD_DEFAULT_REMOTE_DIR}/contacts/yandex-contacts-${timestampForFile()}.${format === "csv" ? "csv" : "vcf"}`;
5479
+ const text = format === "csv" ? contactsToCsv(rows) : rows.map((row) => row.card).filter(Boolean).join("\r\n");
5480
+ const saved = await yandexDiskSaveText(text, remotePath);
5481
+ return { provider: "yandex-disk", status: "contacts-backup", remote: saved.remote, rows: rows.length, format };
5482
+ }
5483
+
5484
+ async function yandexContactsImport(format, args = {}) {
5485
+ if (!args.confirm) throw new Error("Для импорта контактов нужен аргумент confirm=true.");
5486
+ const inputPath = args.path || args.file || args.input;
5487
+ if (!inputPath) throw new Error("Укажите файл для импорта.");
5488
+ const text = await readFile(path.resolve(inputPath), "utf8");
5489
+ const rows = format === "csv" ? parseContactsCsv(text) : parseVCards(text);
5490
+ const existing = await yandexContactsList({ limit: 1000 });
5491
+ const existingEmails = new Set(existing.flatMap((row) => row.emails || []).map((email) => email.toLocaleLowerCase("en-US")));
5492
+ const existingPhones = new Set(existing.flatMap((row) => row.phones || []).map(normalizePhone).filter(Boolean));
5493
+ const created = [];
5494
+ const skipped = [];
5495
+ for (const row of rows.slice(0, Number(args.limit || 200))) {
5496
+ const email = row.email || row.emails?.[0] || "";
5497
+ const phone = row.phone || row.phones?.[0] || "";
5498
+ const emailKey = email.toLocaleLowerCase("en-US");
5499
+ const phoneKey = normalizePhone(phone);
5500
+ if ((!email && !phone) || (emailKey && existingEmails.has(emailKey)) || (phoneKey && existingPhones.has(phoneKey))) {
5501
+ skipped.push(row);
5502
+ continue;
5503
+ }
5504
+ const createdRow = await yandexContactsCreate({
5505
+ name: row.name || email || phone,
5506
+ email,
5507
+ phone,
5508
+ address: row.address,
5509
+ note: row.note,
5510
+ birthday: row.birthday,
5511
+ org: row.org,
5512
+ title: row.title,
5513
+ categories: row.categories,
5514
+ confirm: true,
5515
+ });
5516
+ created.push(createdRow);
5517
+ if (emailKey) existingEmails.add(emailKey);
5518
+ if (phoneKey) existingPhones.add(phoneKey);
5519
+ }
5520
+ return { status: "contacts-imported", format, input: path.resolve(inputPath), created: created.length, skipped: skipped.length };
5521
+ }
5522
+
5523
+ async function yandexContactsBirthdaysToCalendar(args = {}) {
5524
+ if (!args.confirm) throw new Error("Для создания событий дней рождения нужен аргумент confirm=true.");
5525
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000) });
5526
+ const withBirthday = rows.filter((row) => row.birthday);
5527
+ const created = [];
5528
+ for (const contact of withBirthday.slice(0, Number(args.maxCreate || 50))) {
5529
+ const next = nextBirthdayDate(contact.birthday);
5530
+ if (!next) continue;
5531
+ const result = await yandexCalendarCreateEvent({
5532
+ title: `День рождения: ${contact.name || contact.email || contact.phone}`,
5533
+ start: next.start,
5534
+ end: next.end,
5535
+ description: `Контакт: ${formatYandexContact(contact)}`,
5536
+ confirm: true,
5537
+ });
5538
+ created.push(result);
5539
+ }
5540
+ return { status: "birthday-events-created", created: created.length, totalWithBirthday: withBirthday.length };
5541
+ }
5542
+
5543
+ async function yandexContactSendMail(args = {}) {
5544
+ if (!args.confirm) throw new Error("Для отправки письма контакту нужен аргумент confirm=true.");
5545
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5546
+ if (resolved.status !== "ok") return resolved;
5547
+ const contact = resolved.contact;
5548
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5549
+ const sent = await yandexMailSend({
5550
+ to: [contact.email],
5551
+ subject: args.subject || "Сообщение от IOLA CLI",
5552
+ text: args.text || args.message || "",
5553
+ confirm: true,
5554
+ });
5555
+ return { status: "contact-mail-sent", contact: contact.name || contact.email, to: sent.to, subject: sent.subject };
5556
+ }
5557
+
5558
+ async function yandexContactSendDiskLinkQr(args = {}) {
5559
+ if (!args.confirm) throw new Error("Для отправки ссылки контакту нужен аргумент confirm=true.");
5560
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5561
+ if (resolved.status !== "ok") return resolved;
5562
+ const contact = resolved.contact;
5563
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5564
+ const result = await yandexDiskShareEmail({
5565
+ remotePath: args.path || args.remotePath || args.target,
5566
+ to: [contact.email],
5567
+ subject: args.subject,
5568
+ text: args.text || args.message,
5569
+ confirm: true,
5570
+ });
5571
+ return { ...result, status: "contact-disk-link-sent", contact: contact.name || contact.email };
5572
+ }
5573
+
5574
+ async function yandexContactCreateDiskFolder(args = {}) {
5575
+ if (!args.confirm) throw new Error("Для создания папки контакта нужен аргумент confirm=true.");
5576
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5577
+ if (resolved.status !== "ok") return resolved;
5578
+ const contact = resolved.contact;
5579
+ const folder = args.path || `${CLOUD_DEFAULT_REMOTE_DIR}/contacts/${slugYandexDiskName(contact.name || contact.email || contact.phone || "contact")}`;
5580
+ await cloudCreateFolder("yandex-disk", folder);
5581
+ const cardPath = path.posix.join(folder, "contact.vcf");
5582
+ await yandexDiskSaveText(contact.card, cardPath);
5583
+ const note = [
5584
+ `Контакт: ${contact.name || "-"}`,
5585
+ `Email: ${(contact.emails || []).join(", ") || "-"}`,
5586
+ `Телефон: ${(contact.phones || []).join(", ") || "-"}`,
5587
+ contact.address ? `Адрес: ${contact.address}` : "",
5588
+ contact.org ? `Организация: ${contact.org}` : "",
5589
+ contact.title ? `Должность: ${contact.title}` : "",
5590
+ contact.note ? `Заметка: ${contact.note}` : "",
5591
+ ].filter(Boolean).join("\n");
5592
+ await yandexDiskSaveText(note, path.posix.join(folder, "README.txt"));
5593
+ return { provider: "yandex-disk", status: "contact-folder-created", contact: contact.name || contact.email, remote: folder, cardPath };
5594
+ }
5595
+
5596
+ async function yandexContactCreateCalendarEvent(args = {}) {
5597
+ if (!args.confirm) throw new Error("Для создания встречи с контактом нужен аргумент confirm=true.");
5598
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5599
+ if (resolved.status !== "ok") return resolved;
5600
+ const contact = resolved.contact;
5601
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5602
+ const result = await yandexCalendarCreateEvent({
5603
+ ...args,
5604
+ title: args.title || `Встреча: ${contact.name || contact.email}`,
5605
+ description: [args.description || "", `Контакт: ${contact.name || "-"}`, `Email: ${contact.email}`].filter(Boolean).join("\n"),
5606
+ attendees: [contact.email],
5607
+ confirm: true,
5608
+ });
5609
+ return { ...result, status: "contact-calendar-event-created", contact: contact.name || contact.email, attendee: contact.email };
5610
+ }
5611
+
5612
+ async function yandexContactCreateTelemostEvent(args = {}) {
5613
+ const result = await yandexContactCreateCalendarEvent({
5614
+ ...args,
5615
+ title: args.title || `Телемост: ${args.contact || args.name || args.query || "контакт"}`,
5616
+ description: [args.description || "", "Телемост: создайте ссылку в Яндекс Календаре, если интерфейс календаря предложит видеовстречу."].filter(Boolean).join("\n"),
5617
+ confirm: true,
5618
+ });
5619
+ return { ...result, status: "contact-telemost-event-created" };
5620
+ }
5621
+
5622
+ async function yandexContactFromPublicEntity(args = {}) {
5623
+ if (!args.confirm) throw new Error("Для создания контакта из городского слоя нужен аргумент confirm=true.");
5624
+ const layer = normalizeEntityLayer(args.layer || (/(сад|детсад)/iu.test(args.query || "") ? "kindergartens" : "schools"));
5625
+ const sourceQuery = args.query || args.name || args.inn || "";
5626
+ const number = String(sourceQuery).match(/№?\s*(\d{1,4})/u)?.[1] || "";
5627
+ const query = number
5628
+ ? (layer === "kindergartens" ? `детский сад ${number}` : `школа ${number}`)
5629
+ : sourceQuery;
5630
+ const rows = await searchPublicEntities({ layer, query, limit: 5 });
5631
+ if (!rows.length) return { status: "not-found", query: args.query || args.name || args.inn || "" };
5632
+ const entity = rows[0];
5633
+ const name = args.contactName || entity.name || entity.fns_short_name || args.query;
5634
+ const email = entity.email || "";
5635
+ const phone = entity.phone || "";
5636
+ if (!email && !phone) return { status: "no-contact-fields", entity };
5637
+ return yandexContactsCreate({
5638
+ name,
5639
+ email,
5640
+ phone,
5641
+ address: entity.address,
5642
+ org: entity.name,
5643
+ note: `Создано из открытого слоя ${layer}. ИНН: ${entity.inn || "-"}.`,
5644
+ confirm: true,
5645
+ });
5646
+ }
5647
+
5648
+ async function resolveYandexContact(query, args = {}) {
5649
+ const rows = await yandexContactsList({ limit: Math.max(500, Number(args.limit || 100)), full: true });
5650
+ const normalized = normalizeContactLookupText(query || args.query || args.name || args.email || args.phone || "");
5651
+ const matches = rows.filter((row) => contactMatchesQuery(row, normalized)).slice(0, 20);
5652
+ if (!matches.length) return { status: "not-found", query };
5653
+ const exact = pickExactYandexContactMatch(matches, query, args);
5654
+ if (exact) return { status: "ok", contact: exact };
5655
+ if (matches.length > 1 && !args.selectFirst) return { status: "ambiguous", query, contacts: matches.map(stripYandexContactPrivateFields) };
5656
+ return { status: "ok", contact: matches[0] };
5657
+ }
5658
+
5659
+ function pickExactYandexContactMatch(matches, query, args = {}) {
5660
+ const text = String(query || args.email || args.phone || args.name || "").trim();
5661
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0]?.toLocaleLowerCase("en-US");
5662
+ if (email) return matches.find((row) => row.emails?.some((item) => item.toLocaleLowerCase("en-US") === email));
5663
+ const phone = normalizePhone(args.phone || text);
5664
+ if (phone.length >= 7) return matches.find((row) => row.phones?.some((item) => normalizePhone(item).endsWith(phone) || phone.endsWith(normalizePhone(item))));
5665
+ const normalized = normalizeContactLookupText(text);
5666
+ return matches.find((row) => normalizeContactLookupText(row.name) === normalized) || null;
5667
+ }
5668
+
5669
+ function stripYandexContactPrivateFields(row = {}) {
5670
+ const { card: _card, ...rest } = row;
5671
+ return rest;
5672
+ }
5673
+
5674
+ async function saveYandexContactCard(href, card) {
5675
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
5676
+ await yandexDavRequest(new URL(href, "https://carddav.yandex.ru/").toString(), token, {
5677
+ method: "PUT",
5678
+ headers: { "content-type": "text/vcard; charset=utf-8" },
5679
+ body: ensureVcardCrlf(card),
5680
+ timeout: 45000,
5681
+ });
5682
+ }
5683
+
5684
+ function buildVcard(args = {}) {
5685
+ const uid = args.uid || `${randomUUID()}@iola-cli`;
5686
+ const lines = [
5687
+ "BEGIN:VCARD",
5688
+ "VERSION:3.0",
5689
+ `UID:${uid}`,
5690
+ `FN:${escapeVcardValue(args.name || args.email || args.phone || "Контакт")}`,
5691
+ `N:${escapeVcardValue(args.name || args.email || args.phone || "Контакт")};;;;`,
5692
+ args.email ? `EMAIL;TYPE=INTERNET:${String(args.email).trim()}` : "",
5693
+ args.phone ? `TEL;TYPE=CELL:${escapeVcardValue(args.phone)}` : "",
5694
+ args.address ? `ADR;TYPE=HOME:;;;;${escapeVcardValue(args.address)};;` : "",
5695
+ args.org ? `ORG:${escapeVcardValue(args.org)}` : "",
5696
+ args.title ? `TITLE:${escapeVcardValue(args.title)}` : "",
5697
+ args.birthday ? `BDAY:${normalizeVcardBirthday(args.birthday)}` : "",
5698
+ args.note ? `NOTE:${escapeVcardValue(args.note)}` : "",
5699
+ args.categories ? `CATEGORIES:${escapeVcardValue(Array.isArray(args.categories) ? args.categories.join(",") : args.categories)}` : "",
5700
+ "END:VCARD",
5701
+ "",
5702
+ ].filter((line) => line !== "");
5703
+ return lines.join("\r\n");
4916
5704
  }
4917
5705
 
4918
5706
  function upsertVcardEmail(card, email, options = {}) {
5707
+ return upsertVcardProperty(card, "EMAIL;TYPE=INTERNET", email, { replace: Boolean(options.overwrite) });
5708
+ }
5709
+
5710
+ function upsertVcardProperty(card, property, value, options = {}) {
4919
5711
  const text = String(card || "").replace(/\r/g, "").trim();
4920
5712
  if (!text.includes("BEGIN:VCARD")) throw new Error("Контакт не похож на vCard.");
4921
- if (/^EMAIL[^:]*:/imu.test(text)) {
4922
- if (!options.overwrite) return text;
4923
- return text.replace(/^EMAIL[^:]*:[^\n]*/imu, `EMAIL;TYPE=INTERNET:${email}`);
5713
+ const propName = String(property || "").split(";")[0].toLocaleUpperCase("en-US");
5714
+ const rawValue = options.raw ? String(value || "") : escapeVcardValue(value);
5715
+ if (!rawValue) return text;
5716
+ const line = `${property}:${rawValue}`;
5717
+ const pattern = new RegExp(`^${escapeRegExp(propName)}[^:]*:[^\\n]*`, "imu");
5718
+ if (pattern.test(text)) {
5719
+ if (!options.replace) return text;
5720
+ return text.replace(pattern, line);
4924
5721
  }
4925
- return text.replace(/\nEND:VCARD/iu, `\nEMAIL;TYPE=INTERNET:${email}\nEND:VCARD`);
5722
+ return text.replace(/\nEND:VCARD/iu, `\n${line}\nEND:VCARD`);
5723
+ }
5724
+
5725
+ function removeVcardProperty(card, property, value = "") {
5726
+ const text = String(card || "").replace(/\r/g, "").trim();
5727
+ const propName = String(property || "").split(";")[0].toLocaleUpperCase("en-US");
5728
+ const lines = text.split(/\n/u);
5729
+ const needle = String(value || "").toLocaleLowerCase("ru-RU");
5730
+ const next = lines.filter((line) => {
5731
+ if (!new RegExp(`^${escapeRegExp(propName)}(?:[;:]|$)`, "iu").test(line)) return true;
5732
+ if (!needle) return false;
5733
+ return !line.toLocaleLowerCase("ru-RU").includes(needle);
5734
+ });
5735
+ return next.join("\n");
4926
5736
  }
4927
5737
 
4928
5738
  function escapeVcardValue(value) {
@@ -4930,10 +5740,28 @@ function escapeVcardValue(value) {
4930
5740
  }
4931
5741
 
4932
5742
  function contactMatchesQuery(contact, normalizedQuery) {
4933
- const contactText = normalizeContactLookupText(`${contact.name || ""} ${contact.email || ""}`);
4934
- if (!contactText || !normalizedQuery) return false;
4935
- if (contactText.includes(normalizedQuery)) return true;
4936
- const queryTokens = normalizedQuery.split(/\s+/u).filter(Boolean);
5743
+ const query = normalizeContactLookupText(normalizedQuery);
5744
+ const contactText = normalizeContactLookupText([
5745
+ contact.name,
5746
+ contact.email,
5747
+ ...(contact.emails || []),
5748
+ contact.phone,
5749
+ ...(contact.phones || []),
5750
+ contact.address,
5751
+ contact.org,
5752
+ contact.title,
5753
+ contact.note,
5754
+ contact.categories,
5755
+ ].filter(Boolean).join(" "));
5756
+ const queryPhone = normalizePhone(normalizedQuery);
5757
+ if (queryPhone.length >= 7 && [...(contact.phones || []), contact.phone].some((phone) => {
5758
+ const contactPhone = normalizePhone(phone);
5759
+ return contactPhone.length >= 7 && (contactPhone.includes(queryPhone) || queryPhone.includes(contactPhone));
5760
+ })) return true;
5761
+ const normalizedQueryText = query;
5762
+ if (!contactText || !normalizedQueryText) return false;
5763
+ if (contactText.includes(normalizedQueryText)) return true;
5764
+ const queryTokens = normalizedQueryText.split(/\s+/u).filter(Boolean);
4937
5765
  const contactTokens = contactText.split(/\s+/u).filter(Boolean);
4938
5766
  return queryTokens.every((queryToken) => contactTokens.some((contactToken) => contactToken.startsWith(queryToken.slice(0, Math.max(4, Math.min(queryToken.length, 6)))) || queryToken.startsWith(contactToken.slice(0, Math.max(4, Math.min(contactToken.length, 6))))));
4939
5767
  }
@@ -4942,6 +5770,99 @@ function normalizeContactLookupText(value) {
4942
5770
  return normalizeGeoText(String(value || "").replace(/\b(?:кому|контакт|письмо|сообщение)\b/giu, " ")).trim();
4943
5771
  }
4944
5772
 
5773
+ function normalizePhone(value) {
5774
+ return String(value || "").replace(/[^\d]+/g, "");
5775
+ }
5776
+
5777
+ function normalizeVcardBirthday(value) {
5778
+ const text = String(value || "").trim();
5779
+ const iso = text.match(/^(\d{4})-(\d{2})-(\d{2})$/u);
5780
+ if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
5781
+ const ru = text.match(/^(\d{1,2})[.\-/](\d{1,2})(?:[.\-/](\d{2,4}))?$/u);
5782
+ if (ru) {
5783
+ const year = ru[3] ? (ru[3].length === 2 ? `20${ru[3]}` : ru[3]) : "1900";
5784
+ return `${year}-${String(ru[2]).padStart(2, "0")}-${String(ru[1]).padStart(2, "0")}`;
5785
+ }
5786
+ return text;
5787
+ }
5788
+
5789
+ function contactsToCsv(rows) {
5790
+ const headers = ["name", "email", "emails", "phone", "phones", "address", "org", "title", "birthday", "note", "categories"];
5791
+ return [
5792
+ headers.join(","),
5793
+ ...rows.map((row) => headers.map((key) => contactsCsvCell(Array.isArray(row[key]) ? row[key].join("; ") : row[key])).join(",")),
5794
+ ].join("\n");
5795
+ }
5796
+
5797
+ function contactsCsvCell(value) {
5798
+ return `"${String(value || "").replace(/"/g, '""')}"`;
5799
+ }
5800
+
5801
+ function parseContactsCsv(text) {
5802
+ const rows = parseSimpleCsv(text);
5803
+ return rows.map((row) => ({
5804
+ name: row.name || row.Name || row["Имя"] || row["ФИО"] || "",
5805
+ email: row.email || row.Email || row["Почта"] || row["Email"] || "",
5806
+ phone: row.phone || row.Phone || row["Телефон"] || "",
5807
+ address: row.address || row.Address || row["Адрес"] || "",
5808
+ org: row.org || row.organization || row["Организация"] || "",
5809
+ title: row.title || row.position || row["Должность"] || "",
5810
+ birthday: row.birthday || row.Birthday || row["День рождения"] || "",
5811
+ note: row.note || row.Note || row["Заметка"] || "",
5812
+ categories: row.categories || row.group || row["Группа"] || "",
5813
+ })).filter((row) => row.name || row.email || row.phone);
5814
+ }
5815
+
5816
+ function parseSimpleCsv(text) {
5817
+ const lines = String(text || "").replace(/\r/g, "").split("\n").filter((line) => line.trim());
5818
+ if (!lines.length) return [];
5819
+ const headers = splitContactsCsvLine(lines[0]).map((header) => header.trim());
5820
+ return lines.slice(1).map((line) => {
5821
+ const values = splitContactsCsvLine(line);
5822
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || ""]));
5823
+ });
5824
+ }
5825
+
5826
+ function splitContactsCsvLine(line) {
5827
+ const cells = [];
5828
+ let current = "";
5829
+ let quoted = false;
5830
+ for (let index = 0; index < String(line || "").length; index += 1) {
5831
+ const char = line[index];
5832
+ const next = line[index + 1];
5833
+ if (char === "\"" && quoted && next === "\"") {
5834
+ current += "\"";
5835
+ index += 1;
5836
+ } else if (char === "\"") {
5837
+ quoted = !quoted;
5838
+ } else if (char === "," && !quoted) {
5839
+ cells.push(current);
5840
+ current = "";
5841
+ } else {
5842
+ current += char;
5843
+ }
5844
+ }
5845
+ cells.push(current);
5846
+ return cells.map((cell) => cell.trim());
5847
+ }
5848
+
5849
+ function nextBirthdayDate(value) {
5850
+ const normalized = normalizeVcardBirthday(value);
5851
+ const match = normalized.match(/^\d{4}-(\d{2})-(\d{2})$/u)
5852
+ || normalized.match(/^\d{4}(\d{2})(\d{2})$/u)
5853
+ || normalized.match(/^(\d{2})-(\d{2})$/u)
5854
+ || normalized.match(/^(\d{2})(\d{2})$/u);
5855
+ if (!match) return null;
5856
+ const now = new Date();
5857
+ let date = new Date(now.getFullYear(), Number(match[1]) - 1, Number(match[2]), 9, 0, 0);
5858
+ if (date < now) date = new Date(now.getFullYear() + 1, Number(match[1]) - 1, Number(match[2]), 9, 0, 0);
5859
+ return { start: date.toISOString(), end: new Date(date.getTime() + 3600000).toISOString() };
5860
+ }
5861
+
5862
+ function escapeRegExp(value) {
5863
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5864
+ }
5865
+
4945
5866
  async function yandexContactsBaseUrl(token) {
4946
5867
  const root = "https://carddav.yandex.ru/";
4947
5868
  const principalXml = await yandexDavRequest(root, token, {
@@ -4971,21 +5892,92 @@ async function yandexContactsBaseUrl(token) {
4971
5892
  return new URL(addressBookHref, root).toString();
4972
5893
  }
4973
5894
 
5895
+ async function yandexTelemostStatus() {
5896
+ const calendar = await yandexCalendarStatus();
5897
+ return {
5898
+ status: "calendar-fallback",
5899
+ provider: "yandex-telemost",
5900
+ calendar: calendar.displayName,
5901
+ message: "Для обычного OAuth-подключения CLI использует календарное событие. Прямой Telemost API проверяется отдельно и может быть доступен только аккаунтам/организациям Яндекс 360.",
5902
+ };
5903
+ }
5904
+
4974
5905
  async function yandexTelemostCreateEvent(args = {}) {
5906
+ if (!args.confirm) throw new Error("Для создания встречи нужен аргумент confirm=true.");
5907
+ const telemost = await tryCreateYandexTelemostMeeting(args).catch((error) => ({
5908
+ status: "telemost-api-unavailable",
5909
+ error: error instanceof Error ? error.message : String(error),
5910
+ }));
4975
5911
  const description = [
4976
5912
  args.description || "",
4977
5913
  "",
4978
- "Телемост: создайте ссылку в Яндекс Календаре, если интерфейс календаря предложит видеовстречу.",
5914
+ telemost.joinUrl ? `Ссылка Телемоста: ${telemost.joinUrl}` : "Телемост: прямое создание ссылки через API недоступно для текущего OAuth-подключения. Событие создано в календаре как встреча; ссылку можно добавить вручную в Яндекс Календаре.",
4979
5915
  ].join("\n").trim();
4980
- return yandexCalendarCreateEvent({ ...args, description });
5916
+ const event = await yandexCalendarCreateEvent({
5917
+ ...args,
5918
+ title: args.title || args.summary || "Телемост IOLA",
5919
+ description,
5920
+ location: telemost.joinUrl || args.location || "Яндекс Телемост",
5921
+ confirm: true,
5922
+ });
5923
+ return { ...event, status: telemost.joinUrl ? "telemost-event-created" : "telemost-calendar-fallback-created", telemost };
4981
5924
  }
4982
5925
 
4983
- function buildIcsEvent({ uid, start, end, summary, description, location }) {
5926
+ async function tryCreateYandexTelemostMeeting(args = {}) {
5927
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Телемост");
5928
+ const endpoints = [
5929
+ "https://cloud-api.yandex.net/v1/telemost/meetings",
5930
+ "https://api360.yandex.net/v1/telemost/meetings",
5931
+ ];
5932
+ let lastError = "";
5933
+ for (const endpoint of endpoints) {
5934
+ const response = await fetch(endpoint, {
5935
+ method: "POST",
5936
+ headers: {
5937
+ Authorization: `OAuth ${token}`,
5938
+ "content-type": "application/json",
5939
+ },
5940
+ body: JSON.stringify({
5941
+ title: args.title || args.summary || "Телемост IOLA",
5942
+ description: args.description || "",
5943
+ }),
5944
+ signal: AbortSignal.timeout(15000),
5945
+ }).catch((error) => ({ ok: false, status: 0, statusText: error.message, text: async () => error.message }));
5946
+ const text = await response.text().catch(() => "");
5947
+ if (response.ok) {
5948
+ const payload = text ? JSON.parse(text) : {};
5949
+ return {
5950
+ status: "telemost-api-created",
5951
+ endpoint,
5952
+ id: payload.id || payload.meeting_id || "",
5953
+ joinUrl: payload.join_url || payload.url || payload.link || payload.meeting_url || "",
5954
+ raw: payload,
5955
+ };
5956
+ }
5957
+ lastError = `${endpoint}: ${response.status} ${response.statusText} ${sanitizeSecretFromText(text.slice(0, 500), token)}`;
5958
+ if (![404, 405].includes(Number(response.status))) break;
5959
+ }
5960
+ throw new Error(lastError || "Telemost API недоступен.");
5961
+ }
5962
+
5963
+ function buildIcsEvent({ uid, start, end, summary, description, location, attendees = [], rrule = "", reminders = [] }) {
4984
5964
  const escape = (value) => String(value || "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
5965
+ const attendeeLines = (Array.isArray(attendees) ? attendees : [attendees])
5966
+ .map((email) => String(email || "").trim())
5967
+ .filter((email) => /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email))
5968
+ .map((email) => `ATTENDEE;CN=${escape(email)};ROLE=REQ-PARTICIPANT:mailto:${email}`);
5969
+ const reminderBlocks = normalizeCalendarReminders(reminders).map((minutes) => [
5970
+ "BEGIN:VALARM",
5971
+ `TRIGGER:-PT${Math.max(1, Math.abs(Number(minutes) || 15))}M`,
5972
+ "ACTION:DISPLAY",
5973
+ `DESCRIPTION:${escape(summary || "Напоминание")}`,
5974
+ "END:VALARM",
5975
+ ].join("\r\n"));
4985
5976
  return [
4986
5977
  "BEGIN:VCALENDAR",
4987
5978
  "VERSION:2.0",
4988
5979
  "PRODID:-//IOLA CLI//Yandex Calendar//RU",
5980
+ "CALSCALE:GREGORIAN",
4989
5981
  "BEGIN:VEVENT",
4990
5982
  `UID:${uid}`,
4991
5983
  `DTSTAMP:${toIcsDate(new Date().toISOString())}`,
@@ -4994,6 +5986,9 @@ function buildIcsEvent({ uid, start, end, summary, description, location }) {
4994
5986
  `SUMMARY:${escape(summary)}`,
4995
5987
  description ? `DESCRIPTION:${escape(description)}` : "",
4996
5988
  location ? `LOCATION:${escape(location)}` : "",
5989
+ rrule ? `RRULE:${rrule}` : "",
5990
+ ...attendeeLines,
5991
+ ...reminderBlocks,
4997
5992
  "END:VEVENT",
4998
5993
  "END:VCALENDAR",
4999
5994
  "",
@@ -5006,15 +6001,105 @@ function toIcsDate(value) {
5006
6001
  return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/u, "Z");
5007
6002
  }
5008
6003
 
5009
- function parseIcsEvents(xmlOrIcs) {
5010
- const decoded = decodeXml(stripXmlTags(xmlOrIcs));
5011
- return decoded.split("BEGIN:VEVENT").slice(1).map((chunk) => ({
5012
- uid: chunk.match(/\nUID:([^\n\r]+)/u)?.[1] || "",
5013
- title: chunk.match(/\nSUMMARY:([^\n\r]+)/u)?.[1] || "",
5014
- start: chunk.match(/\nDTSTART[^:]*:([^\n\r]+)/u)?.[1] || "",
5015
- end: chunk.match(/\nDTEND[^:]*:([^\n\r]+)/u)?.[1] || "",
5016
- location: chunk.match(/\nLOCATION:([^\n\r]+)/u)?.[1] || "",
5017
- })).filter((item) => item.uid || item.title);
6004
+ function parseIcsEvents(xmlOrIcs, options = {}) {
6005
+ const text = String(xmlOrIcs || "");
6006
+ const responses = text.split(/<[^:>]*:?response[^>]*>/iu).slice(1);
6007
+ if (responses.length) {
6008
+ return responses.flatMap((response) => {
6009
+ const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || "")).trim();
6010
+ const calendarData = response.match(/<[^:>]*:?calendar-data[^>]*>([\s\S]*?)<\/[^:>]*:?calendar-data>/iu)?.[1] || response;
6011
+ return parseIcsEvents(calendarData, options).map((row) => ({
6012
+ ...row,
6013
+ href: row.href || href,
6014
+ url: row.url || (href ? new URL(href, options.baseUrl || "https://caldav.yandex.ru/").toString() : ""),
6015
+ }));
6016
+ });
6017
+ }
6018
+ const decoded = decodeXml(stripXmlTags(text));
6019
+ return decoded.split("BEGIN:VEVENT").slice(1).map((chunk) => {
6020
+ const lines = unfoldIcsLines(`BEGIN:VEVENT${chunk}`);
6021
+ const uid = icsValue(lines, "UID");
6022
+ const start = icsValue(lines, "DTSTART");
6023
+ const end = icsValue(lines, "DTEND");
6024
+ return {
6025
+ uid,
6026
+ title: unescapeIcsValue(icsValue(lines, "SUMMARY")),
6027
+ start,
6028
+ end,
6029
+ startIso: icsDateToIso(start),
6030
+ endIso: icsDateToIso(end),
6031
+ location: unescapeIcsValue(icsValue(lines, "LOCATION")),
6032
+ description: unescapeIcsValue(icsValue(lines, "DESCRIPTION")),
6033
+ rrule: icsValue(lines, "RRULE"),
6034
+ attendees: icsValues(lines, "ATTENDEE").map((value) => value.replace(/^mailto:/iu, "")),
6035
+ reminders: parseIcsReminderMinutes(lines),
6036
+ ics: decoded.includes("BEGIN:VCALENDAR") ? decoded : "",
6037
+ };
6038
+ }).filter((item) => item.uid || item.title);
6039
+ }
6040
+
6041
+ function unfoldIcsLines(value) {
6042
+ return String(value || "")
6043
+ .replace(/\r/g, "")
6044
+ .replace(/\n[ \t]/g, "")
6045
+ .split(/\n/u)
6046
+ .map((line) => line.trim())
6047
+ .filter(Boolean);
6048
+ }
6049
+
6050
+ function icsValues(lines, property) {
6051
+ const prop = String(property || "").toLocaleUpperCase("en-US");
6052
+ return lines
6053
+ .filter((line) => line.toLocaleUpperCase("en-US").startsWith(`${prop};`) || line.toLocaleUpperCase("en-US").startsWith(`${prop}:`))
6054
+ .map((line) => line.slice(line.indexOf(":") + 1).trim())
6055
+ .filter(Boolean);
6056
+ }
6057
+
6058
+ function icsValue(lines, property) {
6059
+ return icsValues(lines, property)[0] || "";
6060
+ }
6061
+
6062
+ function unescapeIcsValue(value) {
6063
+ return String(value || "")
6064
+ .replace(/\\n/giu, "\n")
6065
+ .replace(/\\,/gu, ",")
6066
+ .replace(/\\;/gu, ";")
6067
+ .replace(/\\\\/gu, "\\")
6068
+ .trim();
6069
+ }
6070
+
6071
+ function icsDateToIso(value) {
6072
+ const text = String(value || "").trim();
6073
+ const match = text.match(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/u);
6074
+ if (!match) return "";
6075
+ const iso = `${match[1]}-${match[2]}-${match[3]}T${match[4] || "00"}:${match[5] || "00"}:${match[6] || "00"}${text.endsWith("Z") ? "Z" : ""}`;
6076
+ const date = new Date(iso);
6077
+ return Number.isNaN(date.getTime()) ? "" : date.toISOString();
6078
+ }
6079
+
6080
+ function parseIcsReminderMinutes(lines) {
6081
+ return lines
6082
+ .filter((line) => /^TRIGGER:/iu.test(line))
6083
+ .map((line) => Number(line.match(/PT(\d+)M/iu)?.[1] || 0))
6084
+ .filter(Boolean);
6085
+ }
6086
+
6087
+ function normalizeCalendarReminders(value) {
6088
+ const rows = Array.isArray(value) ? value : String(value || "").split(/[;,]/u);
6089
+ return rows.map((item) => Number(String(item).match(/\d+/u)?.[0] || 0)).filter(Boolean);
6090
+ }
6091
+
6092
+ function buildIcsRrule(args = {}) {
6093
+ const repeat = String(args.repeat || args.frequency || "").toLocaleLowerCase("ru-RU");
6094
+ if (!repeat && !args.count && !args.until) return "";
6095
+ const freq = /день|daily|day/iu.test(repeat) ? "DAILY"
6096
+ : /месяц|monthly|month/iu.test(repeat) ? "MONTHLY"
6097
+ : /год|year|yearly|annual/iu.test(repeat) ? "YEARLY"
6098
+ : "WEEKLY";
6099
+ const parts = [`FREQ=${freq}`];
6100
+ if (args.count) parts.push(`COUNT=${Number(args.count)}`);
6101
+ if (args.until) parts.push(`UNTIL=${toIcsDate(args.until)}`);
6102
+ return parts.join(";");
5018
6103
  }
5019
6104
 
5020
6105
  function extractDavHref(xml, propertyName) {
@@ -5033,6 +6118,24 @@ function extractCalendarCollectionHref(xml) {
5033
6118
  return "";
5034
6119
  }
5035
6120
 
6121
+ function extractCalendarCollections(xml) {
6122
+ const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
6123
+ const rows = [];
6124
+ for (const response of responses) {
6125
+ if (!/<[^:>]*:?calendar(?:\s|>|\/)/iu.test(response)) continue;
6126
+ const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || "")).trim();
6127
+ if (!href || /\/(?:inbox|outbox)\//iu.test(href)) continue;
6128
+ const name = stripXmlTags(response.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "").trim() || path.posix.basename(href.replace(/\/$/u, ""));
6129
+ rows.push({ provider: "yandex-calendar", href, name, type: "calendar" });
6130
+ }
6131
+ return rows;
6132
+ }
6133
+
6134
+ function stripCalendarPrivateFields(row = {}) {
6135
+ const { ics: _ics, url: _url, ...rest } = row;
6136
+ return rest;
6137
+ }
6138
+
5036
6139
  function extractAddressBookCollectionHref(xml) {
5037
6140
  const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
5038
6141
  for (const response of responses) {
@@ -5057,19 +6160,75 @@ function extractVcardHrefs(xml) {
5057
6160
  function parseVCards(xmlOrCards) {
5058
6161
  const decoded = decodeXml(stripXmlTags(xmlOrCards)).replace(/\r/g, "");
5059
6162
  return decoded.split("BEGIN:VCARD").slice(1).map((chunk) => {
5060
- const email = chunk.match(/^EMAIL[^:]*:([^\n]+)/mu)?.[1] || "";
5061
- const phone = chunk.match(/^TEL[^:]*:([^\n]+)/mu)?.[1] || "";
5062
- const name = cleanVcardName(chunk.match(/^FN:([^\n]+)/mu)?.[1] || chunk.match(/^N:([^\n]+)/mu)?.[1] || "", email);
5063
- return { name, email, phone };
6163
+ const card = `BEGIN:VCARD\n${chunk}`.replace(/\n+/g, "\n").trim();
6164
+ const lines = unfoldVcardLines(chunk);
6165
+ const emails = vcardValues(lines, "EMAIL");
6166
+ const phones = vcardValues(lines, "TEL");
6167
+ const name = cleanVcardName(vcardValue(lines, "FN") || vcardValue(lines, "N"), emails[0] || phones[0] || "");
6168
+ const address = cleanVcardAddress(vcardValue(lines, "ADR"));
6169
+ const row = {
6170
+ uid: vcardValue(lines, "UID"),
6171
+ name,
6172
+ email: emails[0] || "",
6173
+ emails,
6174
+ phone: phones[0] || "",
6175
+ phones,
6176
+ address,
6177
+ org: cleanVcardName(vcardValue(lines, "ORG")),
6178
+ title: cleanVcardName(vcardValue(lines, "TITLE")),
6179
+ note: unescapeVcardValue(vcardValue(lines, "NOTE")),
6180
+ birthday: vcardValue(lines, "BDAY"),
6181
+ categories: vcardValue(lines, "CATEGORIES"),
6182
+ card: ensureVcardCrlf(card),
6183
+ };
6184
+ return row;
5064
6185
  }).filter((item) => item.name || item.email || item.phone);
5065
6186
  }
5066
6187
 
5067
6188
  function cleanVcardName(value, fallback = "") {
5068
- const text = String(value || "").replace(/;/g, " ").replace(/\s+/g, " ").trim();
6189
+ const text = unescapeVcardValue(value).replace(/;/g, " ").replace(/\s+/g, " ").trim();
5069
6190
  if (!text || /^[\s;]+$/u.test(String(value || ""))) return fallback || "";
5070
6191
  return text;
5071
6192
  }
5072
6193
 
6194
+ function unfoldVcardLines(value) {
6195
+ return String(value || "")
6196
+ .replace(/\r/g, "")
6197
+ .replace(/\n[ \t]/g, "")
6198
+ .split(/\n/u)
6199
+ .map((line) => line.trim())
6200
+ .filter(Boolean);
6201
+ }
6202
+
6203
+ function vcardValues(lines, property) {
6204
+ const prop = String(property || "").toLocaleUpperCase("en-US");
6205
+ return lines
6206
+ .filter((line) => line.toLocaleUpperCase("en-US").startsWith(`${prop};`) || line.toLocaleUpperCase("en-US").startsWith(`${prop}:`))
6207
+ .map((line) => unescapeVcardValue(line.slice(line.indexOf(":") + 1).trim()))
6208
+ .filter(Boolean);
6209
+ }
6210
+
6211
+ function vcardValue(lines, property) {
6212
+ return vcardValues(lines, property)[0] || "";
6213
+ }
6214
+
6215
+ function cleanVcardAddress(value) {
6216
+ return unescapeVcardValue(value).split(";").map((part) => part.trim()).filter(Boolean).join(", ");
6217
+ }
6218
+
6219
+ function unescapeVcardValue(value) {
6220
+ return String(value || "")
6221
+ .replace(/\\n/giu, "\n")
6222
+ .replace(/\\,/gu, ",")
6223
+ .replace(/\\;/gu, ";")
6224
+ .replace(/\\\\/gu, "\\")
6225
+ .trim();
6226
+ }
6227
+
6228
+ function ensureVcardCrlf(value) {
6229
+ return String(value || "").replace(/\r/g, "").replace(/\n/g, "\r\n").trim() + "\r\n";
6230
+ }
6231
+
5073
6232
  function normalizeYandexServiceList(values) {
5074
6233
  const aliases = {
5075
6234
  id: "identity",
@@ -5278,6 +6437,121 @@ async function yandexDiskFind(query, options = {}) {
5278
6437
  .slice(0, Number(options.limit || 50));
5279
6438
  }
5280
6439
 
6440
+ async function yandexDiskStat(remotePath) {
6441
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6442
+ const payload = await yandexDiskRequest("GET", "/resources", {
6443
+ query: {
6444
+ path: normalizeYandexDiskPath(remotePath),
6445
+ fields: "name,path,type,size,created,modified,mime_type,public_url,preview,md5,sha256,embedded",
6446
+ },
6447
+ });
6448
+ return formatYandexDiskResource(payload);
6449
+ }
6450
+
6451
+ async function yandexDiskExists(remotePath) {
6452
+ try {
6453
+ const stat = await yandexDiskStat(remotePath);
6454
+ return { provider: "yandex-disk", path: stat.path, exists: true, type: stat.type, name: stat.name };
6455
+ } catch (error) {
6456
+ if (/404|DiskNotFoundError|Path not found/iu.test(String(error?.message || ""))) {
6457
+ return { provider: "yandex-disk", path: remotePath, exists: false };
6458
+ }
6459
+ throw error;
6460
+ }
6461
+ }
6462
+
6463
+ async function yandexDiskReadText(remotePath, options = {}) {
6464
+ if (!remotePath) throw new Error("Путь к текстовому файлу на Яндекс Диске обязателен.");
6465
+ const maxBytes = Number(options.maxBytes || 200000);
6466
+ const download = await yandexDiskRequest("GET", "/resources/download", { query: { path: normalizeYandexDiskPath(remotePath) } });
6467
+ const response = await fetch(download.href, { signal: AbortSignal.timeout(120000) });
6468
+ if (!response.ok) throw new Error(`Yandex Disk read failed: ${response.status} ${response.statusText}`);
6469
+ const buffer = Buffer.from(await response.arrayBuffer());
6470
+ if (buffer.length > maxBytes) throw new Error(`Файл слишком большой для чтения: ${buffer.length} байт. Лимит: ${maxBytes}.`);
6471
+ return { provider: "yandex-disk", remote: remotePath, text: buffer.toString("utf8"), size: buffer.length };
6472
+ }
6473
+
6474
+ async function yandexDiskMove(from, to, options = {}) {
6475
+ if (!options.confirm) throw new Error("Для перемещения на Яндекс Диске нужен аргумент confirm=true.");
6476
+ if (!from || !to) throw new Error("Для перемещения нужны from и to.");
6477
+ await ensureYandexDiskDir(path.dirname(to), { allowExisting: true });
6478
+ await yandexDiskRequest("POST", "/resources/move", {
6479
+ query: { from: normalizeYandexDiskPath(from), path: normalizeYandexDiskPath(to), overwrite: Boolean(options.overwrite) },
6480
+ timeout: 60000,
6481
+ });
6482
+ return { provider: "yandex-disk", status: "moved", from, remote: to };
6483
+ }
6484
+
6485
+ async function yandexDiskCopy(from, to, options = {}) {
6486
+ if (!options.confirm) throw new Error("Для копирования на Яндекс Диске нужен аргумент confirm=true.");
6487
+ if (!from || !to) throw new Error("Для копирования нужны from и to.");
6488
+ await ensureYandexDiskDir(path.dirname(to), { allowExisting: true });
6489
+ await yandexDiskRequest("POST", "/resources/copy", {
6490
+ query: { from: normalizeYandexDiskPath(from), path: normalizeYandexDiskPath(to), overwrite: Boolean(options.overwrite) },
6491
+ timeout: 60000,
6492
+ });
6493
+ return { provider: "yandex-disk", status: "copied", from, remote: to };
6494
+ }
6495
+
6496
+ async function yandexDiskRename(remotePath, newName, options = {}) {
6497
+ if (!remotePath || !newName) throw new Error("Для переименования нужны путь и новое имя.");
6498
+ const current = denormalizeYandexDiskPath(normalizeYandexDiskPath(remotePath));
6499
+ const target = path.posix.join(path.posix.dirname(current), slugYandexDiskName(newName));
6500
+ return yandexDiskMove(remotePath, target, { ...options, confirm: true });
6501
+ }
6502
+
6503
+ async function yandexDiskTrashList(remotePath = "", options = {}) {
6504
+ const query = { limit: Number(options.limit || 100) };
6505
+ if (remotePath) query.path = normalizeYandexDiskPath(remotePath);
6506
+ const payload = await yandexDiskRequest("GET", "/trash/resources", { query });
6507
+ const items = payload._embedded?.items || [];
6508
+ return items.map(formatYandexDiskResource);
6509
+ }
6510
+
6511
+ async function yandexDiskRestore(remotePath, options = {}) {
6512
+ if (!options.confirm) throw new Error("Для восстановления из корзины нужен аргумент confirm=true.");
6513
+ if (!remotePath) throw new Error("Путь в корзине обязателен.");
6514
+ await yandexDiskRequest("PUT", "/trash/resources/restore", {
6515
+ query: { path: normalizeYandexDiskPath(remotePath), name: options.name || "", overwrite: Boolean(options.overwrite) },
6516
+ timeout: 60000,
6517
+ });
6518
+ return { provider: "yandex-disk", status: "restored", remote: remotePath };
6519
+ }
6520
+
6521
+ async function yandexDiskEmptyTrash(options = {}) {
6522
+ if (!options.confirm) throw new Error("Для очистки корзины нужен аргумент confirm=true.");
6523
+ await yandexDiskRequest("DELETE", "/trash/resources", {
6524
+ query: { path: options.path ? normalizeYandexDiskPath(options.path) : "" },
6525
+ timeout: 60000,
6526
+ });
6527
+ return { provider: "yandex-disk", status: "trash-empty-requested", remote: options.path || "trash" };
6528
+ }
6529
+
6530
+ function formatYandexDiskResource(item = {}) {
6531
+ return {
6532
+ provider: "yandex-disk",
6533
+ type: item.type === "dir" ? "dir" : "file",
6534
+ name: item.name || path.basename(item.path || ""),
6535
+ path: denormalizeYandexDiskPath(item.path || ""),
6536
+ remote: denormalizeYandexDiskPath(item.path || ""),
6537
+ size: item.size || 0,
6538
+ mimeType: item.mime_type || "",
6539
+ created: item.created || "",
6540
+ modified: item.modified || "",
6541
+ publicUrl: item.public_url || "",
6542
+ md5: item.md5 || "",
6543
+ sha256: item.sha256 || "",
6544
+ };
6545
+ }
6546
+
6547
+ function slugYandexDiskName(value) {
6548
+ return String(value || "")
6549
+ .replace(/[\\/:*?"<>|]+/gu, "-")
6550
+ .replace(/\s+/gu, " ")
6551
+ .trim()
6552
+ || `item-${timestampForFile()}`;
6553
+ }
6554
+
5281
6555
  async function yandexDiskListRecursive(remotePath, options = {}) {
5282
6556
  const depth = Number(options.depth || 4);
5283
6557
  const limit = Number(options.limit || 200);
@@ -5325,6 +6599,119 @@ async function yandexDiskShare(remotePath) {
5325
6599
  return { provider: "yandex-disk", remote: remotePath, publicUrl: payload.public_url || "-" };
5326
6600
  }
5327
6601
 
6602
+ async function yandexDiskShareWithQr(remotePath, options = {}) {
6603
+ if (!options.confirm) throw new Error("Для публикации ссылки и QR нужен аргумент confirm=true.");
6604
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6605
+ const shared = await yandexDiskShare(remotePath);
6606
+ const qrRemotePath = options.qrPath || buildYandexDiskQrPath(remotePath);
6607
+ const tempPath = path.join(CONFIG_DIR, `qr-${Date.now()}.png`);
6608
+ await mkdir(CONFIG_DIR, { recursive: true });
6609
+ try {
6610
+ await createQrPng(shared.publicUrl, tempPath);
6611
+ await yandexDiskUpload(tempPath, qrRemotePath, { overwrite: true });
6612
+ } finally {
6613
+ await rm(tempPath, { force: true }).catch(() => {});
6614
+ }
6615
+ const qrShared = await yandexDiskShare(qrRemotePath);
6616
+ return {
6617
+ provider: "yandex-disk",
6618
+ status: "shared-with-qr",
6619
+ remote: remotePath,
6620
+ publicUrl: shared.publicUrl,
6621
+ qrRemote: qrRemotePath,
6622
+ qrPublicUrl: qrShared.publicUrl,
6623
+ };
6624
+ }
6625
+
6626
+ async function yandexDiskShareEmail(args = {}) {
6627
+ if (!args.confirm) throw new Error("Для отправки ссылки по почте нужен аргумент confirm=true.");
6628
+ const remotePath = args.remotePath || args.path || args.targetFolder || args.target;
6629
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6630
+ const to = await resolveYandexShareRecipients(args);
6631
+ const shared = await yandexDiskShareWithQr(remotePath, { ...args, confirm: true });
6632
+ const subject = args.subject || `Ссылка на Яндекс Диск: ${path.posix.basename(remotePath) || "материалы"}`;
6633
+ const text = [
6634
+ args.text || args.message || "Здравствуйте. Направляю ссылку на материалы.",
6635
+ "",
6636
+ `Ссылка: ${shared.publicUrl}`,
6637
+ `QR-код: ${shared.qrPublicUrl}`,
6638
+ "",
6639
+ "QR-код сохранен на Яндекс Диске:",
6640
+ shared.qrRemote,
6641
+ ].join("\n");
6642
+ const sent = await yandexMailSend({ to, subject, text, confirm: true });
6643
+ return { ...shared, status: "shared-and-sent", to: sent.to, subject };
6644
+ }
6645
+
6646
+ async function yandexDiskPackageShareEmail(args = {}) {
6647
+ if (!args.confirm) throw new Error("Для пакетной отправки папки нужен аргумент confirm=true.");
6648
+ const sourcePath = args.sourcePath || args.source || args.from;
6649
+ const targetFolder = args.targetFolder || args.target || args.to;
6650
+ if (!sourcePath || !targetFolder) throw new Error("Нужны sourcePath и targetFolder.");
6651
+ await cloudCreateFolder("yandex-disk", targetFolder);
6652
+ const items = await yandexDiskList(sourcePath);
6653
+ const mode = /move|перен/iu.test(args.mode || "") ? "move" : "copy";
6654
+ const transferred = [];
6655
+ for (const item of items.slice(0, Number(args.limit || 100))) {
6656
+ const destination = path.posix.join(targetFolder, item.name);
6657
+ if (mode === "move") {
6658
+ await yandexDiskMove(item.path, destination, { confirm: true, overwrite: args.overwrite !== false });
6659
+ } else {
6660
+ await yandexDiskCopy(item.path, destination, { confirm: true, overwrite: args.overwrite !== false });
6661
+ }
6662
+ transferred.push({ from: item.path, to: destination, type: item.type });
6663
+ }
6664
+ const shared = await yandexDiskShareEmail({
6665
+ remotePath: targetFolder,
6666
+ to: args.to,
6667
+ email: args.email,
6668
+ contact: args.contact || args.contactQuery,
6669
+ subject: args.subject || `Материалы на Яндекс Диске: ${path.posix.basename(targetFolder)}`,
6670
+ text: args.text || args.message || `Собрал материалы в папку ${targetFolder}.`,
6671
+ confirm: true,
6672
+ });
6673
+ return {
6674
+ ...shared,
6675
+ status: "package-shared-and-sent",
6676
+ sourcePath,
6677
+ targetFolder,
6678
+ mode,
6679
+ transferred: transferred.length,
6680
+ };
6681
+ }
6682
+
6683
+ async function resolveYandexShareRecipients(args = {}) {
6684
+ const emails = Array.isArray(args.to) ? args.to : String(args.to || args.email || "").split(/[;,]/u).map((item) => item.trim()).filter(Boolean);
6685
+ if (emails.length) return emails;
6686
+ const contactQuery = args.contact || args.contactQuery || args.name || "";
6687
+ if (!contactQuery) throw new Error("Укажите email или контакт получателя.");
6688
+ const result = await resolveYandexMailRecipientFromContacts(contactQuery);
6689
+ if (result.status === "not-found") throw new Error(`В Яндекс Контактах не нашел: ${contactQuery}. Укажите email вручную.`);
6690
+ if (result.status === "no-email") throw new Error(`Контакт найден, но email не указан: ${result.contact.name}. Укажите email вручную.`);
6691
+ if (result.status === "ambiguous") {
6692
+ const rows = result.contacts.map((contact, index) => `${index + 1}. ${contact.name || contact.email || "Контакт"}${contact.email ? `, ${contact.email}` : ", email не указан"}`).join("\n");
6693
+ throw new Error(`Нашел несколько контактов для "${contactQuery}". Уточните получателя:\n${rows}`);
6694
+ }
6695
+ return [result.contact.email];
6696
+ }
6697
+
6698
+ async function createQrPng(text, outputPath) {
6699
+ const QRCode = await import("qrcode");
6700
+ await QRCode.toFile(outputPath, String(text || ""), {
6701
+ type: "png",
6702
+ errorCorrectionLevel: "M",
6703
+ margin: 2,
6704
+ width: 512,
6705
+ });
6706
+ }
6707
+
6708
+ function buildYandexDiskQrPath(remotePath) {
6709
+ const current = denormalizeYandexDiskPath(normalizeYandexDiskPath(remotePath));
6710
+ const dir = path.posix.dirname(current);
6711
+ const base = path.posix.basename(current).replace(/\.[^.]+$/u, "");
6712
+ return path.posix.join(dir, `${slugYandexDiskName(base || "link")}-qr.png`);
6713
+ }
6714
+
5328
6715
  async function ensureYandexDiskDir(remotePath, options = {}) {
5329
6716
  const normalized = normalizeYandexDiskPath(remotePath || CLOUD_DEFAULT_REMOTE_DIR);
5330
6717
  const plain = denormalizeYandexDiskPath(normalized);
@@ -5345,7 +6732,7 @@ async function ensureYandexDiskDir(remotePath, options = {}) {
5345
6732
 
5346
6733
  function normalizeYandexDiskPath(remotePath) {
5347
6734
  const text = String(remotePath || CLOUD_DEFAULT_REMOTE_DIR).trim().replace(/\\/g, "/");
5348
- if (text.startsWith("disk:") || text.startsWith("app:")) return text;
6735
+ if (text.startsWith("disk:") || text.startsWith("app:") || text.startsWith("trash:")) return text;
5349
6736
  return text.startsWith("/") ? text : `/${text}`;
5350
6737
  }
5351
6738
 
@@ -9692,6 +11079,10 @@ function isCronDue(job) {
9692
11079
  if (everyMinutes) {
9693
11080
  return !lastRun || now.getTime() - lastRun.getTime() >= Number(everyMinutes[1]) * 60 * 1000;
9694
11081
  }
11082
+ const everyDays = normalized.match(/кажд(?:ые|ую)\s+(\d+)\s*(?:дн|день|дня|дней)/u) || normalized.match(/every\s+(\d+)\s*(?:d|day|days)/u);
11083
+ if (everyDays) {
11084
+ return !lastRun || now.getTime() - lastRun.getTime() >= Number(everyDays[1]) * 24 * 60 * 60 * 1000;
11085
+ }
9695
11086
  if (normalized.includes("каждый день") || normalized.includes("daily")) {
9696
11087
  return !lastRun || now.toISOString().slice(0, 10) !== lastRun.toISOString().slice(0, 10);
9697
11088
  }
@@ -10061,19 +11452,35 @@ async function aiAsk(args, context = {}) {
10061
11452
  if (!options.quiet) console.log(casualAnswer);
10062
11453
  return casualAnswer;
10063
11454
  }
10064
- const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
10065
- if (yandexAnswer) {
11455
+ const userSkillAnswer = await buildUserSkillDirectAnswer(question);
11456
+ if (userSkillAnswer) {
10066
11457
  if (historyEnabled) {
10067
- recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
10068
- appendSessionExchange(sessionId, question, yandexAnswer, dataContext, "");
11458
+ recordAskHistory({ question, answer: userSkillAnswer, providerConfig, dataContext, error: "", sessionId });
11459
+ appendSessionExchange(sessionId, question, userSkillAnswer, dataContext, "");
10069
11460
  }
10070
- emitEvent(options, "answer", { length: yandexAnswer.length, sessionId, direct: true, yandex: true });
11461
+ emitEvent(options, "answer", { length: userSkillAnswer.length, sessionId, direct: true, skill: true });
10071
11462
  if (options.output) {
10072
11463
  await assertPermission("writeFiles");
10073
- await writeFile(options.output, yandexAnswer, "utf8");
11464
+ await writeFile(options.output, userSkillAnswer, "utf8");
11465
+ }
11466
+ if (!options.quiet) console.log(userSkillAnswer);
11467
+ return userSkillAnswer;
11468
+ }
11469
+ if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
11470
+ const yandexContactAnswer = await buildYandexDirectAnswer(question, context.history || history);
11471
+ if (yandexContactAnswer) {
11472
+ if (historyEnabled) {
11473
+ recordAskHistory({ question, answer: yandexContactAnswer, providerConfig, dataContext, error: "", sessionId });
11474
+ appendSessionExchange(sessionId, question, yandexContactAnswer, dataContext, "");
11475
+ }
11476
+ emitEvent(options, "answer", { length: yandexContactAnswer.length, sessionId, direct: true, yandex: true });
11477
+ if (options.output) {
11478
+ await assertPermission("writeFiles");
11479
+ await writeFile(options.output, yandexContactAnswer, "utf8");
11480
+ }
11481
+ if (!options.quiet) console.log(yandexContactAnswer);
11482
+ return yandexContactAnswer;
10074
11483
  }
10075
- if (!options.quiet) console.log(yandexAnswer);
10076
- return yandexAnswer;
10077
11484
  }
10078
11485
  const cloudAnswer = await buildCloudDirectAnswer(question);
10079
11486
  if (cloudAnswer) {
@@ -10089,6 +11496,20 @@ async function aiAsk(args, context = {}) {
10089
11496
  if (!options.quiet) console.log(cloudAnswer);
10090
11497
  return cloudAnswer;
10091
11498
  }
11499
+ const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
11500
+ if (yandexAnswer) {
11501
+ if (historyEnabled) {
11502
+ recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
11503
+ appendSessionExchange(sessionId, question, yandexAnswer, dataContext, "");
11504
+ }
11505
+ emitEvent(options, "answer", { length: yandexAnswer.length, sessionId, direct: true, yandex: true });
11506
+ if (options.output) {
11507
+ await assertPermission("writeFiles");
11508
+ await writeFile(options.output, yandexAnswer, "utf8");
11509
+ }
11510
+ if (!options.quiet) console.log(yandexAnswer);
11511
+ return yandexAnswer;
11512
+ }
10092
11513
  const geoAnswer = await buildGeoDirectAnswer(question);
10093
11514
  if (geoAnswer) {
10094
11515
  if (historyEnabled) {
@@ -10214,6 +11635,10 @@ async function buildYandexDirectAnswer(question, history = []) {
10214
11635
  ].join("\n");
10215
11636
  }
10216
11637
 
11638
+ if (/(контакт|адресн)/iu.test(normalized) && !mailFollowup && !isExplicitYandexDiskPathDelete(question)) {
11639
+ return await buildYandexContactsDirectAnswer(question, normalized);
11640
+ }
11641
+
10217
11642
  if (mailFollowup || /(почт|письм|email|e-mail|спам|чернов|отправлен|исходящ|корзин)/iu.test(normalized)) {
10218
11643
  if (/(авто|автомат|кажд|период|монитор|следи|проверяй|проверку|режим)/iu.test(normalized) && /(включ|запусти|начни|поставь|создай)/iu.test(normalized)) {
10219
11644
  const minutes = Number(String(question || "").match(/(\d+)\s*(?:мин|минут)/iu)?.[1] || 5);
@@ -10361,13 +11786,128 @@ async function buildYandexDirectAnswer(question, history = []) {
10361
11786
  return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. ${formatYandexMailSummary(row)}`)].join("\n");
10362
11787
  }
10363
11788
 
10364
- if (/(календар|событи)/iu.test(normalized) && !/(создай|добавь|запланируй)/iu.test(normalized)) {
11789
+ if (/(документ|документы|docs|360)/iu.test(normalized) && /(яндекс|диск|облак|360|docs)/iu.test(normalized)) {
11790
+ if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
11791
+ const result = await yandexDocsStatus();
11792
+ return `Яндекс Документы через Диск подключены. Папка: ${result.folder}.`;
11793
+ }
11794
+ if (/(создай|сделай|запиши|сохрани)/iu.test(normalized)) {
11795
+ const text = extractShareMessage(question) || cleanupCloudSaveText(question);
11796
+ if (!text) return "Укажите текст документа. Пример: создай документ на Яндекс Диске текст: ...";
11797
+ const result = await yandexDocsCreateText({
11798
+ title: extractYandexDocTitle(question),
11799
+ text,
11800
+ format: /html/iu.test(normalized) ? "html" : /txt|текстов/iu.test(normalized) ? "txt" : "md",
11801
+ confirm: true,
11802
+ });
11803
+ return `Документ создан на Яндекс Диске: ${result.remote}.`;
11804
+ }
11805
+ if (/(прочитай|открой|покажи\s+текст)/iu.test(normalized)) {
11806
+ const result = await yandexDocsRead(extractCloudPath(question) || cleanupYandexQuery(question), {});
11807
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11808
+ }
11809
+ if (/(ссылк|поделись|опубликуй|qr|qr-код)/iu.test(normalized)) {
11810
+ const result = await yandexDocsShare(extractCloudPath(question) || cleanupYandexQuery(question), { confirm: true });
11811
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11812
+ }
11813
+ if (/(переимен|rename)/iu.test(normalized)) {
11814
+ const result = await yandexDocsRename(extractCloudPath(question) || cleanupYandexQuery(question), extractCloudNewName(question), { confirm: true });
11815
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11816
+ }
11817
+ if (/(удали|удалить)/iu.test(normalized)) {
11818
+ const result = await yandexDocsDelete(extractCloudPath(question) || cleanupYandexQuery(question), { confirm: true });
11819
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11820
+ }
11821
+ const rows = /(найди|поиск)/iu.test(normalized)
11822
+ ? await yandexDocsFind(cleanupYandexQuery(question), { limit: 20 })
11823
+ : await yandexDocsList({ limit: 20 });
11824
+ if (!rows.length) return "Документы на Яндекс Диске не найдены.";
11825
+ return ["Документы на Яндекс Диске:", ...rows.map((row, index) => `${index + 1}. ${row.name} — ${row.path}`)].join("\n");
11826
+ }
11827
+
11828
+ if (/(календар|событи|встреч|телемост)/iu.test(normalized)) {
11829
+ if (/(статус|проверь|работает|доступ)/iu.test(normalized) && /(календар|телемост)/iu.test(normalized)) {
11830
+ const result = /телемост/iu.test(normalized) ? await yandexTelemostStatus() : await yandexCalendarStatus();
11831
+ return /телемост/iu.test(normalized)
11832
+ ? `${result.message} Календарь: ${result.calendar || "-"}.`
11833
+ : `Яндекс Календарь подключен: ${result.displayName || result.url}. Календарей: ${result.calendars || 1}.`;
11834
+ }
11835
+ if (/(какие|список).{0,30}(календар|календари)|календари/iu.test(normalized)) {
11836
+ const rows = await yandexCalendarCalendars({ limit: 20 });
11837
+ if (!rows.length) return "Календари Яндекса не найдены.";
11838
+ return ["Яндекс Календари:", ...rows.map((row, index) => `${index + 1}. ${row.name} — ${row.href}`)].join("\n");
11839
+ }
11840
+ if (/(напомин|уведом)/iu.test(normalized) && /(добав|постав|создай)/iu.test(normalized)) {
11841
+ const minutes = Number(question.match(/(\d+)\s*(?:мин|минут)/iu)?.[1] || 15);
11842
+ const result = await yandexCalendarAddReminder(cleanupCalendarEventQuery(question), { minutes, confirm: true });
11843
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11844
+ }
11845
+ if (/(создай|добавь|запланируй|назначь)/iu.test(normalized)) {
11846
+ const dateTime = extractDateTimeFromText(question);
11847
+ const title = extractCalendarTitle(question) || (/телемост/iu.test(normalized) ? "Телемост IOLA" : "Событие IOLA");
11848
+ const args = { ...dateTime, title, description: extractShareMessage(question) || "", confirm: true };
11849
+ const result = /телемост/iu.test(normalized)
11850
+ ? await yandexTelemostCreateEvent(args)
11851
+ : /кажд|еженед|ежеднев|ежемесяч|повтор/iu.test(normalized)
11852
+ ? await yandexCalendarCreateRecurringEvent({ ...args, ...extractCalendarRepeat(question), confirm: true })
11853
+ : await yandexCalendarCreateEvent(args);
11854
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11855
+ }
11856
+ if (/(перенеси|перемести|измени\s+время|смени\s+время)/iu.test(normalized)) {
11857
+ const result = await yandexCalendarMove(cleanupCalendarEventQuery(question), { ...extractDateTimeFromText(question), confirm: true });
11858
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11859
+ }
11860
+ if (/(переимен|измени\s+назв|смени\s+назв)/iu.test(normalized)) {
11861
+ const result = await yandexCalendarUpdate(cleanupCalendarEventQuery(question), { title: extractCalendarTitle(question), confirm: true });
11862
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11863
+ }
11864
+ if (/(удали|удалить|отмени|отменить)/iu.test(normalized)) {
11865
+ const result = await yandexCalendarDelete(cleanupCalendarEventQuery(question), { confirm: true });
11866
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11867
+ }
11868
+ if (/(найди|поиск|где|покажи).{0,40}(событи|встреч|телемост)/iu.test(normalized)) {
11869
+ const rows = await yandexCalendarSearch(cleanupCalendarEventQuery(question), { limit: 20 });
11870
+ if (!rows.length) return "События в Яндекс Календаре по запросу не найдены.";
11871
+ return ["Яндекс Календарь:", ...rows.map((row, index) => `${index + 1}. ${row.title || "(без названия)"} — ${row.startIso || row.start || "-"}${row.location ? `, ${row.location}` : ""}`)].join("\n");
11872
+ }
10365
11873
  const rows = await yandexCalendarList({ limit: 10 });
10366
11874
  if (!rows.length) return "В ближайшие дни событий в Яндекс Календаре не найдено.";
10367
- return ["Яндекс Календарь:", ...rows.map((row, index) => `${index + 1}. ${row.title || "(без названия)"} — ${row.start || "-"}`)].join("\n");
11875
+ return ["Яндекс Календарь:", ...rows.map((row, index) => `${index + 1}. ${row.title || "(без названия)"} — ${row.startIso || row.start || "-"}${row.location ? `, ${row.location}` : ""}`)].join("\n");
10368
11876
  }
10369
11877
 
10370
11878
  if (/(контакт|адресн)/iu.test(normalized)) {
11879
+ if (/(дубликат|повтор)/iu.test(normalized)) {
11880
+ const rows = await yandexContactsFindDuplicates({ limit: 20 });
11881
+ if (!rows.length) return "Дубликаты контактов не найдены.";
11882
+ return rows.map((row) => formatToolResult({ rows: [row], outputs: [] }, {})).join("\n");
11883
+ }
11884
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(normalized)) {
11885
+ const field = /без\s+(?:email|почт)/iu.test(normalized) ? "email" : /без\s+телефон/iu.test(normalized) ? "phone" : /без\s+адрес/iu.test(normalized) ? "address" : "";
11886
+ const rows = await yandexContactsFindIncomplete({ field, limit: 30 });
11887
+ if (!rows.length) return "Неполные контакты по этому признаку не найдены.";
11888
+ return ["Неполные контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
11889
+ }
11890
+ if (/(экспорт|выгруз|сохрани|резерв|backup|бэкап)/iu.test(normalized) && /(диск|яндекс.?диск|облак)/iu.test(normalized)) {
11891
+ const result = await yandexContactsBackupToDisk({ format: /csv/iu.test(normalized) ? "csv" : "vcard", confirm: true });
11892
+ return `Контакты сохранены на Яндекс Диск: ${result.remote}. Записей: ${result.rows}.`;
11893
+ }
11894
+ if (/(экспорт|выгруз)/iu.test(normalized)) {
11895
+ const result = await yandexContactsExport(/csv/iu.test(normalized) ? "csv" : "vcard", {});
11896
+ return `Контакты экспортированы: ${result.output}. Записей: ${result.rows}.`;
11897
+ }
11898
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(normalized)) {
11899
+ const draft = parseYandexContactCreateRequest(question);
11900
+ if (!draft.name || (!draft.email && !draft.phone)) return "Для создания контакта укажите имя и email или телефон.";
11901
+ const result = await yandexContactsCreate({ ...draft, confirm: true });
11902
+ return `Контакт создан: ${formatYandexContact(result)}.`;
11903
+ }
11904
+ if (/(удали|удалить).{0,40}контакт/iu.test(normalized)) {
11905
+ const query = cleanupYandexContactActionQuery(question);
11906
+ const result = await yandexContactsDelete(query, { confirm: true });
11907
+ if (result.status === "ambiguous") return [`Нашел несколько контактов. Уточните:`, ...result.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
11908
+ if (result.status === "not-found") return `Контакт не найден: ${query}.`;
11909
+ return `Контакт удален: ${formatYandexContact(result)}.`;
11910
+ }
10371
11911
  if (/(добав|запиши|сохрани).{0,40}(email|e-mail|почт)/iu.test(normalized)) {
10372
11912
  const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
10373
11913
  const query = cleanupYandexContactEmailTarget(question, email);
@@ -10383,6 +11923,54 @@ async function buildYandexDirectAnswer(question, history = []) {
10383
11923
  }
10384
11924
  return `Email добавлен в контакт: ${result.name || query}, ${result.email}.`;
10385
11925
  }
11926
+ if (/(добав|запиши|сохрани).{0,40}(телефон|номер)/iu.test(normalized)) {
11927
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
11928
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
11929
+ if (!phone || !query) return "Укажите контакт и телефон.";
11930
+ const result = await yandexContactsUpdate(query, { phone, mode: "add-phone", confirm: true });
11931
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11932
+ return `Телефон добавлен: ${formatYandexContact(result)}.`;
11933
+ }
11934
+ if (/(добав|запиши|сохрани).{0,40}(адрес)/iu.test(normalized)) {
11935
+ const address = question.match(/(?:адрес|по адресу)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11936
+ const query = cleanupYandexContactActionQuery(question.replace(address, " "));
11937
+ if (!address || !query) return "Укажите контакт и адрес.";
11938
+ const result = await yandexContactsUpdate(query, { address, mode: "add-address", confirm: true });
11939
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11940
+ return `Адрес добавлен: ${formatYandexContact(result)}.`;
11941
+ }
11942
+ if (/(добав|запиши|сохрани).{0,40}(заметк|коммент|примеч)/iu.test(normalized)) {
11943
+ const note = question.match(/(?:заметк\p{L}*|коммент\p{L}*|примеч\p{L}*)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11944
+ const query = cleanupYandexContactActionQuery(question.replace(note, " "));
11945
+ if (!note || !query) return "Укажите контакт и текст заметки.";
11946
+ const result = await yandexContactsUpdate(query, { note, mode: "add-note", confirm: true });
11947
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11948
+ return `Заметка добавлена: ${formatYandexContact(result)}.`;
11949
+ }
11950
+ if (/(создай|сделай).{0,40}(папк).{0,40}(контакт)/iu.test(normalized)) {
11951
+ const query = cleanupYandexContactActionQuery(question);
11952
+ const result = await yandexContactCreateDiskFolder({ query, confirm: true });
11953
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11954
+ }
11955
+ if (/(отправь|пошли).{0,80}(ссылк|qr|qr-код|диск|яндекс.?диск)/iu.test(normalized)) {
11956
+ const remotePath = extractCloudPath(question);
11957
+ const contact = cleanupYandexContactActionQuery(question.replace(remotePath || "", " "));
11958
+ const result = await yandexContactSendDiskLinkQr({ contact, path: remotePath, confirm: true });
11959
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11960
+ }
11961
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(normalized)) {
11962
+ const draft = parseYandexMailSendRequest(question);
11963
+ const result = await yandexContactSendMail({ contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true });
11964
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11965
+ }
11966
+ if (/(создай|добавь|запланируй).{0,40}(встреч|событи|календар|телемост)/iu.test(normalized)) {
11967
+ const dateTime = extractDateTimeFromText(question);
11968
+ const query = cleanupYandexContactActionQuery(question);
11969
+ const result = /телемост/iu.test(normalized)
11970
+ ? await yandexContactCreateTelemostEvent({ query, ...dateTime, confirm: true })
11971
+ : await yandexContactCreateCalendarEvent({ query, ...dateTime, confirm: true });
11972
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11973
+ }
10386
11974
  if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
10387
11975
  const result = await yandexContactsStatus();
10388
11976
  return `Яндекс Контакты подключены: ${result.displayName || result.url}.`;
@@ -10400,8 +11988,214 @@ async function buildYandexDirectAnswer(question, history = []) {
10400
11988
  return "";
10401
11989
  }
10402
11990
 
11991
+ async function buildYandexContactsDirectAnswer(question, normalized = "") {
11992
+ const text = normalized || String(question || "").toLocaleLowerCase("ru-RU");
11993
+ if (/(авто|автомат|регуляр|кажд|период|обслужив|проверяй|проверку)/iu.test(text) && /(контакт)/iu.test(text) && /(включ|запусти|начни|поставь|создай)/iu.test(text)) {
11994
+ const days = Number(String(question || "").match(/(\d+)\s*(?:дн|день|дня|дней)/iu)?.[1] || 7);
11995
+ const result = await yandexContactsMaintenanceEnable(days, { backup: /(backup|бэкап|резерв|диск)/iu.test(text) });
11996
+ return `Регулярная проверка контактов включена: каждые ${result.days} дней. Backup на Диск: ${result.backup ? "yes" : "no"}.`;
11997
+ }
11998
+ if (/(авто|автомат|регуляр|кажд|период|обслужив|проверяй|проверку)/iu.test(text) && /(контакт)/iu.test(text) && /(выключ|отключ|останов|убери)/iu.test(text)) {
11999
+ await yandexContactsMaintenanceDisable();
12000
+ return "Регулярная проверка контактов выключена.";
12001
+ }
12002
+ if (/(обслужив|провер|диагност|doctor)/iu.test(text) && /(контакт)/iu.test(text) && /(сейчас|запусти|сделай|проверь)/iu.test(text)) {
12003
+ const result = await yandexContactsMaintenanceTick({ force: true, backup: /(backup|бэкап|резерв)/iu.test(text) });
12004
+ return [
12005
+ "Проверка контактов выполнена:",
12006
+ `Всего: ${result.total}`,
12007
+ `Неполных: ${result.incomplete}`,
12008
+ `Групп дубликатов: ${result.duplicateGroups}`,
12009
+ result.backupRemote ? `Backup: ${result.backupRemote}` : "",
12010
+ ].filter(Boolean).join("\n");
12011
+ }
12012
+ if (/(дубликат|повтор)/iu.test(text)) {
12013
+ const rows = await yandexContactsFindDuplicates({ limit: 20 });
12014
+ if (!rows.length) return "Дубликаты контактов не найдены.";
12015
+ return rows.map((row) => formatToolResult({ rows: [row], outputs: [] }, {})).join("\n");
12016
+ }
12017
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(text)) {
12018
+ const field = /без\s+(?:email|почт)/iu.test(text) ? "email" : /без\s+телефон/iu.test(text) ? "phone" : /без\s+адрес/iu.test(text) ? "address" : "";
12019
+ const rows = await yandexContactsFindIncomplete({ field, limit: 30 });
12020
+ if (!rows.length) return "Неполные контакты по этому признаку не найдены.";
12021
+ return ["Неполные контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
12022
+ }
12023
+ if (/(экспорт|выгруз|сохрани|резерв|backup|бэкап)/iu.test(text) && /(диск|яндекс.?диск|облак)/iu.test(text)) {
12024
+ const result = await yandexContactsBackupToDisk({ format: /csv/iu.test(text) ? "csv" : "vcard", confirm: true });
12025
+ return `Контакты сохранены на Яндекс Диск: ${result.remote}. Записей: ${result.rows}.`;
12026
+ }
12027
+ if (/(импорт|импортируй|загрузи).{0,40}контакт/iu.test(text)) {
12028
+ const inputPath = extractLocalInputPath(question) || question.match(/(?:из|файл)\s+([^\s]+(?:\.csv|\.vcf|\.vcard))/iu)?.[1] || "";
12029
+ if (!inputPath) return "Укажите файл CSV или vCard для импорта контактов.";
12030
+ const result = await yandexContactsImport(/\.csv$/iu.test(inputPath) ? "csv" : "vcard", { path: inputPath, confirm: true });
12031
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12032
+ }
12033
+ if (/(дн[еиь]\s+рожден|день\s+рождени|дней\s+рождени|birthday)/iu.test(text) && /(календар|событи|напомин)/iu.test(text)) {
12034
+ const result = await yandexContactsBirthdaysToCalendar({ confirm: true });
12035
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12036
+ }
12037
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(text) && /(школ|детск\w*\s+сад|детсад|садик|инн)/iu.test(text)) {
12038
+ const result = await yandexContactFromPublicEntity({ query: cleanupYandexContactActionQuery(question), confirm: true });
12039
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12040
+ }
12041
+ if (/(экспорт|выгруз)/iu.test(text)) {
12042
+ const result = await yandexContactsExport(/csv/iu.test(text) ? "csv" : "vcard", {});
12043
+ return `Контакты экспортированы: ${result.output}. Записей: ${result.rows}.`;
12044
+ }
12045
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(text)) {
12046
+ const draft = parseYandexContactCreateRequest(question);
12047
+ if (!draft.name || (!draft.email && !draft.phone)) return "Для создания контакта укажите имя и email или телефон.";
12048
+ const result = await yandexContactsCreate({ ...draft, confirm: true });
12049
+ return `Контакт создан: ${formatYandexContact(result)}.`;
12050
+ }
12051
+ if (/(удали|удалить).{0,40}контакт/iu.test(text)) {
12052
+ const query = extractEmailAddress(question) || String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || cleanupYandexContactActionQuery(question);
12053
+ const result = await yandexContactsDelete(query, { confirm: true });
12054
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12055
+ }
12056
+ if (/(добав|запиши|сохрани).{0,40}(email|e-mail|почт)/iu.test(text)) {
12057
+ const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
12058
+ const query = cleanupYandexContactEmailTarget(question, email);
12059
+ if (!email || !query) return "Укажите контакт и email. Пример: добавь email petrov@example.com к контакту Петров.";
12060
+ const result = await yandexContactsAddEmail(query, email, { confirm: true });
12061
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12062
+ }
12063
+ if (/(удали|удалить|убери).{0,40}(email|e-mail|почт)/iu.test(text)) {
12064
+ const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
12065
+ const query = cleanupYandexContactEmailTarget(question, email) || cleanupYandexContactActionQuery(question);
12066
+ if (!query) return "Укажите контакт, у которого удалить email.";
12067
+ const result = await yandexContactsUpdate(query, { removeEmail: email || true, mode: "remove-email", confirm: true });
12068
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12069
+ }
12070
+ if (/(добав|запиши|сохрани).{0,40}(телефон|номер)/iu.test(text)) {
12071
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
12072
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
12073
+ if (!phone || !query) return "Укажите контакт и телефон.";
12074
+ const result = await yandexContactsUpdate(query, { phone, mode: "add-phone", confirm: true });
12075
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12076
+ }
12077
+ if (/(удали|удалить|убери).{0,40}(телефон|номер)/iu.test(text)) {
12078
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
12079
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
12080
+ if (!query) return "Укажите контакт, у которого удалить телефон.";
12081
+ const result = await yandexContactsUpdate(query, { removePhone: phone || true, mode: "remove-phone", confirm: true });
12082
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12083
+ }
12084
+ if (/(переимен|измени\s+имя|смени\s+имя)/iu.test(text) && /(контакт)/iu.test(text)) {
12085
+ const newName = question.match(/(?:в|на|как)\s+["«]?([^"».,!?]+)["»]?\s*$/iu)?.[1]?.trim() || "";
12086
+ const query = cleanupYandexContactActionQuery(question.replace(newName, " "));
12087
+ if (!query || !newName) return "Укажите контакт и новое имя.";
12088
+ const result = await yandexContactsUpdate(query, { name: newName, confirm: true });
12089
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12090
+ }
12091
+ if (/(добав|запиши|сохрани).{0,40}(день\s+рожд|дат[ау]\s+рожд|birthday)/iu.test(text)) {
12092
+ const birthday = question.match(/(\d{1,2}[.\-/]\d{1,2}(?:[.\-/]\d{2,4})?|\d{4}-\d{2}-\d{2})/u)?.[1] || "";
12093
+ const query = cleanupYandexContactActionQuery(question.replace(birthday, " "));
12094
+ if (!birthday || !query) return "Укажите контакт и дату рождения.";
12095
+ const result = await yandexContactsUpdate(query, { birthday, mode: "add-birthday", confirm: true });
12096
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12097
+ }
12098
+ if (/(добав|запиши|сохрани).{0,40}(организац|должност)/iu.test(text)) {
12099
+ const org = question.match(/(?:организац\p{L}*|орг)\s*:?\s*(.*?)(?=\s+должност\p{L}*\s*:|$)/iu)?.[1]?.trim() || "";
12100
+ const title = question.match(/должност\p{L}*\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
12101
+ const query = cleanupYandexContactActionQuery(question.replace(org, " ").replace(title, " "));
12102
+ if (!query || (!org && !title)) return "Укажите контакт и организацию или должность.";
12103
+ const result = await yandexContactsUpdate(query, { org, title, mode: "add-org", confirm: true });
12104
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12105
+ }
12106
+ if (/(добав|запиши|сохрани).{0,40}(адрес)/iu.test(text)) {
12107
+ const address = question.match(/(?:адрес|по адресу)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
12108
+ const query = cleanupYandexContactActionQuery(question.replace(address, " "));
12109
+ if (!address || !query) return "Укажите контакт и адрес.";
12110
+ const result = await yandexContactsUpdate(query, { address, mode: "add-address", confirm: true });
12111
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12112
+ }
12113
+ if (/(добав|запиши|сохрани).{0,40}(заметк|коммент|примеч)/iu.test(text)) {
12114
+ const note = question.match(/(?:заметка|комментарий|примечание)\s*:\s*(.+)$/iu)?.[1]?.trim() || "";
12115
+ const query = cleanupYandexContactActionQuery(question.replace(note, " "));
12116
+ if (!note || !query) return "Укажите контакт и текст заметки.";
12117
+ const result = await yandexContactsUpdate(query, { note, mode: "add-note", confirm: true });
12118
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12119
+ }
12120
+ if (/(создай|сделай).{0,40}(папк).{0,40}(контакт)/iu.test(text)) {
12121
+ const query = cleanupYandexContactActionQuery(question);
12122
+ const result = await yandexContactCreateDiskFolder({ query, confirm: true });
12123
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12124
+ }
12125
+ if (/(отправь|пошли).{0,80}(ссылк|qr|qr-код|диск|яндекс.?диск)/iu.test(text)) {
12126
+ const remotePath = extractCloudPath(question);
12127
+ const contact = cleanupYandexContactActionQuery(question.replace(remotePath || "", " "));
12128
+ const result = await yandexContactSendDiskLinkQr({ contact, path: remotePath, confirm: true });
12129
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12130
+ }
12131
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(text)) {
12132
+ const draft = parseYandexMailSendRequest(question);
12133
+ const result = await yandexContactSendMail({ contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true });
12134
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12135
+ }
12136
+ if (/(создай|добавь|запланируй).{0,40}(встреч|событи|календар|телемост)/iu.test(text)) {
12137
+ const dateTime = extractDateTimeFromText(question);
12138
+ const query = cleanupYandexContactActionQuery(question);
12139
+ const result = /телемост/iu.test(text)
12140
+ ? await yandexContactCreateTelemostEvent({ query, ...dateTime, confirm: true })
12141
+ : await yandexContactCreateCalendarEvent({ query, ...dateTime, confirm: true });
12142
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12143
+ }
12144
+ if (/(статус|проверь|работает|доступ)/iu.test(text)) {
12145
+ const result = await yandexContactsStatus();
12146
+ return `Яндекс Контакты подключены: ${result.displayName || result.url}.`;
12147
+ }
12148
+ const query = cleanupYandexQuery(question);
12149
+ const rows = /(найди|поиск|покажи|посмотри)/iu.test(text) && query
12150
+ ? await yandexContactsSearch(query, { limit: 10 })
12151
+ : await yandexContactsList({ limit: 20 });
12152
+ if (!rows.length) return "Контакты по запросу не найдены.";
12153
+ return ["Яндекс Контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
12154
+ }
12155
+
12156
+ async function buildUserSkillDirectAnswer(question) {
12157
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
12158
+ if (!/(skill|скилл|скил|навык)/iu.test(normalized)) return "";
12159
+ if (/(создай|добавь|сделай|create|new)/iu.test(normalized)) {
12160
+ const name = extractUserSkillNameFromQuestion(question) || "user-skill";
12161
+ const result = await userSkillCreate({
12162
+ name,
12163
+ description: extractUserSkillDescription(question, name),
12164
+ instructions: question,
12165
+ tools: inferUserSkillTools(question),
12166
+ enable: true,
12167
+ confirm: true,
12168
+ });
12169
+ return `Skill создан: ${result.name}\nФайл: ${result.file}\nSkill включен.`;
12170
+ }
12171
+ if (/(выключ|отключ|disable)/iu.test(normalized)) {
12172
+ const name = extractUserSkillNameFromQuestion(question);
12173
+ if (!name) return "Какой skill выключить? Укажите имя.";
12174
+ const result = await userSkillSetEnabled(name, false);
12175
+ return `Skill ${result.name}: disabled`;
12176
+ }
12177
+ if (/(включ|enable)/iu.test(normalized)) {
12178
+ const name = extractUserSkillNameFromQuestion(question);
12179
+ if (!name) return "Какой skill включить? Укажите имя.";
12180
+ const result = await userSkillSetEnabled(name, true);
12181
+ return `Skill ${result.name}: enabled`;
12182
+ }
12183
+ if (/(удали|удалить|remove|delete)/iu.test(normalized)) {
12184
+ const name = extractUserSkillNameFromQuestion(question);
12185
+ if (!name) return "Какой skill удалить? Укажите имя.";
12186
+ const result = await userSkillDelete(name, { confirm: true });
12187
+ return `Skill удален: ${result.name}`;
12188
+ }
12189
+ if (/(список|покажи|какие|list)/iu.test(normalized)) {
12190
+ const skills = listSkills(await loadConfig()).filter((skill) => skill.source === "user");
12191
+ if (!skills.length) return "Пользовательских skills пока нет.";
12192
+ return ["Пользовательские skills:", ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)].join("\n");
12193
+ }
12194
+ return "";
12195
+ }
12196
+
10403
12197
  function isYandexServiceQuestion(normalized) {
10404
- return /(яндекс|яндес|язндекс|язндекс|яндкс|yandex|почт|письм|календар|контакт|телемост|спам|чернов|отправлен|исходящ|корзин)/iu.test(String(normalized || ""));
12198
+ return /(яндекс|яндес|язндекс|язндекс|яндкс|yandex|почт|письм|календар|контакт|телемост|документ|docs|360|спам|чернов|отправлен|исходящ|корзин)/iu.test(String(normalized || ""));
10405
12199
  }
10406
12200
 
10407
12201
  function isYandexIdentityQuestion(normalized) {
@@ -10485,6 +12279,13 @@ function isYandexMailReadRequest(normalizedQuestion) {
10485
12279
  || /(покажи\s+содерж|о чем|о чём|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(normalizedQuestion);
10486
12280
  }
10487
12281
 
12282
+ function isExplicitYandexDiskPathDelete(question) {
12283
+ const text = String(question || "");
12284
+ return /(удали|удалить|перемести\s+в\s+корзин)/iu.test(text)
12285
+ && /(яндекс.?диск|диск|облак|\/IOLA\/)/iu.test(text)
12286
+ && Boolean(extractCloudPath(text));
12287
+ }
12288
+
10488
12289
  function parseYandexMailReplyRequest(question, previousAssistantText = "") {
10489
12290
  const text = String(question || "").replace(/\s+/g, " ").trim();
10490
12291
  const uid = resolveYandexMailUidFromQuestion(text, previousAssistantText);
@@ -10542,6 +12343,49 @@ function cleanupYandexContactEmailTarget(question, email) {
10542
12343
  .trim();
10543
12344
  }
10544
12345
 
12346
+ function parseYandexContactCreateRequest(question) {
12347
+ const text = String(question || "").replace(/\s+/g, " ").trim();
12348
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
12349
+ const phone = text.match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
12350
+ const nameMatch = text.match(/(?:контакт|контакта)\s+["«]?([^"»:,]+)["»]?/iu)
12351
+ || text.match(/(?:создай|добавь|запиши|сохрани)\s+["«]?([^"»:,]+?)["»]?\s+(?:контакт|email|телефон|номер|почт)/iu);
12352
+ const address = text.match(/(?:адрес|по адресу)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|организация|должность)\s*:|$)/iu)?.[1]?.trim() || "";
12353
+ const note = text.match(/(?:заметка|примечание)\s*:?\s*(.*)$/iu)?.[1]?.trim() || "";
12354
+ const org = text.match(/(?:организация|орг)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|должность)\s*:|$)/iu)?.[1]?.trim() || "";
12355
+ const title = text.match(/(?:должность)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|организация)\s*:|$)/iu)?.[1]?.trim() || "";
12356
+ const rawName = (nameMatch?.[1] || text.replace(email, " ").replace(phone, " "))
12357
+ .replace(/\b(?:email|e-mail|почта|телефон|номер|адрес|заметка|организация|должность)\b[\s\S]*$/iu, "")
12358
+ .trim();
12359
+ return {
12360
+ name: cleanupYandexContactQuery(rawName),
12361
+ email,
12362
+ phone,
12363
+ address,
12364
+ note,
12365
+ org,
12366
+ title,
12367
+ };
12368
+ }
12369
+
12370
+ function cleanupYandexContactActionQuery(question) {
12371
+ const stopWords = new Set([
12372
+ "и", "к", "ко", "с", "со", "у", "для", "из", "на", "в", "во", "сегодня", "завтра", "послезавтра", "час", "часа", "часов", "день", "дня", "рождения", "рождение", "дату", "дата", "диске", "облаке", "контакт", "контакта", "контакту", "контактом", "адресная", "книга", "создай", "сделай",
12373
+ "добавь", "добавить", "запиши", "сохрани", "удали", "удалить", "папку", "папка", "отправь", "пошли",
12374
+ "напиши", "письмо", "сообщение", "ссылку", "ссылка", "qr", "qr-код", "диск", "яндекс", "телемост",
12375
+ "встречу", "встреча", "событие", "календарь", "телефон", "номер", "адрес", "заметка", "заметку", "комментарий",
12376
+ "примечание", "тема", "текст",
12377
+ ]);
12378
+ return String(question || "")
12379
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, " ")
12380
+ .replace(/(?:\+?\d[\d\s()\-]{6,}\d)/gu, " ")
12381
+ .replace(/\/[^\s"»]+/gu, " ")
12382
+ .replace(/[,:;.!?«»"()]+/gu, " ")
12383
+ .split(/\s+/u)
12384
+ .filter((token) => token && !stopWords.has(token.toLocaleLowerCase("ru-RU")))
12385
+ .join(" ")
12386
+ .trim();
12387
+ }
12388
+
10545
12389
  function formatYandexMailSummary(row) {
10546
12390
  return `#${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}${row.date ? `, ${row.date}` : ""}`;
10547
12391
  }
@@ -10636,6 +12480,96 @@ async function buildCloudDirectAnswer(question) {
10636
12480
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
10637
12481
  try {
10638
12482
  const provider = await getCloudProvider();
12483
+ if (provider === "yandex-disk" && /(документ|docs|360)/iu.test(normalized)) {
12484
+ if (/(ссылк|поделись|опубликуй|qr|qr-код)/iu.test(normalized)) {
12485
+ const result = await yandexDocsShare(extractCloudPath(question) || cleanupCloudQuery(question), { confirm: true });
12486
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12487
+ }
12488
+ if (/(создай|сделай|запиши|сохрани)/iu.test(normalized)) {
12489
+ const text = extractShareMessage(question) || cleanupCloudSaveText(question);
12490
+ if (!text) return "Укажите текст документа.";
12491
+ const result = await yandexDocsCreateText({ title: extractYandexDocTitle(question), text, confirm: true });
12492
+ return `Документ создан на Яндекс Диске: ${result.remote}.`;
12493
+ }
12494
+ if (/(прочитай|открой|покажи\s+текст)/iu.test(normalized)) {
12495
+ const result = await yandexDocsRead(extractCloudPath(question) || cleanupCloudQuery(question), {});
12496
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12497
+ }
12498
+ if (/(переимен|rename)/iu.test(normalized)) {
12499
+ const result = await yandexDocsRename(extractCloudPath(question) || cleanupCloudQuery(question), extractCloudNewName(question), { confirm: true });
12500
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12501
+ }
12502
+ if (/(удали|удалить)/iu.test(normalized)) {
12503
+ const result = await yandexDocsDelete(extractCloudPath(question) || cleanupCloudQuery(question), { confirm: true });
12504
+ return formatToolResult({ rows: [result], outputs: [] }, {});
12505
+ }
12506
+ if (/(найди|поиск)/iu.test(normalized)) {
12507
+ const rows = await yandexDocsFind(cleanupCloudQuery(question), { limit: 20 });
12508
+ if (!rows.length) return "Документы на Яндекс Диске не найдены.";
12509
+ return ["Документы на Яндекс Диске:", ...rows.map((row, index) => `${index + 1}. ${row.name} — ${row.path}`)].join("\n");
12510
+ }
12511
+ const rows = await yandexDocsList({ limit: 20 });
12512
+ if (!rows.length) return "Документы на Яндекс Диске не найдены.";
12513
+ return ["Документы на Яндекс Диске:", ...rows.map((row, index) => `${index + 1}. ${row.name} — ${row.path}`)].join("\n");
12514
+ }
12515
+ if (provider === "yandex-disk" && /(мест[оа]|сколько.*занято|сколько.*свобод|инфо|статус)/iu.test(normalized)) {
12516
+ const info = await yandexDiskInfo();
12517
+ return [
12518
+ "Яндекс Диск:",
12519
+ `Занято: ${formatBytes(info.usedSpace)} из ${formatBytes(info.totalSpace)}.`,
12520
+ `Корзина: ${formatBytes(info.trashSize)}.`,
12521
+ ].join("\n");
12522
+ }
12523
+ if (provider === "yandex-disk" && /(перенеси|перемести|скопируй|копир).{0,80}(из|с).{0,30}папк/iu.test(normalized) && /(ссылк|qr|qr-код|почт|email|контакт|@)/iu.test(normalized)) {
12524
+ const packageRequest = parseYandexDiskPackageRequest(question);
12525
+ if (!packageRequest.sourcePath || !packageRequest.targetFolder) return "Укажите исходную и целевую папку на Яндекс Диске.";
12526
+ if (!packageRequest.email && !packageRequest.contact) return "Укажите email или контакт, кому отправить ссылку.";
12527
+ const result = await yandexDiskPackageShareEmail({
12528
+ ...packageRequest,
12529
+ confirm: true,
12530
+ });
12531
+ return [
12532
+ `${result.mode === "move" ? "Перенес" : "Скопировал"} объектов: ${result.transferred}.`,
12533
+ `Папка: ${result.targetFolder}`,
12534
+ `Отправил: ${result.to.join(", ")}.`,
12535
+ `Ссылка: ${result.publicUrl}`,
12536
+ `QR-код: ${result.qrPublicUrl}`,
12537
+ ].join("\n");
12538
+ }
12539
+ if (provider === "yandex-disk" && /(корзин|удаленн|удалённ)/iu.test(normalized) && /(покажи|список|что|есть)/iu.test(normalized)) {
12540
+ const rows = await yandexDiskTrashList("", { limit: 20 });
12541
+ if (!rows.length) return "Корзина Яндекс Диска пуста.";
12542
+ return ["Корзина Яндекс Диска:", ...rows.map((row, index) => `${index + 1}. ${row.type === "dir" ? "папка" : "файл"} ${row.name} — ${row.path}`)].join("\n");
12543
+ }
12544
+ if (provider === "yandex-disk" && /(есть\s+ли|существует|проверь).{0,40}(файл|папк|\/)/iu.test(normalized)) {
12545
+ const remotePath = extractCloudPath(question);
12546
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12547
+ const result = await yandexDiskExists(normalizeCloudUserPath(remotePath, provider));
12548
+ return result.exists ? `На Яндекс Диске найдено: ${result.path} (${result.type}).` : `На Яндекс Диске не найдено: ${result.path}.`;
12549
+ }
12550
+ if (provider === "yandex-disk" && /(свойств|карточк|метадан|размер|информац).{0,40}(файл|папк|\/)/iu.test(normalized)) {
12551
+ const remotePath = extractCloudPath(question);
12552
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12553
+ const result = await yandexDiskStat(normalizeCloudUserPath(remotePath, provider));
12554
+ return [
12555
+ `${result.type === "dir" ? "Папка" : "Файл"}: ${result.name}`,
12556
+ `Путь: ${result.path}`,
12557
+ `Размер: ${formatBytes(result.size)}`,
12558
+ result.modified ? `Изменен: ${result.modified}` : "",
12559
+ result.mimeType ? `Тип: ${result.mimeType}` : "",
12560
+ result.publicUrl ? `Публичная ссылка: ${result.publicUrl}` : "",
12561
+ ].filter(Boolean).join("\n");
12562
+ }
12563
+ if (provider === "yandex-disk" && /(очисти|очистить).{0,30}корзин/iu.test(normalized)) {
12564
+ const result = await yandexDiskEmptyTrash({ confirm: true });
12565
+ return `Очистка корзины запрошена: ${result.remote}.`;
12566
+ }
12567
+ if (provider === "yandex-disk" && /(восстанов|верни).{0,40}(корзин|диск|файл|папк)/iu.test(normalized)) {
12568
+ const remotePath = extractCloudPath(question);
12569
+ if (!remotePath) return "Укажите путь объекта в корзине Яндекс Диска.";
12570
+ const result = await yandexDiskRestore(normalizeCloudUserPath(remotePath, provider), { confirm: true });
12571
+ return `Восстановил из корзины: ${result.remote}.`;
12572
+ }
10639
12573
  if (/(созда|сдела|добав).{0,30}(папк|директор)/iu.test(normalized) || /(папк|директор).{0,30}(созда|сдела|добав)/iu.test(normalized)) {
10640
12574
  const folderName = extractCloudFolderName(question) || "Новая папка";
10641
12575
  const remotePath = normalizeCloudUserPath(folderName, provider);
@@ -10663,17 +12597,101 @@ async function buildCloudDirectAnswer(question) {
10663
12597
  ].join("\n");
10664
12598
  }
10665
12599
 
12600
+ if (provider === "yandex-disk" && /(сними|убери|закрой).{0,30}(ссылк|публик)/iu.test(normalized)) {
12601
+ const remotePath = extractCloudPath(question);
12602
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12603
+ const result = await yandexDiskUnshare(normalizeCloudUserPath(remotePath, provider));
12604
+ return `Публичная ссылка снята: ${result.remote}.`;
12605
+ }
12606
+
12607
+ if (provider === "yandex-disk" && /(отправь|отправить|пошли|перешли).{0,80}(ссылк|qr|qr-код|код)/iu.test(normalized) && /(почт|email|e-mail|контакт|@)/iu.test(normalized)) {
12608
+ const remotePath = extractCloudPath(question);
12609
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12610
+ const recipient = extractShareRecipient(question);
12611
+ if (!recipient.email && !recipient.contact) return "Укажите email или имя контакта, кому отправить ссылку.";
12612
+ const result = await yandexDiskShareEmail({
12613
+ remotePath: normalizeCloudUserPath(remotePath, provider),
12614
+ to: recipient.email,
12615
+ contact: recipient.contact,
12616
+ subject: extractMailSubject(question) || "",
12617
+ text: extractShareMessage(question) || "",
12618
+ confirm: true,
12619
+ });
12620
+ return [
12621
+ `Отправил ссылку на Яндекс Диск: ${result.to.join(", ")}.`,
12622
+ `Ссылка: ${result.publicUrl}`,
12623
+ `QR-код: ${result.qrPublicUrl}`,
12624
+ ].join("\n");
12625
+ }
12626
+
10666
12627
  if (/(ссылк|поделись|опубликуй)/iu.test(normalized)) {
10667
12628
  const remotePath = extractCloudPath(question);
10668
12629
  if (!remotePath) return "Укажите путь к файлу на облачном диске, например: /IOLA/reports/report.md";
10669
- const result = await cloudShare(provider, normalizeCloudUserPath(remotePath, provider));
10670
- return `Публичная ссылка: ${result.publicUrl}`;
12630
+ const withQr = provider === "yandex-disk" && /(qr|qr-код|код)/iu.test(normalized);
12631
+ const result = withQr
12632
+ ? await yandexDiskShareWithQr(normalizeCloudUserPath(remotePath, provider), { confirm: true })
12633
+ : await cloudShare(provider, normalizeCloudUserPath(remotePath, provider));
12634
+ return withQr
12635
+ ? `Публичная ссылка: ${result.publicUrl}\nQR-код: ${result.qrPublicUrl}`
12636
+ : `Публичная ссылка: ${result.publicUrl}`;
12637
+ }
12638
+
12639
+ if (provider === "yandex-disk" && /(прочитай|открой|покажи содержим|текст)/iu.test(normalized) && /(файл|\.txt|\.md|\.json|\.csv)/iu.test(normalized) && !/(сохрани|запиши)/iu.test(normalized)) {
12640
+ const remotePath = extractCloudPath(question);
12641
+ if (!remotePath) return "Укажите путь к текстовому файлу на Яндекс Диске.";
12642
+ const result = await yandexDiskReadText(normalizeCloudUserPath(remotePath, provider));
12643
+ return [`Файл ${result.remote}:`, result.text.slice(0, 4000)].join("\n");
12644
+ }
12645
+
12646
+ if (provider === "yandex-disk" && /(скачай|загрузи\s+с\s+диска|сохрани\s+на\s+комп)/iu.test(normalized)) {
12647
+ const remotePath = extractCloudPath(question);
12648
+ if (!remotePath) return "Укажите путь к файлу на Яндекс Диске.";
12649
+ const outputPath = extractLocalOutputPath(question) || path.basename(remotePath);
12650
+ const result = await yandexDiskDownload(normalizeCloudUserPath(remotePath, provider), outputPath);
12651
+ return `Скачал файл с Яндекс Диска: ${result.local}`;
12652
+ }
12653
+
12654
+ if (provider === "yandex-disk" && /(загрузи|отправь|положи).{0,40}(на яндекс.?диск|на диск|в облак)/iu.test(normalized)) {
12655
+ const localPath = extractLocalInputPath(question);
12656
+ if (!localPath) return "Укажите локальный путь к файлу для загрузки на Яндекс Диск.";
12657
+ const remotePath = extractCloudPath(question) || `${cloudRootForProvider(provider)}/${path.basename(localPath)}`;
12658
+ const result = await yandexDiskUpload(localPath, normalizeCloudUserPath(remotePath, provider), { overwrite: true });
12659
+ return `Загрузил файл на Яндекс Диск: ${result.remote}`;
12660
+ }
12661
+
12662
+ if (provider === "yandex-disk" && /(?:^|\s)(?:переименуй|переименовать|переименовать|rename)(?:\s|$)/iu.test(normalized)) {
12663
+ const remotePath = extractCloudPath(question);
12664
+ const newName = extractCloudNewName(question);
12665
+ if (!remotePath || !newName) return "Укажите путь и новое имя. Пример: переименуй /IOLA/a.txt в b.txt на Яндекс Диске.";
12666
+ const result = await yandexDiskRename(normalizeCloudUserPath(remotePath, provider), newName, { confirm: true, overwrite: true });
12667
+ return `Переименовал на Яндекс Диске: ${result.from} -> ${result.remote}`;
12668
+ }
12669
+
12670
+ if (provider === "yandex-disk" && /(перемести|move)/iu.test(normalized)) {
12671
+ const { from, to } = extractCloudTwoPaths(question);
12672
+ if (!from || !to) return "Укажите откуда и куда переместить на Яндекс Диске.";
12673
+ const result = await yandexDiskMove(normalizeCloudUserPath(from, provider), normalizeCloudUserPath(to, provider), { confirm: true, overwrite: true });
12674
+ return `Переместил на Яндекс Диске: ${result.from} -> ${result.remote}`;
12675
+ }
12676
+
12677
+ if (provider === "yandex-disk" && /(скопируй|копир|copy)/iu.test(normalized)) {
12678
+ const { from, to } = extractCloudTwoPaths(question);
12679
+ if (!from || !to) return "Укажите откуда и куда скопировать на Яндекс Диске.";
12680
+ const result = await yandexDiskCopy(normalizeCloudUserPath(from, provider), normalizeCloudUserPath(to, provider), { confirm: true, overwrite: true });
12681
+ return `Скопировал на Яндекс Диске: ${result.from} -> ${result.remote}`;
12682
+ }
12683
+
12684
+ if (provider === "yandex-disk" && /(удали|удалить|перемести.*корзин)/iu.test(normalized)) {
12685
+ const remotePath = extractCloudPath(question);
12686
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12687
+ const result = await yandexDiskDelete(normalizeCloudUserPath(remotePath, provider), { confirm: true, permanently: /навсегда|окончательно|безвозвратно/iu.test(normalized) });
12688
+ return `Удалил на Яндекс Диске: ${result.remote} (${result.status}).`;
10671
12689
  }
10672
12690
 
10673
12691
  if (/(сохрани|запиши).{0,40}(на яндекс диске|в облак|на диск)/iu.test(normalized)) {
10674
12692
  const text = cleanupCloudSaveText(question);
10675
12693
  if (!text) return "Что сохранить на облачный диск?";
10676
- const remotePath = `${cloudRootForProvider(provider)}/notes/iola-${timestampForFile()}.txt`;
12694
+ const remotePath = extractCloudPath(question) || `${cloudRootForProvider(provider)}/notes/iola-${timestampForFile()}.txt`;
10677
12695
  const tempPath = path.join(CONFIG_DIR, `cloud-save-${Date.now()}.txt`);
10678
12696
  await mkdir(CONFIG_DIR, { recursive: true });
10679
12697
  await writeFile(tempPath, text, "utf8");
@@ -10691,7 +12709,7 @@ async function buildCloudDirectAnswer(question) {
10691
12709
  }
10692
12710
 
10693
12711
  function isCloudQuestion(question) {
10694
- return /(яндекс.?диск|yandex.?disk|облак|облачн|на диск|с диска|в диск|cloud|mail\.?ru|публичн.*ссылк|поделиться.*файл)/iu.test(String(question || ""));
12712
+ return /(\/IOLA\/|яндекс.?диск|yandex.?disk|облак|облачн|на диск|с диска|в диск|cloud|mail\.?ru|публичн.*ссылк|поделиться.*файл|qr-код|qr\s+код)/iu.test(String(question || ""));
10695
12713
  }
10696
12714
 
10697
12715
  function normalizeCloudUserPath(value, provider = "yandex-disk") {
@@ -10714,16 +12732,156 @@ function extractCloudFolderName(question) {
10714
12732
 
10715
12733
  function extractCloudPath(question) {
10716
12734
  const text = String(question || "").trim();
10717
- const pathMatch = text.match(/(?:^|\s)(\/IOLA\/[^\s]+|\/[^\s]+)/iu);
10718
- if (pathMatch?.[1]) return pathMatch[1];
10719
12735
  const quoted = text.match(/["«]([^"»]+)["»]/u);
10720
- if (quoted?.[1]) return quoted[1];
12736
+ if (quoted?.[1]) return cleanupCloudPathCandidate(quoted[1]);
12737
+ const iolaPath = text.match(/(?:^|\s)(\/IOLA\/.+)$/iu)?.[1];
12738
+ if (iolaPath) return cleanupCloudPathCandidate(iolaPath);
12739
+ const pathMatch = text.match(/(?:^|\s)(\/IOLA\/[^\s]+|\/[^\s]+)/iu);
12740
+ if (pathMatch?.[1]) return cleanupCloudPathCandidate(pathMatch[1]);
10721
12741
  const afterFolder = text.match(/(?:папк[аеуы]?|файл[ае]?)\s+([^,.!?]+)/iu);
10722
12742
  return afterFolder?.[1] ? cleanupCloudObjectName(afterFolder[1]) : "";
10723
12743
  }
10724
12744
 
12745
+ function cleanupCloudPathCandidate(value) {
12746
+ return String(value || "")
12747
+ .replace(/\s+(?:по\s+почт[еуы]|на\s+почт[уые]|контакту|получател[юя]|кому|с\s+темой|тема\s*:|текст\s*:).*$/iu, "")
12748
+ .replace(/\s+(?:на\s+яндекс.?диск(?:е)?|на\s+диск(?:е)?|в\s+облак(?:е|о)?).*/iu, "")
12749
+ .replace(/[.!?]+$/u, "")
12750
+ .trim();
12751
+ }
12752
+
12753
+ function extractCloudTwoPaths(question) {
12754
+ const text = String(question || "").trim();
12755
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]).filter(Boolean);
12756
+ if (quoted.length >= 2) return { from: quoted[0], to: quoted[1] };
12757
+ const paths = [...text.matchAll(/(?:^|\s)(\/[^\s,;]+)/gu)].map((match) => match[1]).filter(Boolean);
12758
+ if (paths.length >= 2) return { from: paths[0], to: paths[1] };
12759
+ const match = text.match(/(?:из|с|откуда)\s+(.+?)\s+(?:в|на|куда)\s+(.+?)(?:\s+на\s+яндекс|\s+на\s+диск|\s+в\s+облак|$)/iu)
12760
+ || text.match(/(?:перемести|скопируй|копируй|copy|move)\s+(.+?)\s+(?:в|на|куда)\s+(.+?)(?:\s+на\s+яндекс|\s+на\s+диск|\s+в\s+облак|$)/iu);
12761
+ return match ? { from: cleanupCloudObjectName(match[1]), to: cleanupCloudObjectName(match[2]) } : { from: "", to: "" };
12762
+ }
12763
+
12764
+ function extractCloudNewName(question) {
12765
+ const text = String(question || "").trim();
12766
+ return text.match(/(?:в|на|как)\s+["«]?([^"».,!?/\\]+(?:\.[a-z0-9а-яё]+)?)["»]?\s*(?:на\s+яндекс|на\s+диск|в\s+облак|$)/iu)?.[1]?.trim()
12767
+ || text.match(/(?:нов(?:ое|ый|ым)?\s+им(?:я|енем)|названи(?:е|ем))\s+["«]?([^"».,!?/\\]+)["»]?/iu)?.[1]?.trim()
12768
+ || "";
12769
+ }
12770
+
12771
+ function extractLocalInputPath(question) {
12772
+ const text = String(question || "").trim();
12773
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]).find((item) => /^[a-z]:[\\/]|\.{0,2}[\\/]/iu.test(item));
12774
+ if (quoted) return quoted;
12775
+ return text.match(/([a-z]:[\\/][^"»\s]+|\.\.?[\\/][^"»\s]+)/iu)?.[1] || "";
12776
+ }
12777
+
12778
+ function extractLocalOutputPath(question) {
12779
+ const text = String(question || "").trim();
12780
+ return text.match(/(?:в|на|как|куда)\s+([a-z]:[\\/][^"»\s]+|\.\.?[\\/][^"»\s]+)/iu)?.[1] || "";
12781
+ }
12782
+
12783
+ function extractShareRecipient(question) {
12784
+ const text = String(question || "");
12785
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
12786
+ if (email) return { email, contact: "" };
12787
+ const contact = text.match(/(?:контакт[ау]?|получател[юя]|кому|для|почт[уе])\s+["«]?([^"».,;!?@]+)["»]?/iu)?.[1]
12788
+ || text.match(/(?:отправь|отправить|пошли|перешли)\s+([^"».,;!?@]+?)\s+(?:ссылк|qr|qr-код|код)/iu)?.[1]
12789
+ || "";
12790
+ return { email: "", contact: cleanupYandexContactQuery(contact) };
12791
+ }
12792
+
12793
+ function extractMailSubject(question) {
12794
+ return String(question || "").match(/(?:тема|subject)\s*:\s*(.*?)(?=\s+(?:текст|сообщение|body)\s*:|$)/iu)?.[1]?.trim() || "";
12795
+ }
12796
+
12797
+ function extractShareMessage(question) {
12798
+ return String(question || "").match(/(?:текст|text|сообщение|body)\s*:\s*(.*)$/iu)?.[1]?.trim() || "";
12799
+ }
12800
+
12801
+ function extractYandexDocTitle(question) {
12802
+ const text = String(question || "");
12803
+ const raw = text.match(/(?:названи(?:е|ем)|имя|title)\s*:?\s*["«]?([^"».,:;]+)["»]?/iu)?.[1]?.trim()
12804
+ || text.match(/(?:документ|файл)\s+["«]?([^"».,:;]+)["»]?/iu)?.[1]?.trim()
12805
+ || "";
12806
+ return raw.replace(/\s+(?:текст|text|body|сообщение)\s*:?.*$/iu, "").trim();
12807
+ }
12808
+
12809
+ function extractCalendarTitle(question) {
12810
+ const text = String(question || "");
12811
+ const raw = text.match(/(?:тема|названи(?:е|ем)|title)\s*:?\s*["«]?([^"».,:;]+)["»]?/iu)?.[1]?.trim()
12812
+ || text.match(/(?:событи[ея]|встреч[ауи]|телемост)\s+["«]?([^"».,:;]+)["»]?/iu)?.[1]?.trim()
12813
+ || "";
12814
+ return raw
12815
+ .replace(/\s+(?:сегодня|завтра|послезавтра)(?:\s|$).*$/iu, "")
12816
+ .replace(/\s+(?:в\s+\d{1,2}(?::\d{2})?|на\s+\d{1,2}[.\-/]\d{1,2}[\d.\-/]*)(?:\s|$).*$/iu, "")
12817
+ .trim();
12818
+ }
12819
+
12820
+ function extractCalendarRepeat(question) {
12821
+ const text = String(question || "").toLocaleLowerCase("ru-RU");
12822
+ const count = Number(text.match(/(\d+)\s*(?:раз|повтор)/iu)?.[1] || 0);
12823
+ return {
12824
+ repeat: /ежеднев|каждый\s+день/iu.test(text) ? "daily"
12825
+ : /ежемесяч|каждый\s+месяц/iu.test(text) ? "monthly"
12826
+ : /ежегод|каждый\s+год/iu.test(text) ? "yearly"
12827
+ : "weekly",
12828
+ count: count || undefined,
12829
+ };
12830
+ }
12831
+
12832
+ function cleanupCalendarEventQuery(question) {
12833
+ return String(question || "")
12834
+ .replace(/(?:создай|добавь|запланируй|назначь|перенеси|перемести|измени|смени|удали|удалить|отмени|отменить|найди|поиск|покажи|добавь\s+напоминание|поставь\s+напоминание)/giu, " ")
12835
+ .replace(/(?:^|\s)(?:событи\p{L}*|встреч\p{L}*|телемост\p{L}*|календар\p{L}*|напомин\p{L}*|уведом\p{L}*|яндекс|на|к|ко|в|во|сегодня|завтра|послезавтра|час|часа|часов|минут|минуты)(?=\s|$)/giu, " ")
12836
+ .replace(/\d{1,2}[.\-/]\d{1,2}(?:[.\-/]\d{2,4})?/gu, " ")
12837
+ .replace(/\d{1,2}[:.]\d{2}/gu, " ")
12838
+ .replace(/(?:^|\s)\d{1,3}(?=\s|$)/gu, " ")
12839
+ .replace(/[,:;.!?«»"()]+/gu, " ")
12840
+ .replace(/\s+/g, " ")
12841
+ .trim();
12842
+ }
12843
+
12844
+ function parseYandexDiskPackageRequest(question) {
12845
+ const text = String(question || "").trim();
12846
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]);
12847
+ const paths = [...text.matchAll(/(?:^|\s)(\/[^\s,;]+)/gu)].map((match) => match[1]);
12848
+ const sourcePath = text.match(/(?:из|с)\s+папк[иы]?\s+([^,;]+?)(?=\s+(?:создай|сделай|и|отправь|перешли|на\s+яндекс)|[,;]|$)/iu)?.[1]?.trim()
12849
+ || quoted[1]
12850
+ || paths[1]
12851
+ || "";
12852
+ const targetFolder = text.match(/(?:создай|сделай)\s+папк[уи]?\s+([^,;]+?)(?=\s+(?:на\s+яндекс|и|,|$)|[,;]|$)/iu)?.[1]?.trim()
12853
+ || text.match(/(?:в|куда|целев\w*)\s+папк[уи]?\s+([^,;]+?)(?=\s+(?:на\s+яндекс|и|,|$))/iu)?.[1]?.trim()
12854
+ || quoted[0]
12855
+ || paths[0]
12856
+ || "";
12857
+ const recipient = extractShareRecipient(question);
12858
+ return {
12859
+ sourcePath: normalizeCloudUserPath(cleanupCloudObjectName(sourcePath), "yandex-disk"),
12860
+ targetFolder: normalizeCloudUserPath(cleanupCloudObjectName(targetFolder), "yandex-disk"),
12861
+ mode: /(перенеси|перемести|move)/iu.test(text) ? "move" : "copy",
12862
+ email: recipient.email,
12863
+ contact: recipient.contact,
12864
+ subject: extractMailSubject(question),
12865
+ text: extractShareMessage(question),
12866
+ };
12867
+ }
12868
+
12869
+ function formatBytes(value) {
12870
+ const bytes = Number(value || 0);
12871
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 Б";
12872
+ const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
12873
+ let size = bytes;
12874
+ let index = 0;
12875
+ while (size >= 1024 && index < units.length - 1) {
12876
+ size /= 1024;
12877
+ index += 1;
12878
+ }
12879
+ return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
12880
+ }
12881
+
10725
12882
  function cleanupCloudObjectName(value) {
10726
12883
  return String(value || "")
12884
+ .replace(/\s+(?:на\s+яндекс.?диск(?:е)?|на\s+диск(?:е)?|в\s+облак(?:е|о)?).*/iu, " ")
10727
12885
  .replace(/\b(?:на|в|у меня|яндекс.?диск(?:е)?|диск(?:е)?|облак(?:е|о)?|создай|сделай|добавь|покажи)\b/giu, " ")
10728
12886
  .replace(/\s+/g, " ")
10729
12887
  .trim();
@@ -10731,7 +12889,8 @@ function cleanupCloudObjectName(value) {
10731
12889
 
10732
12890
  function cleanupCloudQuery(question) {
10733
12891
  return String(question || "")
10734
- .replace(/\b(?:найди|поиск|где лежит|на|в|яндекс.?диск(?:е)?|облак(?:е|о)?|диск(?:е)?|файл|документ)\b/giu, " ")
12892
+ .replace(/яндекс\s+диск(?:е)?/giu, " ")
12893
+ .replace(/(?:найди|поиск|где лежит|на|в|яндекс|облак(?:е|о)?|диск(?:е)?|файл|документ)/giu, " ")
10735
12894
  .replace(/[?.!]+$/u, "")
10736
12895
  .replace(/\s+/g, " ")
10737
12896
  .trim();
@@ -10740,6 +12899,7 @@ function cleanupCloudQuery(question) {
10740
12899
  function cleanupCloudSaveText(question) {
10741
12900
  return String(question || "")
10742
12901
  .replace(/^.*?(?:сохрани|запиши)\s+/iu, "")
12902
+ .replace(/\s+(?:в|на|как)\s+\/[^\s]+/giu, " ")
10743
12903
  .replace(/\s+(?:на яндекс диске|в облак[ео]|на диск).*$/iu, "")
10744
12904
  .trim();
10745
12905
  }
@@ -11027,16 +13187,28 @@ async function localToolAsk(question, providerConfig, options) {
11027
13187
  if (!options.quiet) console.log(casualAnswer);
11028
13188
  return casualAnswer;
11029
13189
  }
11030
- const yandexAnswer = await buildYandexDirectAnswer(question, []);
11031
- if (yandexAnswer) {
11032
- if (!options.quiet) console.log(yandexAnswer);
11033
- return yandexAnswer;
13190
+ const userSkillAnswer = await buildUserSkillDirectAnswer(question);
13191
+ if (userSkillAnswer) {
13192
+ if (!options.quiet) console.log(userSkillAnswer);
13193
+ return userSkillAnswer;
13194
+ }
13195
+ if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
13196
+ const yandexContactAnswer = await buildYandexDirectAnswer(question, []);
13197
+ if (yandexContactAnswer) {
13198
+ if (!options.quiet) console.log(yandexContactAnswer);
13199
+ return yandexContactAnswer;
13200
+ }
11034
13201
  }
11035
13202
  const cloudAnswer = await buildCloudDirectAnswer(question);
11036
13203
  if (cloudAnswer) {
11037
13204
  if (!options.quiet) console.log(cloudAnswer);
11038
13205
  return cloudAnswer;
11039
13206
  }
13207
+ const yandexAnswer = await buildYandexDirectAnswer(question, []);
13208
+ if (yandexAnswer) {
13209
+ if (!options.quiet) console.log(yandexAnswer);
13210
+ return yandexAnswer;
13211
+ }
11040
13212
  const geoAnswer = await buildGeoDirectAnswer(question);
11041
13213
  if (geoAnswer) {
11042
13214
  if (!options.quiet) console.log(geoAnswer);
@@ -11131,6 +13303,9 @@ function isUnsupportedPublicEntityQuestion(normalized) {
11131
13303
 
11132
13304
  function buildCasualDirectAnswer(question) {
11133
13305
  const normalized = String(question || "").toLocaleLowerCase("ru-RU").trim();
13306
+ if (isCurrentDateTimeQuestion(normalized)) {
13307
+ return formatCurrentDateTimeAnswer(normalized);
13308
+ }
11134
13309
  if (/^(кто ты|что ты|какая ты модель|что ты за модель|что за модель|какая модель|назови модель|ты какая модель|ты кто)([?.!\s]*)$/iu.test(normalized)) {
11135
13310
  return "Я IOLA, первая городская модель искусственного интеллекта Йошкар-Олы. Работаю локально в CLI и отвечаю по открытым городским данным через проверяемые слои и API.";
11136
13311
  }
@@ -11240,8 +13415,9 @@ async function buildLocalToolPlan(question, providerConfig, options) {
11240
13415
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
11241
13416
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
11242
13417
  "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
11243
- "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_folders {}, yandex_mail_list {mailbox,limit,unread}, yandex_mail_search {mailbox,query}, yandex_mail_read {mailbox,uid}, yandex_mail_mark {mailbox,uid,seen}, yandex_mail_save_to_disk {uid,path}, yandex_mail_city_context {uid}, yandex_mail_map_addresses {uid}, yandex_mail_create_task {uid,title}, yandex_calendar_list {start,end}, yandex_contacts_search {query}.",
11244
- "Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_delete, yandex_mail_send, yandex_mail_reply, yandex_mail_forward, yandex_mail_delete, yandex_mail_create_calendar_event, yandex_mail_sender_to_contact, yandex_contacts_create, yandex_contacts_add_email, yandex_calendar_create_event, yandex_telemost_create_event.",
13418
+ "Yandex tools: yandex_identity_me {}, yandex_disk_info {}, yandex_disk_ls {path}, yandex_disk_mkdir {path}, yandex_disk_find {query,path}, yandex_disk_stat {path}, yandex_disk_exists {path}, yandex_disk_read_text {path}, yandex_disk_save_text {path,text}, yandex_disk_upload {localPath,remotePath}, yandex_disk_download {remotePath,outputPath}, yandex_disk_move {from,to,confirm}, yandex_disk_copy {from,to,confirm}, yandex_disk_rename {path,name,confirm}, yandex_disk_share {path,confirm}, yandex_disk_share_qr {path,confirm}, yandex_disk_share_email {path,to,contact,subject,text,confirm}, yandex_disk_package_share_email {sourcePath,targetFolder,to,contact,mode,confirm}, yandex_disk_unshare {path}, yandex_disk_delete {path,confirm}, yandex_disk_trash_list {}, yandex_disk_restore {path,confirm}, yandex_disk_empty_trash {confirm}, yandex_mail_folders {}, yandex_mail_list {mailbox,limit,unread}, yandex_mail_search {mailbox,query}, yandex_mail_read {mailbox,uid}, yandex_mail_mark {mailbox,uid,seen}, yandex_mail_send {to,subject,text,confirm}, yandex_mail_reply {uid,text,confirm}, yandex_mail_forward {uid,to,confirm}, yandex_mail_save_to_disk {uid,path}, yandex_mail_city_context {uid}, yandex_mail_map_addresses {uid}, yandex_mail_create_task {uid,title}, yandex_calendar_calendars {}, yandex_calendar_list {start,end}, yandex_calendar_search {query,start,end}, yandex_calendar_get {query}, yandex_calendar_create_event {title,start,end,location,attendees,reminders,confirm}, yandex_calendar_update {query,title,start,end,location,description,reminders,confirm}, yandex_calendar_move {query,start,end,confirm}, yandex_calendar_delete {query,confirm}, yandex_docs_list {path}, yandex_docs_find {query}, yandex_docs_create_text {title,text,format,confirm}, yandex_docs_read {path|query}, yandex_docs_share {path|query,confirm}, yandex_docs_rename {path|query,name,confirm}, yandex_docs_delete {path|query,confirm}, yandex_contacts_list {limit}, yandex_contacts_search {query}, yandex_contacts_get {query}, yandex_contacts_create {name,email,phone,address,note,confirm}, yandex_contacts_update {query,email,phone,address,note,birthday,org,title,confirm}, yandex_contacts_delete {query,confirm}, yandex_contacts_export_csv {}, yandex_contacts_find_incomplete {}, yandex_contacts_find_duplicates {}, yandex_contacts_backup_to_disk {format,confirm}, yandex_contact_send_mail {contact,subject,text,confirm}, yandex_contact_send_disk_link_qr {contact,path,confirm}, yandex_contact_create_disk_folder {contact,confirm}, yandex_contact_create_calendar_event {contact,start,end,title,confirm}, yandex_contact_create_telemost_event {contact,start,end,title,confirm}.",
13419
+ "Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_share_qr, yandex_disk_share_email, yandex_disk_package_share_email, yandex_disk_delete, yandex_disk_move, yandex_disk_copy, yandex_disk_rename, yandex_disk_restore, yandex_disk_empty_trash, yandex_mail_send, yandex_mail_reply, yandex_mail_forward, yandex_mail_delete, yandex_mail_create_calendar_event, yandex_mail_sender_to_contact, yandex_contacts_create, yandex_contacts_update, yandex_contacts_delete, yandex_contacts_add_email, yandex_contacts_add_phone, yandex_contacts_add_address, yandex_contacts_backup_to_disk, yandex_contact_send_mail, yandex_contact_send_disk_link_qr, yandex_contact_create_disk_folder, yandex_contact_create_calendar_event, yandex_contact_create_telemost_event, yandex_calendar_create_event, yandex_calendar_update, yandex_calendar_move, yandex_calendar_delete, yandex_calendar_add_reminder, yandex_docs_create_text, yandex_docs_share, yandex_docs_rename, yandex_docs_delete, yandex_telemost_create_event.",
13420
+ "User skill tools: user_skill_create {name,description,instructions,tools,enable,confirm}, user_skill_enable {name}, user_skill_disable {name}, user_skill_delete {name,confirm}, user_skill_list {}. Создавай skill только по явной просьбе пользователя и с confirm=true.",
11245
13421
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
11246
13422
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
11247
13423
  `Вопрос: ${question}`,
@@ -11299,14 +13475,48 @@ function parseJsonObject(text) {
11299
13475
 
11300
13476
  function inferToolPlan(question, options = {}) {
11301
13477
  const normalized = question.toLocaleLowerCase("ru-RU");
13478
+ if (isCurrentDateTimeQuestion(normalized)) {
13479
+ return { steps: [{ tool: "get_current_date", args: {} }] };
13480
+ }
13481
+ if (/(создай|добавь|сделай).{0,40}(skill|скилл|скил|навык)/iu.test(normalized)) {
13482
+ const name = normalizeUserSkillName(
13483
+ question.match(/(?:skill|скилл|скил|навык)\s+["«]?([^"».,:;\n]+)["»]?/iu)?.[1]
13484
+ || question.match(/(?:создай|добавь|сделай)\s+["«]?([^"».,:;\n]+?)["»]?\s+(?:skill|скилл|скил|навык)/iu)?.[1]
13485
+ || "user-skill"
13486
+ );
13487
+ return {
13488
+ steps: [{
13489
+ tool: "user_skill_create",
13490
+ args: {
13491
+ name,
13492
+ description: `Пользовательский skill: ${name}`,
13493
+ instructions: question,
13494
+ enable: true,
13495
+ confirm: true,
13496
+ },
13497
+ }],
13498
+ };
13499
+ }
11302
13500
  if (/(яндекс|yandex)/iu.test(normalized) && /(аккаунт|профил|логин|почт[аы]|email|e-mail|кто подключен)/iu.test(normalized)) {
11303
13501
  return { steps: [{ tool: "yandex_identity_me", args: {} }] };
11304
13502
  }
11305
13503
  if (/(яндекс|диск|облак)/iu.test(normalized)) {
13504
+ const diskPath = extractCloudPath(question) || CLOUD_DEFAULT_REMOTE_DIR;
13505
+ if (/(мест[оа]|сколько.*занято|сколько.*свобод|статус|инфо)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_info", args: {} }] };
13506
+ if (/(корзин|удаленн|удалённ)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_trash_list", args: { limit: 20 } }] };
11306
13507
  if (/(создай|сделай).{0,30}папк/iu.test(normalized)) {
11307
13508
  const folder = question.match(/папк[ауи]?\s+["«]?([^"»\n]+)["»]?/iu)?.[1]?.trim() || "Новая папка";
11308
13509
  return { steps: [{ tool: "yandex_disk_mkdir", args: { path: `${CLOUD_DEFAULT_REMOTE_DIR}/${folder}` } }] };
11309
13510
  }
13511
+ if (/(прочитай|открой|содержим|текст)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_read_text", args: { path: diskPath } }] };
13512
+ if (/(скачай|download)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_download", args: { remotePath: diskPath, outputPath: path.basename(diskPath) } }] };
13513
+ if (/(ссылк|поделись|опубликуй)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_share", args: { path: diskPath, confirm: true } }] };
13514
+ if (/(удали|удалить)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_delete", args: { path: diskPath, confirm: true } }] };
13515
+ if (/(переимен|rename)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_rename", args: { path: diskPath, name: extractCloudNewName(question), confirm: true } }] };
13516
+ if (/(перемести|move|скопируй|копир|copy)/iu.test(normalized)) {
13517
+ const { from, to } = extractCloudTwoPaths(question);
13518
+ return { steps: [{ tool: /(скопируй|копир|copy)/iu.test(normalized) ? "yandex_disk_copy" : "yandex_disk_move", args: { from, to, confirm: true, overwrite: true } }] };
13519
+ }
11310
13520
  if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_find", args: { query: question, path: CLOUD_DEFAULT_REMOTE_DIR, limit: 20 } }] };
11311
13521
  return { steps: [{ tool: "yandex_disk_ls", args: { path: CLOUD_DEFAULT_REMOTE_DIR } }] };
11312
13522
  }
@@ -11321,10 +13531,39 @@ function inferToolPlan(question, options = {}) {
11321
13531
  if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_search", args: { mailbox, query: question, limit: 20 } }] };
11322
13532
  return { steps: [{ tool: "yandex_mail_list", args: { mailbox, limit: 10, unread: /непрочитан/iu.test(normalized) } }] };
11323
13533
  }
13534
+ if (/(документ|docs|360)/iu.test(normalized) && /(яндекс|диск|облак|360|docs)/iu.test(normalized)) {
13535
+ const target = extractCloudPath(question) || cleanupYandexQuery(question);
13536
+ if (/(создай|сделай|запиши|сохрани)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_create_text", args: { title: extractYandexDocTitle(question), text: extractShareMessage(question) || cleanupCloudSaveText(question), confirm: true } }] };
13537
+ if (/(прочитай|открой|текст)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_read", args: { query: target } }] };
13538
+ if (/(ссылк|поделись|опубликуй|qr|qr-код)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_share", args: { query: target, confirm: true } }] };
13539
+ if (/(переимен|rename)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_rename", args: { query: target, name: extractCloudNewName(question), confirm: true } }] };
13540
+ if (/(удали|удалить)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_delete", args: { query: target, confirm: true } }] };
13541
+ if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_docs_find", args: { query: cleanupYandexQuery(question), limit: 20 } }] };
13542
+ return { steps: [{ tool: "yandex_docs_list", args: { limit: 20 } }] };
13543
+ }
11324
13544
  if (/(календар|событи|встреч|телемост)/iu.test(normalized)) {
11325
- return { steps: [{ tool: normalized.includes("телемост") ? "yandex_telemost_create_event" : "yandex_calendar_list", args: { limit: 20 } }] };
13545
+ if (/(создай|добавь|запланируй|назначь)/iu.test(normalized)) {
13546
+ const dateTime = extractDateTimeFromText(question);
13547
+ return { steps: [{ tool: /телемост/iu.test(normalized) ? "yandex_telemost_create_event" : "yandex_calendar_create_event", args: { ...dateTime, title: extractCalendarTitle(question) || (/телемост/iu.test(normalized) ? "Телемост IOLA" : "Событие IOLA"), confirm: true } }] };
13548
+ }
13549
+ if (/(перенеси|перемести|измени\s+время|смени\s+время)/iu.test(normalized)) return { steps: [{ tool: "yandex_calendar_move", args: { query: cleanupCalendarEventQuery(question), ...extractDateTimeFromText(question), confirm: true } }] };
13550
+ if (/(удали|удалить|отмени|отменить)/iu.test(normalized)) return { steps: [{ tool: "yandex_calendar_delete", args: { query: cleanupCalendarEventQuery(question), confirm: true } }] };
13551
+ if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_calendar_search", args: { query: cleanupCalendarEventQuery(question), limit: 20 } }] };
13552
+ return { steps: [{ tool: "yandex_calendar_list", args: { limit: 20 } }] };
11326
13553
  }
11327
13554
  if (/(контакт|адресн)/iu.test(normalized)) {
13555
+ if (/(дубликат|повтор)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_find_duplicates", args: { limit: 20 } }] };
13556
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_find_incomplete", args: { limit: 30 } }] };
13557
+ if (/(экспорт|выгруз)/iu.test(normalized)) return { steps: [{ tool: /csv/iu.test(normalized) ? "yandex_contacts_export_csv" : "yandex_contacts_export_vcard", args: {} }] };
13558
+ if (/(резерв|backup|бэкап|диск|яндекс.?диск)/iu.test(normalized) && /(контакт)/iu.test(normalized) && /(сохрани|экспорт|выгруз|резерв|backup|бэкап)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_backup_to_disk", args: { format: /csv/iu.test(normalized) ? "csv" : "vcard", confirm: true } }] };
13559
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_create", args: { ...parseYandexContactCreateRequest(question), confirm: true } }] };
13560
+ if (/(удали|удалить)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_delete", args: { query: cleanupYandexContactActionQuery(question), confirm: true } }] };
13561
+ if (/(отправь|пошли).{0,80}(ссылк|qr|qr-код|диск|яндекс.?диск)/iu.test(normalized)) return { steps: [{ tool: "yandex_contact_send_disk_link_qr", args: { contact: cleanupYandexContactActionQuery(question), path: extractCloudPath(question), confirm: true } }] };
13562
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(normalized)) {
13563
+ const draft = parseYandexMailSendRequest(question);
13564
+ return { steps: [{ tool: "yandex_contact_send_mail", args: { contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true } }] };
13565
+ }
13566
+ if (/(создай|добавь|запланируй).{0,40}(встреч|событи|календар|телемост)/iu.test(normalized)) return { steps: [{ tool: /телемост/iu.test(normalized) ? "yandex_contact_create_telemost_event" : "yandex_contact_create_calendar_event", args: { contact: cleanupYandexContactActionQuery(question), ...extractDateTimeFromText(question), confirm: true } }] };
11328
13567
  return { steps: [{ tool: "yandex_contacts_search", args: { query: question, limit: 20 } }] };
11329
13568
  }
11330
13569
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
@@ -11680,7 +13919,7 @@ function formatToolExecutionError(error, plan) {
11680
13919
  }
11681
13920
 
11682
13921
  function availableToolNames(options = {}) {
11683
- const names = new Set([...LOCAL_TOOLS, ...YANDEX_TOOLS]);
13922
+ const names = new Set([...LOCAL_TOOLS, ...YANDEX_TOOLS, ...USER_SKILL_TOOLS]);
11684
13923
  if (options.files) {
11685
13924
  for (const tool of FILE_TOOLS) names.add(tool);
11686
13925
  }
@@ -11749,6 +13988,10 @@ async function executeToolPlan(plan, options = {}) {
11749
13988
  const result = await executeYandexTool(step.tool, step.args || {});
11750
13989
  current = Array.isArray(result) ? result : [result];
11751
13990
  outputs.push({ tool: step.tool, rows: current.length });
13991
+ } else if (USER_SKILL_TOOLS.includes(step.tool)) {
13992
+ const result = await executeUserSkillTool(step.tool, step.args || {});
13993
+ current = Array.isArray(result) ? result : [result];
13994
+ outputs.push({ tool: step.tool, rows: current.length });
11752
13995
  } else if (String(step.tool || "").startsWith("mcp:")) {
11753
13996
  const result = await callConfiguredMcpTool(step.tool, step.args || {});
11754
13997
  current = Array.isArray(result) ? result : [result];
@@ -11789,14 +14032,34 @@ async function executeToolPlan(plan, options = {}) {
11789
14032
 
11790
14033
  function getCurrentDateInfo() {
11791
14034
  const now = new Date();
14035
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
11792
14036
  return {
11793
14037
  name: "текущая дата",
11794
14038
  date: new Intl.DateTimeFormat("ru-RU", { dateStyle: "long" }).format(now),
11795
14039
  time: new Intl.DateTimeFormat("ru-RU", { timeStyle: "short" }).format(now),
14040
+ weekday: new Intl.DateTimeFormat("ru-RU", { weekday: "long" }).format(now),
14041
+ timezone: timeZone,
11796
14042
  iso: now.toISOString(),
11797
14043
  };
11798
14044
  }
11799
14045
 
14046
+ function isCurrentDateTimeQuestion(normalized) {
14047
+ const text = String(normalized || "");
14048
+ return /^(?:какая|какой|какое|скажи|подскажи|что)\s+(?:сегодня\s+)?(?:дата|день|число|время|день недели)|^(?:сегодня|сейчас)\??$/iu.test(text)
14049
+ || /(?:какая\s+сегодня\s+дата|какой\s+сегодня\s+день|который\s+час|сколько\s+времени|текущее\s+время|текущая\s+дата|дата\s+сегодня)/iu.test(text);
14050
+ }
14051
+
14052
+ function formatCurrentDateTimeAnswer(normalized) {
14053
+ const info = getCurrentDateInfo();
14054
+ if (/(время|час|сейчас|сколько)/iu.test(normalized) && !/(дата|день|число)/iu.test(normalized)) {
14055
+ return `Сейчас ${info.time}. Часовой пояс: ${info.timezone}.`;
14056
+ }
14057
+ if (/(день недели)/iu.test(normalized) && !/(дата|число)/iu.test(normalized)) {
14058
+ return `Сегодня ${info.weekday}.`;
14059
+ }
14060
+ return `Сегодня ${info.date}, ${info.weekday}. Время: ${info.time}. Часовой пояс: ${info.timezone}.`;
14061
+ }
14062
+
11800
14063
  function getLocalMcpToolNames() {
11801
14064
  return mcpTools().map((tool) => `mcp:iola-local:${tool.name}`);
11802
14065
  }
@@ -11880,23 +14143,74 @@ function formatToolResult(result, options) {
11880
14143
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
11881
14144
  }
11882
14145
  if (row.date && row.time) return `Сегодня ${row.date}, ${row.time}.`;
14146
+ if (row.status === "calendar-event-created" || row.status === "telemost-event-created" || row.status === "telemost-calendar-fallback-created") {
14147
+ return `${row.status === "calendar-event-created" ? "Событие" : "Телемост"} создан: ${row.title || row.uid}${row.start ? `, ${row.start}` : ""}${row.telemost?.joinUrl ? `\nСсылка: ${row.telemost.joinUrl}` : row.status === "telemost-calendar-fallback-created" ? "\nПрямая ссылка Телемоста через API недоступна, создано событие календаря." : ""}`;
14148
+ }
14149
+ if (row.status === "calendar-event-updated") return `Событие обновлено: ${row.title || row.uid}`;
14150
+ if (row.status === "calendar-event-deleted") return `Событие удалено: ${row.title || row.uid}`;
14151
+ if (row.status === "ambiguous" && row.events) return [`Нашел несколько событий. Уточните:`, ...row.events.map((event, index) => `${index + 1}. ${event.title || event.uid} — ${event.startIso || event.start || "-"}`)].join("\n");
14152
+ if (row.status === "not-found" && row.kind === "calendar-event") return `Событие не найдено: ${row.query}`;
14153
+ if (row.status === "document-created") return `Документ создан на Яндекс Диске: ${row.remote}`;
14154
+ if (row.status === "binary-document") return `${row.name || row.remote}: ${row.message}`;
14155
+ if (row.status === "not-document") return `Это не документ: ${row.path}`;
14156
+ if (row.status === "ambiguous" && row.docs) return [`Нашел несколько документов. Уточните:`, ...row.docs.map((doc, index) => `${index + 1}. ${doc.name} — ${doc.path}`)].join("\n");
14157
+ if (row.status === "not-found" && row.docs !== undefined) return `Документ не найден: ${row.query}`;
14158
+ if (row.status === "contact-mail-sent") return `Письмо контакту отправлено: ${row.contact}. Тема: ${row.subject || "-"}`;
14159
+ if (row.status === "contact-disk-link-sent") return `Отправил контакту ${row.contact} ссылку на Яндекс Диск.\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
14160
+ if (row.status === "contact-folder-created") return `Папка контакта создана: ${row.remote}\nКарточка: ${row.cardPath}`;
14161
+ if (row.status === "contact-calendar-event-created" || row.status === "contact-telemost-event-created") return `${row.status === "contact-telemost-event-created" ? "Телемост" : "Встреча"} создана: ${row.title || row.uid}. Участник: ${row.attendee || "-"}`;
14162
+ if (row.status === "contacts-backup") return `Контакты сохранены на Яндекс Диск: ${row.remote}. Записей: ${row.rows}.`;
14163
+ if (row.status === "contacts-imported") return `Контакты импортированы из ${row.input}. Создано: ${row.created}, пропущено: ${row.skipped}.`;
14164
+ if (row.status === "birthday-events-created") return `События дней рождения созданы: ${row.created}. Контактов с днем рождения: ${row.totalWithBirthday}.`;
11883
14165
  if (row.provider === "yandex-disk" && row.publicUrl) return `Публичная ссылка: ${row.publicUrl}`;
14166
+ if (row.provider === "yandex-disk" && row.status === "shared-with-qr") return `Публичная ссылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
14167
+ if (row.provider === "yandex-disk" && row.status === "shared-and-sent") return `Отправил ссылку на Яндекс Диск: ${Array.isArray(row.to) ? row.to.join(", ") : row.to}\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
14168
+ if (row.provider === "yandex-disk" && row.status === "package-shared-and-sent") return `${row.mode === "move" ? "Перенес" : "Скопировал"} объектов: ${row.transferred}\nПапка: ${row.targetFolder}\nОтправил: ${Array.isArray(row.to) ? row.to.join(", ") : row.to}\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
14169
+ if (row.provider === "yandex-disk" && typeof row.exists === "boolean") return `Яндекс Диск: ${row.path || row.remote} ${row.exists ? "найден" : "не найден"}`;
14170
+ if (row.provider === "yandex-disk" && (row.status === "moved" || row.status === "copied" || row.status === "restored")) return `Яндекс Диск: ${row.status} ${row.from ? `${row.from} -> ` : ""}${row.remote}`;
14171
+ if (row.provider === "yandex-disk" && row.status === "unpublished") return `Яндекс Диск: публичная ссылка снята ${row.remote}`;
14172
+ if (row.provider === "yandex-disk" && row.status === "trash-empty-requested") return `Яндекс Диск: очистка корзины запрошена`;
14173
+ if (row.provider === "yandex-disk" && row.text) return `Яндекс Диск: ${row.remote}\n${String(row.text).slice(0, 2000)}`;
11884
14174
  if (row.provider === "yandex-disk" && row.remote) return `Яндекс Диск: ${row.status || "ok"} ${row.remote}`;
11885
14175
  if (row.uid && (row.subject || row.from)) return `Письмо #${row.uid}: ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`;
11886
14176
  if (row.status === "moved-to-trash") return `Письмо #${row.uid} перемещено в корзину: ${row.to}`;
11887
14177
  if (row.status === "seen" || row.status === "unseen") return `Письмо #${row.uid}: ${row.status === "seen" ? "прочитано" : "непрочитано"}`;
11888
14178
  if (row.status === "sent" && row.to) return `Письмо отправлено: ${Array.isArray(row.to) ? row.to.join(", ") : row.to}. Тема: ${row.subject || "-"}`;
14179
+ if (row.status === "contact-mail-sent") return `Письмо контакту отправлено: ${row.contact}. Тема: ${row.subject || "-"}`;
14180
+ if (row.status === "contact-disk-link-sent") return `Отправил контакту ${row.contact} ссылку на Яндекс Диск.\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
14181
+ if (row.status === "contact-folder-created") return `Папка контакта создана: ${row.remote}\nКарточка: ${row.cardPath}`;
14182
+ if (row.status === "contact-calendar-event-created" || row.status === "contact-telemost-event-created") return `${row.status === "contact-telemost-event-created" ? "Телемост" : "Встреча"} создана: ${row.title || row.uid}. Участник: ${row.attendee || "-"}`;
14183
+ if (row.status === "contacts-backup") return `Контакты сохранены на Яндекс Диск: ${row.remote}. Записей: ${row.rows}.`;
14184
+ if (row.status === "exported" && row.output) return `Контакты экспортированы: ${row.output}. Записей: ${row.rows}.`;
14185
+ if (row.status === "deleted" && (row.email || row.phone || row.name)) return `Контакт удален: ${formatYandexContact(row)}`;
14186
+ if (row.status === "updated" && (row.email || row.phone || row.name)) return `Контакт обновлен: ${formatYandexContact(row)}`;
14187
+ if (row.status === "created" && (row.email || row.phone || row.name)) return `Контакт создан: ${formatYandexContact(row)}`;
14188
+ if (row.status === "no-email" && row.contact) return `У контакта нет email: ${formatYandexContact(row.contact)}`;
14189
+ if (row.status === "ambiguous" && row.contacts) return [`Нашел несколько контактов. Уточните:`, ...row.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
14190
+ if (row.status === "not-found" && row.query !== undefined) return `Контакт не найден: ${row.query}`;
14191
+ if (row.status === "duplicate-group" && row.contacts) return [`Дубликаты (${row.key}):`, ...row.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
14192
+ if (row.status === "created" && row.file && row.name) return `Skill создан: ${row.name}\nФайл: ${row.file}${row.enabled ? "\nSkill включен." : ""}`;
14193
+ if ((row.status === "enabled" || row.status === "disabled") && row.name) return `Skill ${row.name}: ${row.status}`;
14194
+ if (row.status === "deleted" && row.name) return `Skill удален: ${row.name}`;
11889
14195
  if (row.special || row.delimiter) return `Папка почты: ${row.name}${row.special ? ` (${row.special})` : ""}`;
11890
14196
  if (row.login || row.defaultEmail) return `Yandex ID: ${row.displayName || row.login || "-"}${row.defaultEmail ? `, ${row.defaultEmail}` : ""}`;
11891
14197
  if (row.title && (row.start || row.end)) return `${row.title}: ${row.start || "-"}${row.end ? ` - ${row.end}` : ""}`;
11892
- if (row.email || row.phone) return formatYandexContact(row);
14198
+ if (row.email || row.phone || row.emails || row.phones) return formatYandexContact(row);
11893
14199
  return `${row.name || row.check || row.inn || "строка"}: ${row.address || row.phone || row.email || row.website || row.count || ""}`;
11894
14200
  }).join("\n");
11895
14201
  }
11896
14202
 
11897
14203
  function formatYandexContact(row) {
11898
- const name = row.name && row.name !== row.email ? row.name : "";
11899
- return [name || row.email || "Контакт", name && row.email ? row.email : "", row.phone || ""].filter(Boolean).join(", ");
14204
+ const emails = row.emails?.length ? row.emails : (row.email ? [row.email] : []);
14205
+ const phones = row.phones?.length ? row.phones : (row.phone ? [row.phone] : []);
14206
+ const name = row.name && row.name !== emails[0] ? row.name : "";
14207
+ return [
14208
+ name || emails[0] || phones[0] || "Контакт",
14209
+ emails.length ? `email: ${emails.join(", ")}` : "",
14210
+ phones.length ? `тел: ${phones.join(", ")}` : "",
14211
+ row.org ? `орг: ${row.org}` : "",
14212
+ row.address ? `адрес: ${row.address}` : "",
14213
+ ].filter(Boolean).join(", ");
11900
14214
  }
11901
14215
 
11902
14216
  function applyRuntimeConfig(target, value) {
@@ -12242,8 +14556,11 @@ async function buildAiMessages(question, dataContext, history, options = {}, con
12242
14556
  const projectContext = options.bare ? "" : await buildProjectContextText();
12243
14557
  const skillsText = options.bare ? "" : await buildSkillsText(config, question, options);
12244
14558
  const hasDataContext = dataContext.enabled !== false;
14559
+ const currentDate = getCurrentDateInfo();
12245
14560
  const system = [
12246
14561
  "Ты терминальный AI-агент городского округа Йошкар-Ола.",
14562
+ `Текущие дата и время CLI: ${currentDate.date}, ${currentDate.weekday}, ${currentDate.time}; часовой пояс: ${currentDate.timezone}; ISO: ${currentDate.iso}.`,
14563
+ "Если пользователь спрашивает про сегодня, завтра, вчера, текущую дату, время или относительные сроки, опирайся на текущие дату и время CLI.",
12247
14564
  "Отвечай на русском языке естественно и по смыслу запроса пользователя.",
12248
14565
  "Не смешивай языки. Не выдумывай факты, географию и числа.",
12249
14566
  "Если пользователь просто здоровается, ответь коротким приветствием и спроси, чем помочь.",
@@ -13395,12 +15712,12 @@ function parseOptions(args) {
13395
15712
 
13396
15713
  for (let index = 0; index < args.length; index += 1) {
13397
15714
  const arg = args[index];
13398
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--optional" || arg === "--project" || arg === "--dry-run" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--force" || arg === "--append" || arg === "--preserve-active" || arg === "--open" || arg === "--print-url") {
15715
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--optional" || arg === "--project" || arg === "--dry-run" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--force" || arg === "--append" || arg === "--preserve-active" || arg === "--open" || arg === "--print-url" || arg === "--enable") {
13399
15716
  result[arg.slice(2)] = true;
13400
15717
  } else if (arg === "--check" || arg === "--upgrade-node") {
13401
15718
  result.check = true;
13402
15719
  result[arg.slice(2)] = true;
13403
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-url" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token" || arg === "--app") {
15720
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--instructions" || arg === "--allowed-tools" || arg === "--tool" || arg === "--uses" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-url" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token" || arg === "--app") {
13404
15721
  result[arg.slice(2)] = args[index + 1];
13405
15722
  index += 1;
13406
15723
  } else {
@@ -13607,6 +15924,209 @@ function findSkill(name, config) {
13607
15924
  return listSkills(config).find((skill) => skill.name === name);
13608
15925
  }
13609
15926
 
15927
+ function printSkillsList(skills, config) {
15928
+ if (!skills.length) {
15929
+ console.log("Нет данных.");
15930
+ return;
15931
+ }
15932
+ const nameWidth = Math.min(28, Math.max(5, ...skills.map((skill) => visibleLength(skill.name))));
15933
+ console.log(`${padCell("Вкл", 3)} ${padCell("Skill", nameWidth)} Описание`);
15934
+ console.log(`${"-".repeat(3)} ${"-".repeat(nameWidth)} ${"-".repeat(8)}`);
15935
+ for (const skill of skills) {
15936
+ const enabled = isSkillEnabled(config, skill.name) ? "yes" : "no";
15937
+ console.log(`${padCell(enabled, 3)} ${padCell(skill.name, nameWidth)} ${skill.description || "-"}`);
15938
+ }
15939
+ }
15940
+
15941
+ async function executeUserSkillTool(tool, args = {}) {
15942
+ if (tool === "user_skill_list") return listSkills(await loadConfig()).filter((skill) => skill.source === "user");
15943
+ if (tool === "user_skill_create") return userSkillCreate({ ...args, confirm: args.confirm === true });
15944
+ if (tool === "user_skill_enable") return userSkillSetEnabled(args.name, true);
15945
+ if (tool === "user_skill_disable") return userSkillSetEnabled(args.name, false);
15946
+ if (tool === "user_skill_delete") return userSkillDelete(args.name, { confirm: args.confirm === true });
15947
+ throw new Error(`Неизвестный user skill tool: ${tool}`);
15948
+ }
15949
+
15950
+ async function userSkillCreate(args = {}) {
15951
+ if (!args.confirm) throw new Error("Для создания пользовательского skill нужен аргумент confirm=true.");
15952
+ const name = normalizeUserSkillName(args.name || args.skill || args.title);
15953
+ if (!name) throw new Error("Имя skill обязательно.");
15954
+ const description = String(args.description || `Пользовательский skill: ${name}`).trim();
15955
+ const instructions = String(args.instructions || args.text || args.prompt || "").trim();
15956
+ if (!instructions) throw new Error("Инструкции skill обязательны.");
15957
+ const tools = parseCommaList(args.tools || args.allowedTools || args.allowed_tools || args.uses || "");
15958
+ const dir = path.join(USER_SKILLS_DIR, name);
15959
+ const file = path.join(dir, "SKILL.md");
15960
+ if (existsSync(file) && !args.overwrite) throw new Error(`Skill уже существует: ${name}. Используйте overwrite=true или --force.`);
15961
+ await mkdir(dir, { recursive: true });
15962
+ const body = buildUserSkillMarkdown({ name, description, instructions, tools });
15963
+ await writeFile(file, body, "utf8");
15964
+ let enabled = false;
15965
+ if (args.enable) {
15966
+ await userSkillSetEnabled(name, true);
15967
+ enabled = true;
15968
+ }
15969
+ return { name, description, file, enabled, tools, status: "created" };
15970
+ }
15971
+
15972
+ async function userSkillSetEnabled(name, enabled) {
15973
+ const skillName = normalizeUserSkillName(name);
15974
+ if (!skillName) throw new Error("Имя skill обязательно.");
15975
+ const config = await loadConfig();
15976
+ const skill = findSkill(skillName, config);
15977
+ if (!skill && enabled) throw new Error(`Skill не найден: ${skillName}`);
15978
+ const enabledSet = new Set(config.skills?.enabled || []);
15979
+ if (enabled) enabledSet.add(skillName);
15980
+ else enabledSet.delete(skillName);
15981
+ await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabledSet] } });
15982
+ return { name: skillName, enabled, status: enabled ? "enabled" : "disabled" };
15983
+ }
15984
+
15985
+ async function userSkillDelete(name, options = {}) {
15986
+ if (!options.confirm) throw new Error("Для удаления пользовательского skill нужен --yes или confirm=true.");
15987
+ const skillName = normalizeUserSkillName(name);
15988
+ if (!skillName) throw new Error("Имя skill обязательно.");
15989
+ const file = path.join(USER_SKILLS_DIR, skillName, "SKILL.md");
15990
+ const dir = path.dirname(file);
15991
+ if (!existsSync(file)) throw new Error(`Пользовательский skill не найден: ${skillName}`);
15992
+ await rm(dir, { recursive: true, force: true });
15993
+ await userSkillSetEnabled(skillName, false);
15994
+ return { name: skillName, status: "deleted" };
15995
+ }
15996
+
15997
+ function normalizeUserSkillName(value) {
15998
+ return String(value || "")
15999
+ .toLocaleLowerCase("ru-RU")
16000
+ .replace(/[^a-z0-9а-яё_-]+/giu, "-")
16001
+ .replace(/^-+|-+$/gu, "")
16002
+ .slice(0, 80);
16003
+ }
16004
+
16005
+ function parseCommaList(value) {
16006
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
16007
+ return String(value || "")
16008
+ .split(/[,\n;]/u)
16009
+ .map((item) => item.trim())
16010
+ .filter(Boolean);
16011
+ }
16012
+
16013
+ function extractUserSkillNameFromQuestion(question) {
16014
+ const text = String(question || "");
16015
+ const quoted = text.match(/[«"]([^»"]+)[»"]/u)?.[1];
16016
+ if (quoted) return normalizeUserSkillName(quoted);
16017
+ const explicit = text.match(/(?:skill|скилл|скил|навык)\s+([a-z0-9а-яё_-]{3,80})/iu)?.[1]
16018
+ || text.match(/(?:создай|добавь|сделай|включи|выключи|удали|удалить)\s+([a-z0-9а-яё_-]{3,80})\s+(?:skill|скилл|скил|навык)/iu)?.[1];
16019
+ return normalizeUserSkillName(explicit || "");
16020
+ }
16021
+
16022
+ function extractUserSkillDescription(question, name) {
16023
+ const text = String(question || "").replace(/\s+/g, " ").trim();
16024
+ const afterColon = text.match(/[:\-]\s*(.+)$/u)?.[1];
16025
+ return (afterColon || `Пользовательский skill: ${name}`).slice(0, 180);
16026
+ }
16027
+
16028
+ function inferUserSkillTools(question) {
16029
+ const text = String(question || "").toLocaleLowerCase("ru-RU");
16030
+ const tools = new Set();
16031
+ if (/(почт|письм|email|e-mail)/iu.test(text)) {
16032
+ tools.add("yandex_mail_list");
16033
+ tools.add("yandex_mail_search");
16034
+ tools.add("yandex_mail_read");
16035
+ }
16036
+ if (/(отправь|отправить|отправляй|ответь|ответить|перешли|переслать|удали|удалить)/iu.test(text) && /(почт|письм|email|e-mail)/iu.test(text)) {
16037
+ tools.add("yandex_mail_send");
16038
+ tools.add("yandex_mail_reply");
16039
+ tools.add("yandex_mail_forward");
16040
+ tools.add("yandex_mail_delete");
16041
+ }
16042
+ if (/(диск|облак|яндекс.?диск)/iu.test(text)) {
16043
+ tools.add("yandex_disk_info");
16044
+ tools.add("yandex_disk_ls");
16045
+ tools.add("yandex_disk_find");
16046
+ tools.add("yandex_disk_stat");
16047
+ tools.add("yandex_disk_exists");
16048
+ tools.add("yandex_disk_read_text");
16049
+ tools.add("yandex_disk_save_text");
16050
+ tools.add("yandex_disk_upload");
16051
+ tools.add("yandex_disk_download");
16052
+ tools.add("yandex_disk_share");
16053
+ tools.add("yandex_disk_unshare");
16054
+ tools.add("yandex_disk_move");
16055
+ tools.add("yandex_disk_copy");
16056
+ tools.add("yandex_disk_rename");
16057
+ tools.add("yandex_disk_delete");
16058
+ tools.add("yandex_disk_trash_list");
16059
+ tools.add("yandex_disk_restore");
16060
+ }
16061
+ if (/(календар|событи|встреч|телемост)/iu.test(text)) {
16062
+ tools.add("yandex_calendar_list");
16063
+ tools.add("yandex_calendar_create_event");
16064
+ tools.add("yandex_telemost_create_event");
16065
+ }
16066
+ if (/(контакт|адресн)/iu.test(text)) {
16067
+ tools.add("yandex_contacts_search");
16068
+ tools.add("yandex_contacts_get");
16069
+ tools.add("yandex_contacts_create");
16070
+ tools.add("yandex_contacts_update");
16071
+ tools.add("yandex_contacts_delete");
16072
+ tools.add("yandex_contacts_add_email");
16073
+ tools.add("yandex_contacts_add_phone");
16074
+ tools.add("yandex_contacts_add_address");
16075
+ tools.add("yandex_contacts_add_note");
16076
+ tools.add("yandex_contacts_add_birthday");
16077
+ tools.add("yandex_contacts_add_org");
16078
+ tools.add("yandex_contacts_export_csv");
16079
+ tools.add("yandex_contacts_find_incomplete");
16080
+ tools.add("yandex_contacts_find_duplicates");
16081
+ tools.add("yandex_contacts_backup_to_disk");
16082
+ tools.add("yandex_contact_send_mail");
16083
+ tools.add("yandex_contact_send_disk_link_qr");
16084
+ tools.add("yandex_contact_create_disk_folder");
16085
+ tools.add("yandex_contact_create_calendar_event");
16086
+ tools.add("yandex_contact_create_telemost_event");
16087
+ }
16088
+ if (/(файл|папк|документ|архив)/iu.test(text)) {
16089
+ tools.add("files_tree");
16090
+ tools.add("files_read");
16091
+ tools.add("files_search");
16092
+ }
16093
+ if (/(запиши|сохрани|создай файл|измени|исправ)/iu.test(text) && /(файл|папк|документ|архив)/iu.test(text)) {
16094
+ tools.add("files_write");
16095
+ tools.add("files_patch");
16096
+ }
16097
+ if (/(школ|сад|детсад|инн|адрес|телефон|открыт)/iu.test(text)) {
16098
+ tools.add("search_data");
16099
+ tools.add("get_card");
16100
+ tools.add("export_report");
16101
+ }
16102
+ if (/(сайт|страниц|браузер|url|ссылка)/iu.test(text)) tools.add("browser_open");
16103
+ return [...tools];
16104
+ }
16105
+
16106
+ function buildUserSkillMarkdown({ name, description, instructions, tools = [] }) {
16107
+ const toolLines = tools.length
16108
+ ? ["", "Разрешенные/ожидаемые tools для этого skill:", "", ...tools.map((tool) => `- \`${tool}\``)]
16109
+ : [];
16110
+ return [
16111
+ "---",
16112
+ `name: ${name}`,
16113
+ `description: ${description.replace(/\r?\n/g, " ")}`,
16114
+ "source: user",
16115
+ "---",
16116
+ "",
16117
+ instructions.trim(),
16118
+ "",
16119
+ "Правила безопасности:",
16120
+ "",
16121
+ "- Используй только встроенные tools `iola-cli` и подключенные MCP/Yandex/local-files механизмы.",
16122
+ "- Не выводи секреты, OAuth-токены, API-ключи и пароли.",
16123
+ "- Для записи, удаления, отправки писем, публикации ссылок и изменения внешних сервисов требуется явная просьба пользователя.",
16124
+ "- Если действие неоднозначно, сначала уточни у пользователя цель и место выполнения.",
16125
+ ...toolLines,
16126
+ "",
16127
+ ].join("\n");
16128
+ }
16129
+
13610
16130
  function readSkillMeta(file) {
13611
16131
  try {
13612
16132
  const text = readFileSyncUtf8(file);
@@ -13652,7 +16172,7 @@ function selectSkillsForPrompt(config, question = "", options = {}) {
13652
16172
  if (enabled.has("geo") && isGeoQuestion(normalized)) selected.add("geo");
13653
16173
  const cloudQuestion = isCloudQuestion(normalized);
13654
16174
  if (enabled.has("personal-docs") && cloudQuestion) selected.add("personal-docs");
13655
- if (enabled.has("yandex-services") && /(яндекс|диск|почт|письм|календар|контакт|телемост|облако)/iu.test(normalized)) selected.add("yandex-services");
16175
+ if (enabled.has("yandex-services") && /(яндекс|диск|почт|письм|календар|контакт|телемост|документ|docs|360|облако)/iu.test(normalized)) selected.add("yandex-services");
13656
16176
  if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
13657
16177
  if (enabled.has("local-files") && !cloudQuestion && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
13658
16178
  if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
@@ -15376,6 +17896,10 @@ function sanitizeConfig(config) {
15376
17896
  if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("local-files") && !next.skills.enabled.includes("personal-docs")) {
15377
17897
  next.skills.enabled = [...next.skills.enabled, "personal-docs"];
15378
17898
  }
17899
+ next.toolsets = next.toolsets || {};
17900
+ next.toolsets.enabled = [...new Set([...(next.toolsets.enabled || []), "user-skills"])];
17901
+ next.skills = next.skills || {};
17902
+ next.skills.enabled = [...new Set([...(next.skills.enabled || []), "user-skills"])];
15379
17903
  if (Array.isArray(next.yandex?.enabledServices)) {
15380
17904
  next.yandex.enabledServices = next.yandex.enabledServices.filter((service) => Boolean(YANDEX_CONNECTOR_SERVICES[service]));
15381
17905
  }