@iola_adm/iola-cli 0.2.33 → 0.2.34

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",
@@ -185,11 +198,35 @@ const YANDEX_TOOLS = [
185
198
  "yandex_contacts_status",
186
199
  "yandex_contacts_list",
187
200
  "yandex_contacts_search",
201
+ "yandex_contacts_get",
188
202
  "yandex_contacts_create",
203
+ "yandex_contacts_update",
204
+ "yandex_contacts_delete",
189
205
  "yandex_contacts_add_email",
206
+ "yandex_contacts_add_phone",
207
+ "yandex_contacts_add_address",
208
+ "yandex_contacts_add_note",
209
+ "yandex_contacts_add_birthday",
210
+ "yandex_contacts_add_org",
211
+ "yandex_contacts_remove_email",
212
+ "yandex_contacts_remove_phone",
213
+ "yandex_contacts_export_vcard",
214
+ "yandex_contacts_export_csv",
215
+ "yandex_contacts_import_vcard",
216
+ "yandex_contacts_import_csv",
217
+ "yandex_contacts_find_incomplete",
218
+ "yandex_contacts_find_duplicates",
219
+ "yandex_contacts_backup_to_disk",
220
+ "yandex_contacts_birthdays_to_calendar",
221
+ "yandex_contact_send_mail",
222
+ "yandex_contact_send_disk_link_qr",
223
+ "yandex_contact_create_disk_folder",
224
+ "yandex_contact_create_calendar_event",
225
+ "yandex_contact_create_telemost_event",
226
+ "yandex_contact_from_public_entity",
190
227
  "yandex_telemost_create_event",
191
228
  ];
192
- const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS, ...YANDEX_TOOLS];
229
+ const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS, ...YANDEX_TOOLS, ...USER_SKILL_TOOLS];
193
230
  const ALL_TOOL_ALIASES = [...ALL_LOCAL_TOOLS, ...LEGACY_LOCAL_TOOLS];
194
231
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
195
232
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
@@ -247,6 +284,13 @@ const TOOLSETS = {
247
284
  localTools: Object.fromEntries(ALL_LOCAL_TOOLS.map((tool) => [tool, true])),
248
285
  },
249
286
  },
287
+ "user-skills": {
288
+ description: "Создание и управление пользовательскими skills на базе встроенных tools.",
289
+ permissions: {
290
+ writeFiles: true,
291
+ localTools: Object.fromEntries(USER_SKILL_TOOLS.map((tool) => [tool, true])),
292
+ },
293
+ },
250
294
  };
251
295
  const FEATURES = {
252
296
  "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
@@ -390,7 +434,7 @@ const DEFAULT_AI_CONFIG = {
390
434
  codex: true,
391
435
  },
392
436
  toolsets: {
393
- enabled: ["data-read", "reports", "sync", "ai", "yandex"],
437
+ enabled: ["data-read", "reports", "sync", "ai", "yandex", "user-skills"],
394
438
  },
395
439
  files: {
396
440
  mode: "locked",
@@ -404,7 +448,7 @@ const DEFAULT_AI_CONFIG = {
404
448
  suggestions: true,
405
449
  },
406
450
  skills: {
407
- enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent", "yandex-services"],
451
+ enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent", "yandex-services", "user-skills"],
408
452
  },
409
453
  cloud: {
410
454
  activeProvider: "",
@@ -768,7 +812,7 @@ Usage:
768
812
  iola settings list|get|validate|doctor|init
769
813
  iola wiki [open|links]
770
814
  iola context list|show|init
771
- iola skills list|show|paths|enable|disable|bundles|bundle|doctor
815
+ iola skills list|show|paths|create|enable|disable|delete|bundles|bundle|doctor
772
816
  iola tools list|toolsets|enable|disable|profile
773
817
  iola files status|mode|approvals|tree|read|search|write|patch
774
818
  iola cloud setup|status|ls|find|upload|download|share|save|backup
@@ -2949,20 +2993,7 @@ async function handleSkills(args) {
2949
2993
  const config = await loadConfig();
2950
2994
 
2951
2995
  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
- ]);
2996
+ printSkillsList(listSkills(config), config);
2966
2997
  return;
2967
2998
  }
2968
2999
 
@@ -3016,6 +3047,30 @@ async function handleSkills(args) {
3016
3047
  return;
3017
3048
  }
3018
3049
 
3050
+ if (action === "create" || action === "new") {
3051
+ const options = parseOptions(args.slice(2));
3052
+ const result = await userSkillCreate({
3053
+ name,
3054
+ description: options.description || "",
3055
+ instructions: options.instructions || options.text || options.prompt || options._.join(" "),
3056
+ tools: parseCommaList(options["allowed-tools"] || options.tool || options.uses || ""),
3057
+ enable: Boolean(options.enable),
3058
+ overwrite: Boolean(options.force),
3059
+ confirm: true,
3060
+ });
3061
+ console.log(`Skill создан: ${result.name}`);
3062
+ console.log(`Файл: ${result.file}`);
3063
+ if (result.enabled) console.log("Skill включен.");
3064
+ return;
3065
+ }
3066
+
3067
+ if (action === "delete" || action === "remove" || action === "rm") {
3068
+ const options = parseOptions(args.slice(2));
3069
+ const result = await userSkillDelete(name, { confirm: Boolean(options.yes || options.force) });
3070
+ console.log(`Skill удален: ${result.name}`);
3071
+ return;
3072
+ }
3073
+
3019
3074
  if (action === "enable" || action === "disable") {
3020
3075
  if (!name) throw new Error("Имя skill обязательно.");
3021
3076
  const enabled = new Set(config.skills?.enabled || []);
@@ -3026,7 +3081,7 @@ async function handleSkills(args) {
3026
3081
  return;
3027
3082
  }
3028
3083
 
3029
- throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME, bundles, bundle enable NAME, doctor.");
3084
+ 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
3085
  }
3031
3086
 
3032
3087
  async function handleTools(args) {
@@ -3322,6 +3377,11 @@ async function handleYandex(args) {
3322
3377
  return;
3323
3378
  }
3324
3379
 
3380
+ if (action === "contacts-maintenance" || action === "contacts-watch" || action === "contacts-doctor") {
3381
+ await handleYandexContactsMaintenance([target, ...rest].filter(Boolean));
3382
+ return;
3383
+ }
3384
+
3325
3385
  if (action === "enable" || action === "disable") {
3326
3386
  const services = [target, ...rest].filter((item) => item && !String(item).startsWith("--"));
3327
3387
  if (services.length === 0) throw new Error("Укажите сервисы. Пример: iola yandex enable disk mail calendar");
@@ -3358,6 +3418,7 @@ async function handleYandex(args) {
3358
3418
  iola yandex status|doctor
3359
3419
  iola yandex services
3360
3420
  iola yandex mail-watch on|off|status|tick [--minutes 5]
3421
+ iola yandex contacts-maintenance on|off|status|tick [--days 7] [--backup]
3361
3422
  iola yandex enable disk mail calendar
3362
3423
  iola yandex disable mail
3363
3424
  iola yandex oauth-url [disk mail calendar] [--client-id ID] [--open]
@@ -3428,6 +3489,55 @@ async function handleYandexMailWatch(args = []) {
3428
3489
  throw new Error("Команды: iola yandex mail-watch on --minutes 5 | off | status | tick");
3429
3490
  }
3430
3491
 
3492
+ async function handleYandexContactsMaintenance(args = []) {
3493
+ const [action = "status", ...rest] = args;
3494
+ const options = parseOptions(rest);
3495
+ if (action === "on" || action === "enable" || action === "start" || action === "вкл") {
3496
+ const days = Math.max(1, Number(options.days || options.interval || rest.find((item) => /^\d+$/u.test(String(item))) || 7));
3497
+ const result = await yandexContactsMaintenanceEnable(days, { backup: Boolean(options.backup) });
3498
+ console.log(`Проверка контактов включена: каждые ${days} дней.`);
3499
+ console.log(`Backup на Диск: ${result.backup ? "yes" : "no"}.`);
3500
+ console.log("Для работы по расписанию должен запускаться cron tick: вручную, через daemon или Windows Task Scheduler.");
3501
+ return;
3502
+ }
3503
+ if (action === "off" || action === "disable" || action === "stop" || action === "выкл") {
3504
+ await yandexContactsMaintenanceDisable();
3505
+ console.log("Проверка контактов выключена.");
3506
+ return;
3507
+ }
3508
+ if (action === "tick" || action === "run" || action === "check") {
3509
+ const result = await yandexContactsMaintenanceTick({ force: true, backup: options.backup });
3510
+ if (!result.enabled) {
3511
+ console.log("Проверка контактов выключена.");
3512
+ } else {
3513
+ printKeyValue({
3514
+ status: "ok",
3515
+ contacts: result.total,
3516
+ incomplete: result.incomplete,
3517
+ duplicateGroups: result.duplicateGroups,
3518
+ backup: result.backupRemote || "-",
3519
+ });
3520
+ }
3521
+ return;
3522
+ }
3523
+ if (action === "status" || action === "doctor") {
3524
+ const config = await loadConfig();
3525
+ const maintenance = config.yandex?.contactsMaintenance || {};
3526
+ printKeyValue({
3527
+ enabled: maintenance.enabled ? "yes" : "no",
3528
+ days: maintenance.days || "-",
3529
+ backup: maintenance.backup ? "yes" : "no",
3530
+ lastRunAt: maintenance.lastRunAt || "-",
3531
+ lastTotal: maintenance.lastTotal || "-",
3532
+ lastIncomplete: maintenance.lastIncomplete || "-",
3533
+ lastDuplicateGroups: maintenance.lastDuplicateGroups || "-",
3534
+ cron: listCronJobs().some((job) => job.command === "yandex contacts-maintenance tick") ? "yes" : "no",
3535
+ });
3536
+ return;
3537
+ }
3538
+ throw new Error("Команды: iola yandex contacts-maintenance on --days 7 [--backup] | off | status | tick");
3539
+ }
3540
+
3431
3541
  async function setupYandexConnector(args = []) {
3432
3542
  const options = parseOptions(args);
3433
3543
  const config = await loadConfig();
@@ -3999,15 +4109,27 @@ async function executeYandexTool(tool, args = {}) {
3999
4109
  if (tool === "yandex_disk_ls") return yandexDiskList(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR, { allowMissingRoot: true });
4000
4110
  if (tool === "yandex_disk_mkdir") return cloudCreateFolder("yandex-disk", args.path || args.remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/Новая папка`);
4001
4111
  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 });
4112
+ if (tool === "yandex_disk_stat") return yandexDiskStat(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR);
4113
+ if (tool === "yandex_disk_exists") return yandexDiskExists(args.path || args.remotePath || CLOUD_DEFAULT_REMOTE_DIR);
4114
+ if (tool === "yandex_disk_read_text") return yandexDiskReadText(args.path || args.remotePath, { maxBytes: args.maxBytes || args["max-bytes"] });
4002
4115
  if (tool === "yandex_disk_save_text") return yandexDiskSaveText(args.text || args.content || "", args.path || args.remotePath);
4003
4116
  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
4117
  if (tool === "yandex_disk_download") return yandexDiskDownload(args.remotePath || args.path, args.outputPath || args.output || path.basename(args.remotePath || args.path || "download"));
4118
+ if (tool === "yandex_disk_move") return yandexDiskMove(args.from || args.source || args.path, args.to || args.target || args.remotePath, args);
4119
+ if (tool === "yandex_disk_copy") return yandexDiskCopy(args.from || args.source || args.path, args.to || args.target || args.remotePath, args);
4120
+ if (tool === "yandex_disk_rename") return yandexDiskRename(args.path || args.remotePath, args.name || args.newName || args.to, args);
4005
4121
  if (tool === "yandex_disk_share") {
4006
4122
  if (!args.confirm) throw new Error("Для публикации ссылки нужен аргумент confirm=true.");
4007
4123
  return yandexDiskShare(args.remotePath || args.path);
4008
4124
  }
4125
+ if (tool === "yandex_disk_share_qr") return yandexDiskShareWithQr(args.remotePath || args.path, args);
4126
+ if (tool === "yandex_disk_share_email") return yandexDiskShareEmail(args);
4127
+ if (tool === "yandex_disk_package_share_email") return yandexDiskPackageShareEmail(args);
4009
4128
  if (tool === "yandex_disk_unshare") return yandexDiskUnshare(args.remotePath || args.path);
4010
4129
  if (tool === "yandex_disk_delete") return yandexDiskDelete(args.remotePath || args.path, args);
4130
+ if (tool === "yandex_disk_trash_list") return yandexDiskTrashList(args.path || args.remotePath || "", args);
4131
+ if (tool === "yandex_disk_restore") return yandexDiskRestore(args.path || args.remotePath, args);
4132
+ if (tool === "yandex_disk_empty_trash") return yandexDiskEmptyTrash(args);
4011
4133
  if (tool === "yandex_mail_status") return yandexMailStatus();
4012
4134
  if (tool === "yandex_mail_folders") return yandexMailFolders();
4013
4135
  if (tool === "yandex_mail_list") return yandexMailList({ mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), limit: args.limit || 10, unread: Boolean(args.unread) });
@@ -4030,8 +4152,32 @@ async function executeYandexTool(tool, args = {}) {
4030
4152
  if (tool === "yandex_contacts_status") return yandexContactsStatus();
4031
4153
  if (tool === "yandex_contacts_list") return yandexContactsList(args);
4032
4154
  if (tool === "yandex_contacts_search") return yandexContactsSearch(args.query || "", args);
4155
+ if (tool === "yandex_contacts_get") return yandexContactsGet(args.query || args.name || args.email || args.phone || "", args);
4033
4156
  if (tool === "yandex_contacts_create") return yandexContactsCreate(args);
4157
+ if (tool === "yandex_contacts_update") return yandexContactsUpdate(args.query || args.name || args.email || "", args);
4158
+ if (tool === "yandex_contacts_delete") return yandexContactsDelete(args.query || args.name || args.email || "", args);
4034
4159
  if (tool === "yandex_contacts_add_email") return yandexContactsAddEmail(args.query || args.name || "", args.email, args);
4160
+ if (tool === "yandex_contacts_add_phone") return yandexContactsUpdate(args.query || args.name || "", { ...args, phone: args.phone, mode: "add-phone" });
4161
+ if (tool === "yandex_contacts_add_address") return yandexContactsUpdate(args.query || args.name || "", { ...args, address: args.address, mode: "add-address" });
4162
+ if (tool === "yandex_contacts_add_note") return yandexContactsUpdate(args.query || args.name || "", { ...args, note: args.note || args.text, mode: "add-note" });
4163
+ if (tool === "yandex_contacts_add_birthday") return yandexContactsUpdate(args.query || args.name || "", { ...args, birthday: args.birthday || args.date, mode: "add-birthday" });
4164
+ 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" });
4165
+ if (tool === "yandex_contacts_remove_email") return yandexContactsUpdate(args.query || args.name || "", { ...args, removeEmail: args.email || true, mode: "remove-email" });
4166
+ if (tool === "yandex_contacts_remove_phone") return yandexContactsUpdate(args.query || args.name || "", { ...args, removePhone: args.phone || true, mode: "remove-phone" });
4167
+ if (tool === "yandex_contacts_export_vcard") return yandexContactsExport("vcard", args);
4168
+ if (tool === "yandex_contacts_export_csv") return yandexContactsExport("csv", args);
4169
+ if (tool === "yandex_contacts_import_vcard") return yandexContactsImport("vcard", args);
4170
+ if (tool === "yandex_contacts_import_csv") return yandexContactsImport("csv", args);
4171
+ if (tool === "yandex_contacts_find_incomplete") return yandexContactsFindIncomplete(args);
4172
+ if (tool === "yandex_contacts_find_duplicates") return yandexContactsFindDuplicates(args);
4173
+ if (tool === "yandex_contacts_backup_to_disk") return yandexContactsBackupToDisk(args);
4174
+ if (tool === "yandex_contacts_birthdays_to_calendar") return yandexContactsBirthdaysToCalendar(args);
4175
+ if (tool === "yandex_contact_send_mail") return yandexContactSendMail(args);
4176
+ if (tool === "yandex_contact_send_disk_link_qr") return yandexContactSendDiskLinkQr(args);
4177
+ if (tool === "yandex_contact_create_disk_folder") return yandexContactCreateDiskFolder(args);
4178
+ if (tool === "yandex_contact_create_calendar_event") return yandexContactCreateCalendarEvent(args);
4179
+ if (tool === "yandex_contact_create_telemost_event") return yandexContactCreateTelemostEvent(args);
4180
+ if (tool === "yandex_contact_from_public_entity") return yandexContactFromPublicEntity(args);
4035
4181
  if (tool === "yandex_telemost_create_event") return yandexTelemostCreateEvent(args);
4036
4182
  throw new Error(`Yandex tool неизвестен: ${tool}`);
4037
4183
  }
@@ -4203,6 +4349,78 @@ async function yandexMailWatchDisable() {
4203
4349
  return { enabled: false };
4204
4350
  }
4205
4351
 
4352
+ async function yandexContactsMaintenanceEnable(days = 7, options = {}) {
4353
+ const config = await loadConfig();
4354
+ const safeDays = Math.max(1, Number(days || 7));
4355
+ await saveConfig({
4356
+ yandex: {
4357
+ ...(config.yandex || {}),
4358
+ contactsMaintenance: {
4359
+ ...(config.yandex?.contactsMaintenance || {}),
4360
+ enabled: true,
4361
+ days: safeDays,
4362
+ backup: Boolean(options.backup),
4363
+ updatedAt: new Date().toISOString(),
4364
+ },
4365
+ },
4366
+ });
4367
+ await upsertCronJob(`каждые ${safeDays} дней`, "yandex contacts-maintenance tick", { replaceCommand: true });
4368
+ return { enabled: true, days: safeDays, backup: Boolean(options.backup) };
4369
+ }
4370
+
4371
+ async function yandexContactsMaintenanceDisable() {
4372
+ const config = await loadConfig();
4373
+ await saveConfig({
4374
+ yandex: {
4375
+ ...(config.yandex || {}),
4376
+ contactsMaintenance: {
4377
+ ...(config.yandex?.contactsMaintenance || {}),
4378
+ enabled: false,
4379
+ updatedAt: new Date().toISOString(),
4380
+ },
4381
+ },
4382
+ });
4383
+ deleteCronJobsByCommand("yandex contacts-maintenance tick");
4384
+ return { enabled: false };
4385
+ }
4386
+
4387
+ async function yandexContactsMaintenanceTick(options = {}) {
4388
+ const config = await loadConfig();
4389
+ const maintenance = config.yandex?.contactsMaintenance || {};
4390
+ if (!maintenance.enabled && !options.force) return { enabled: false };
4391
+ const contacts = await yandexContactsList({ limit: Number(options.limit || 1000) });
4392
+ const incomplete = await yandexContactsFindIncomplete({ limit: Number(options.limit || 1000) });
4393
+ const duplicates = await yandexContactsFindDuplicates({ limit: Number(options.limit || 1000) });
4394
+ let backupRemote = "";
4395
+ if (options.backup || maintenance.backup) {
4396
+ const backup = await yandexContactsBackupToDisk({ format: "csv", confirm: true, limit: Number(options.limit || 1000) });
4397
+ backupRemote = backup.remote || "";
4398
+ }
4399
+ await saveConfig({
4400
+ yandex: {
4401
+ ...(config.yandex || {}),
4402
+ contactsMaintenance: {
4403
+ ...maintenance,
4404
+ enabled: maintenance.enabled !== false,
4405
+ lastRunAt: new Date().toISOString(),
4406
+ lastTotal: contacts.length,
4407
+ lastIncomplete: incomplete.length,
4408
+ lastDuplicateGroups: duplicates.length,
4409
+ lastBackupRemote: backupRemote || maintenance.lastBackupRemote || "",
4410
+ },
4411
+ },
4412
+ });
4413
+ return {
4414
+ enabled: true,
4415
+ total: contacts.length,
4416
+ incomplete: incomplete.length,
4417
+ duplicateGroups: duplicates.length,
4418
+ backupRemote,
4419
+ incompletePreview: incomplete.slice(0, 10),
4420
+ duplicatePreview: duplicates.slice(0, 10),
4421
+ };
4422
+ }
4423
+
4206
4424
  async function yandexMailRead(uid, options = {}) {
4207
4425
  if (!uid) throw new Error("UID письма обязателен.");
4208
4426
  const { token, email } = await yandexMailCredentials();
@@ -4776,7 +4994,7 @@ async function yandexCalendarCreateEvent(args = {}) {
4776
4994
  const end = toIcsDate(args.end || new Date(Date.now() + 7200000).toISOString());
4777
4995
  const summary = args.title || args.summary || "Событие IOLA";
4778
4996
  const description = args.description || "";
4779
- const ics = buildIcsEvent({ uid, start, end, summary, description, location: args.location || "" });
4997
+ const ics = buildIcsEvent({ uid, start, end, summary, description, location: args.location || "", attendees: args.attendees || args.to || [] });
4780
4998
  const url = `${baseUrl}${encodeURIComponent(uid)}.ics`;
4781
4999
  await yandexDavRequest(url, token, { method: "PUT", ics: true, body: ics, timeout: 45000 });
4782
5000
  return { status: "created", uid, title: summary, start: args.start || args.date || "", url };
@@ -4848,9 +5066,9 @@ async function yandexContactsList(args = {}) {
4848
5066
 
4849
5067
  async function yandexContactsSearch(query, args = {}) {
4850
5068
  const normalized = normalizeGeoText(query);
4851
- const rows = await yandexContactsList({ limit: Math.max(100, Number(args.limit || 20) * 4) });
5069
+ const rows = await yandexContactsList({ limit: Math.max(1000, Number(args.limit || 20) * 10) });
4852
5070
  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));
5071
+ return rows.filter((row) => contactMatchesQuery(row, normalized)).slice(0, Number(args.limit || 20));
4854
5072
  }
4855
5073
 
4856
5074
  async function resolveYandexMailRecipientFromContacts(query) {
@@ -4871,58 +5089,397 @@ async function yandexContactsAddEmail(query, email, args = {}) {
4871
5089
  if (!args.confirm) throw new Error("Для изменения контакта нужен аргумент confirm=true.");
4872
5090
  if (!query) throw new Error("Укажите имя или часть имени контакта.");
4873
5091
  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) });
5092
+ const result = await yandexContactsUpdate(query, { ...args, email, mode: "add-email" });
5093
+ if (result.status === "updated") return { status: "updated", name: result.name, email };
5094
+ return result;
5095
+ }
5096
+
5097
+ async function yandexContactsGet(query, args = {}) {
5098
+ const resolved = await resolveYandexContact(query, args);
5099
+ if (resolved.status !== "ok") return resolved;
5100
+ const { card: _card, ...contact } = resolved.contact;
5101
+ return { status: "ok", ...contact };
5102
+ }
5103
+
5104
+ async function yandexContactsUpdate(query, args = {}) {
5105
+ if (!args.confirm) throw new Error("Для изменения контакта нужен аргумент confirm=true.");
5106
+ if (!query) throw new Error("Укажите имя, email или телефон контакта.");
5107
+ const resolved = await resolveYandexContact(query, args);
5108
+ if (resolved.status !== "ok") return resolved;
5109
+ const contact = resolved.contact;
5110
+ let card = contact.card;
5111
+ if (args.name || args.fullName) {
5112
+ card = upsertVcardProperty(card, "FN", args.name || args.fullName, { replace: true });
5113
+ card = upsertVcardProperty(card, "N", `${escapeVcardValue(args.name || args.fullName)};;;;`, { replace: true, raw: true });
5114
+ }
5115
+ if (args.email) card = upsertVcardProperty(card, "EMAIL;TYPE=INTERNET", String(args.email).trim(), { replace: args.overwriteEmail || args.overwrite || args.mode === "set-email" });
5116
+ if (args.phone) card = upsertVcardProperty(card, "TEL;TYPE=CELL", String(args.phone).trim(), { replace: args.overwritePhone || args.overwrite || args.mode === "set-phone" });
5117
+ if (args.address) card = upsertVcardProperty(card, "ADR;TYPE=HOME", `;;;;${escapeVcardValue(args.address)};;`, { replace: args.overwriteAddress || args.overwrite || args.mode === "set-address", raw: true });
5118
+ if (args.note) card = upsertVcardProperty(card, "NOTE", args.note, { replace: args.overwriteNote || args.overwrite || args.mode === "set-note" });
5119
+ if (args.birthday) card = upsertVcardProperty(card, "BDAY", normalizeVcardBirthday(args.birthday), { replace: true });
5120
+ if (args.org) card = upsertVcardProperty(card, "ORG", args.org, { replace: args.overwriteOrg || args.overwrite || args.mode === "set-org" });
5121
+ if (args.title) card = upsertVcardProperty(card, "TITLE", args.title, { replace: true });
5122
+ if (args.categories || args.group) card = upsertVcardProperty(card, "CATEGORIES", Array.isArray(args.categories) ? args.categories.join(",") : (args.categories || args.group), { replace: Boolean(args.overwriteCategories) });
5123
+ if (args.removeEmail) card = removeVcardProperty(card, "EMAIL", args.removeEmail === true ? "" : args.removeEmail);
5124
+ if (args.removePhone) card = removeVcardProperty(card, "TEL", args.removePhone === true ? "" : args.removePhone);
5125
+ if (card === contact.card) return { status: "unchanged", name: contact.name, email: contact.email, phone: contact.phone };
5126
+ await saveYandexContactCard(contact.href, card);
5127
+ const parsed = parseVCards(card)[0] || {};
5128
+ return { status: "updated", ...parsed, href: contact.href };
5129
+ }
5130
+
5131
+ async function yandexContactsDelete(query, args = {}) {
5132
+ if (!args.confirm) throw new Error("Для удаления контакта нужен аргумент confirm=true.");
5133
+ const resolved = await resolveYandexContact(query, args);
5134
+ if (resolved.status !== "ok") return resolved;
4881
5135
  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 };
5136
+ await yandexDavRequest(new URL(resolved.contact.href, "https://carddav.yandex.ru/").toString(), token, { method: "DELETE", timeout: 45000 });
5137
+ return { status: "deleted", name: resolved.contact.name, email: resolved.contact.email, phone: resolved.contact.phone };
4889
5138
  }
4890
5139
 
4891
5140
  async function yandexContactsCreate(args = {}) {
4892
5141
  if (!args.confirm) throw new Error("Для создания контакта нужен аргумент confirm=true.");
4893
5142
  const name = String(args.name || args.email || "").trim();
4894
5143
  const email = String(args.email || "").trim();
5144
+ const phone = String(args.phone || "").trim();
4895
5145
  if (!name) throw new Error("Имя контакта обязательно.");
4896
- if (!email || !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email)) throw new Error("Корректный email обязателен.");
5146
+ if (email && !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email)) throw new Error("Укажите корректный email.");
5147
+ if (!email && !phone) throw new Error("Для контакта нужен хотя бы email или телефон.");
4897
5148
  const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
4898
5149
  const baseUrl = await yandexContactsBaseUrl(token);
4899
5150
  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");
5151
+ const card = buildVcard({
5152
+ uid,
5153
+ name,
5154
+ email,
5155
+ phone,
5156
+ address: args.address,
5157
+ note: args.note,
5158
+ birthday: args.birthday,
5159
+ org: args.org || args.organization,
5160
+ title: args.title || args.position,
5161
+ categories: args.categories || args.group,
5162
+ });
4909
5163
  await yandexDavRequest(new URL(`${encodeURIComponent(uid)}.vcf`, baseUrl).toString(), token, {
4910
5164
  method: "PUT",
4911
5165
  headers: { "content-type": "text/vcard; charset=utf-8" },
4912
5166
  body: card,
4913
5167
  timeout: 45000,
4914
5168
  });
4915
- return { status: "created", name, email, uid };
5169
+ return { status: "created", name, email, phone, uid };
5170
+ }
5171
+
5172
+ async function yandexContactsExport(format, args = {}) {
5173
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000), full: true });
5174
+ const safeFormat = format === "csv" ? "csv" : "vcard";
5175
+ const output = args.output || path.join(CONFIG_DIR, `yandex-contacts-${timestampForFile()}.${safeFormat === "csv" ? "csv" : "vcf"}`);
5176
+ const text = safeFormat === "csv"
5177
+ ? contactsToCsv(rows)
5178
+ : rows.map((row) => row.card).filter(Boolean).join("\r\n");
5179
+ await mkdir(path.dirname(path.resolve(output)), { recursive: true });
5180
+ await writeFile(output, text, "utf8");
5181
+ saveArtifact("yandex-contacts-export", output, output, { rows: rows.length, format: safeFormat });
5182
+ return { status: "exported", output: path.resolve(output), rows: rows.length, format: safeFormat };
5183
+ }
5184
+
5185
+ async function yandexContactsFindIncomplete(args = {}) {
5186
+ const rows = await yandexContactsList({ limit: Number(args.limit || 500) });
5187
+ return rows.filter((row) => {
5188
+ if (args.field === "email") return !row.email;
5189
+ if (args.field === "phone") return !row.phone;
5190
+ if (args.field === "address") return !row.address;
5191
+ return !row.email || !row.phone || !row.name;
5192
+ }).slice(0, Number(args.limit || 50));
5193
+ }
5194
+
5195
+ async function yandexContactsFindDuplicates(args = {}) {
5196
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000) });
5197
+ const groups = new Map();
5198
+ for (const row of rows) {
5199
+ const keys = [
5200
+ row.email ? `email:${row.email.toLocaleLowerCase("en-US")}` : "",
5201
+ row.phone ? `phone:${normalizePhone(row.phone)}` : "",
5202
+ row.name ? `name:${normalizeContactLookupText(row.name)}` : "",
5203
+ ].filter((key) => key && !key.endsWith(":"));
5204
+ for (const key of keys) {
5205
+ if (!groups.has(key)) groups.set(key, []);
5206
+ groups.get(key).push(row);
5207
+ }
5208
+ }
5209
+ const seen = new Set();
5210
+ const duplicates = [];
5211
+ for (const [key, group] of groups.entries()) {
5212
+ if (group.length < 2) continue;
5213
+ const signature = group.map((row) => row.href || `${row.name}:${row.email}:${row.phone}`).sort().join("|");
5214
+ if (seen.has(signature)) continue;
5215
+ seen.add(signature);
5216
+ duplicates.push({ status: "duplicate-group", key, count: group.length, contacts: group.slice(0, 10) });
5217
+ }
5218
+ return duplicates.slice(0, Number(args.limit || 20));
5219
+ }
5220
+
5221
+ async function yandexContactsBackupToDisk(args = {}) {
5222
+ if (!args.confirm) throw new Error("Для резервной копии контактов на Диск нужен аргумент confirm=true.");
5223
+ const format = args.format === "csv" ? "csv" : "vcard";
5224
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000), full: true });
5225
+ const remotePath = args.path || `${CLOUD_DEFAULT_REMOTE_DIR}/contacts/yandex-contacts-${timestampForFile()}.${format === "csv" ? "csv" : "vcf"}`;
5226
+ const text = format === "csv" ? contactsToCsv(rows) : rows.map((row) => row.card).filter(Boolean).join("\r\n");
5227
+ const saved = await yandexDiskSaveText(text, remotePath);
5228
+ return { provider: "yandex-disk", status: "contacts-backup", remote: saved.remote, rows: rows.length, format };
5229
+ }
5230
+
5231
+ async function yandexContactsImport(format, args = {}) {
5232
+ if (!args.confirm) throw new Error("Для импорта контактов нужен аргумент confirm=true.");
5233
+ const inputPath = args.path || args.file || args.input;
5234
+ if (!inputPath) throw new Error("Укажите файл для импорта.");
5235
+ const text = await readFile(path.resolve(inputPath), "utf8");
5236
+ const rows = format === "csv" ? parseContactsCsv(text) : parseVCards(text);
5237
+ const existing = await yandexContactsList({ limit: 1000 });
5238
+ const existingEmails = new Set(existing.flatMap((row) => row.emails || []).map((email) => email.toLocaleLowerCase("en-US")));
5239
+ const existingPhones = new Set(existing.flatMap((row) => row.phones || []).map(normalizePhone).filter(Boolean));
5240
+ const created = [];
5241
+ const skipped = [];
5242
+ for (const row of rows.slice(0, Number(args.limit || 200))) {
5243
+ const email = row.email || row.emails?.[0] || "";
5244
+ const phone = row.phone || row.phones?.[0] || "";
5245
+ const emailKey = email.toLocaleLowerCase("en-US");
5246
+ const phoneKey = normalizePhone(phone);
5247
+ if ((!email && !phone) || (emailKey && existingEmails.has(emailKey)) || (phoneKey && existingPhones.has(phoneKey))) {
5248
+ skipped.push(row);
5249
+ continue;
5250
+ }
5251
+ const createdRow = await yandexContactsCreate({
5252
+ name: row.name || email || phone,
5253
+ email,
5254
+ phone,
5255
+ address: row.address,
5256
+ note: row.note,
5257
+ birthday: row.birthday,
5258
+ org: row.org,
5259
+ title: row.title,
5260
+ categories: row.categories,
5261
+ confirm: true,
5262
+ });
5263
+ created.push(createdRow);
5264
+ if (emailKey) existingEmails.add(emailKey);
5265
+ if (phoneKey) existingPhones.add(phoneKey);
5266
+ }
5267
+ return { status: "contacts-imported", format, input: path.resolve(inputPath), created: created.length, skipped: skipped.length };
5268
+ }
5269
+
5270
+ async function yandexContactsBirthdaysToCalendar(args = {}) {
5271
+ if (!args.confirm) throw new Error("Для создания событий дней рождения нужен аргумент confirm=true.");
5272
+ const rows = await yandexContactsList({ limit: Number(args.limit || 1000) });
5273
+ const withBirthday = rows.filter((row) => row.birthday);
5274
+ const created = [];
5275
+ for (const contact of withBirthday.slice(0, Number(args.maxCreate || 50))) {
5276
+ const next = nextBirthdayDate(contact.birthday);
5277
+ if (!next) continue;
5278
+ const result = await yandexCalendarCreateEvent({
5279
+ title: `День рождения: ${contact.name || contact.email || contact.phone}`,
5280
+ start: next.start,
5281
+ end: next.end,
5282
+ description: `Контакт: ${formatYandexContact(contact)}`,
5283
+ confirm: true,
5284
+ });
5285
+ created.push(result);
5286
+ }
5287
+ return { status: "birthday-events-created", created: created.length, totalWithBirthday: withBirthday.length };
5288
+ }
5289
+
5290
+ async function yandexContactSendMail(args = {}) {
5291
+ if (!args.confirm) throw new Error("Для отправки письма контакту нужен аргумент confirm=true.");
5292
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5293
+ if (resolved.status !== "ok") return resolved;
5294
+ const contact = resolved.contact;
5295
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5296
+ const sent = await yandexMailSend({
5297
+ to: [contact.email],
5298
+ subject: args.subject || "Сообщение от IOLA CLI",
5299
+ text: args.text || args.message || "",
5300
+ confirm: true,
5301
+ });
5302
+ return { status: "contact-mail-sent", contact: contact.name || contact.email, to: sent.to, subject: sent.subject };
5303
+ }
5304
+
5305
+ async function yandexContactSendDiskLinkQr(args = {}) {
5306
+ if (!args.confirm) throw new Error("Для отправки ссылки контакту нужен аргумент confirm=true.");
5307
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5308
+ if (resolved.status !== "ok") return resolved;
5309
+ const contact = resolved.contact;
5310
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5311
+ const result = await yandexDiskShareEmail({
5312
+ remotePath: args.path || args.remotePath || args.target,
5313
+ to: [contact.email],
5314
+ subject: args.subject,
5315
+ text: args.text || args.message,
5316
+ confirm: true,
5317
+ });
5318
+ return { ...result, status: "contact-disk-link-sent", contact: contact.name || contact.email };
5319
+ }
5320
+
5321
+ async function yandexContactCreateDiskFolder(args = {}) {
5322
+ if (!args.confirm) throw new Error("Для создания папки контакта нужен аргумент confirm=true.");
5323
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5324
+ if (resolved.status !== "ok") return resolved;
5325
+ const contact = resolved.contact;
5326
+ const folder = args.path || `${CLOUD_DEFAULT_REMOTE_DIR}/contacts/${slugYandexDiskName(contact.name || contact.email || contact.phone || "contact")}`;
5327
+ await cloudCreateFolder("yandex-disk", folder);
5328
+ const cardPath = path.posix.join(folder, "contact.vcf");
5329
+ await yandexDiskSaveText(contact.card, cardPath);
5330
+ const note = [
5331
+ `Контакт: ${contact.name || "-"}`,
5332
+ `Email: ${(contact.emails || []).join(", ") || "-"}`,
5333
+ `Телефон: ${(contact.phones || []).join(", ") || "-"}`,
5334
+ contact.address ? `Адрес: ${contact.address}` : "",
5335
+ contact.org ? `Организация: ${contact.org}` : "",
5336
+ contact.title ? `Должность: ${contact.title}` : "",
5337
+ contact.note ? `Заметка: ${contact.note}` : "",
5338
+ ].filter(Boolean).join("\n");
5339
+ await yandexDiskSaveText(note, path.posix.join(folder, "README.txt"));
5340
+ return { provider: "yandex-disk", status: "contact-folder-created", contact: contact.name || contact.email, remote: folder, cardPath };
5341
+ }
5342
+
5343
+ async function yandexContactCreateCalendarEvent(args = {}) {
5344
+ if (!args.confirm) throw new Error("Для создания встречи с контактом нужен аргумент confirm=true.");
5345
+ const resolved = await resolveYandexContact(args.query || args.contact || args.name || "", args);
5346
+ if (resolved.status !== "ok") return resolved;
5347
+ const contact = resolved.contact;
5348
+ if (!contact.email) return { status: "no-email", contact: stripYandexContactPrivateFields(contact) };
5349
+ const result = await yandexCalendarCreateEvent({
5350
+ ...args,
5351
+ title: args.title || `Встреча: ${contact.name || contact.email}`,
5352
+ description: [args.description || "", `Контакт: ${contact.name || "-"}`, `Email: ${contact.email}`].filter(Boolean).join("\n"),
5353
+ attendees: [contact.email],
5354
+ confirm: true,
5355
+ });
5356
+ return { ...result, status: "contact-calendar-event-created", contact: contact.name || contact.email, attendee: contact.email };
5357
+ }
5358
+
5359
+ async function yandexContactCreateTelemostEvent(args = {}) {
5360
+ const result = await yandexContactCreateCalendarEvent({
5361
+ ...args,
5362
+ title: args.title || `Телемост: ${args.contact || args.name || args.query || "контакт"}`,
5363
+ description: [args.description || "", "Телемост: создайте ссылку в Яндекс Календаре, если интерфейс календаря предложит видеовстречу."].filter(Boolean).join("\n"),
5364
+ confirm: true,
5365
+ });
5366
+ return { ...result, status: "contact-telemost-event-created" };
5367
+ }
5368
+
5369
+ async function yandexContactFromPublicEntity(args = {}) {
5370
+ if (!args.confirm) throw new Error("Для создания контакта из городского слоя нужен аргумент confirm=true.");
5371
+ const layer = normalizeEntityLayer(args.layer || (/(сад|детсад)/iu.test(args.query || "") ? "kindergartens" : "schools"));
5372
+ const sourceQuery = args.query || args.name || args.inn || "";
5373
+ const number = String(sourceQuery).match(/№?\s*(\d{1,4})/u)?.[1] || "";
5374
+ const query = number
5375
+ ? (layer === "kindergartens" ? `детский сад ${number}` : `школа ${number}`)
5376
+ : sourceQuery;
5377
+ const rows = await searchPublicEntities({ layer, query, limit: 5 });
5378
+ if (!rows.length) return { status: "not-found", query: args.query || args.name || args.inn || "" };
5379
+ const entity = rows[0];
5380
+ const name = args.contactName || entity.name || entity.fns_short_name || args.query;
5381
+ const email = entity.email || "";
5382
+ const phone = entity.phone || "";
5383
+ if (!email && !phone) return { status: "no-contact-fields", entity };
5384
+ return yandexContactsCreate({
5385
+ name,
5386
+ email,
5387
+ phone,
5388
+ address: entity.address,
5389
+ org: entity.name,
5390
+ note: `Создано из открытого слоя ${layer}. ИНН: ${entity.inn || "-"}.`,
5391
+ confirm: true,
5392
+ });
5393
+ }
5394
+
5395
+ async function resolveYandexContact(query, args = {}) {
5396
+ const rows = await yandexContactsList({ limit: Math.max(500, Number(args.limit || 100)), full: true });
5397
+ const normalized = normalizeContactLookupText(query || args.query || args.name || args.email || args.phone || "");
5398
+ const matches = rows.filter((row) => contactMatchesQuery(row, normalized)).slice(0, 20);
5399
+ if (!matches.length) return { status: "not-found", query };
5400
+ const exact = pickExactYandexContactMatch(matches, query, args);
5401
+ if (exact) return { status: "ok", contact: exact };
5402
+ if (matches.length > 1 && !args.selectFirst) return { status: "ambiguous", query, contacts: matches.map(stripYandexContactPrivateFields) };
5403
+ return { status: "ok", contact: matches[0] };
5404
+ }
5405
+
5406
+ function pickExactYandexContactMatch(matches, query, args = {}) {
5407
+ const text = String(query || args.email || args.phone || args.name || "").trim();
5408
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0]?.toLocaleLowerCase("en-US");
5409
+ if (email) return matches.find((row) => row.emails?.some((item) => item.toLocaleLowerCase("en-US") === email));
5410
+ const phone = normalizePhone(args.phone || text);
5411
+ if (phone.length >= 7) return matches.find((row) => row.phones?.some((item) => normalizePhone(item).endsWith(phone) || phone.endsWith(normalizePhone(item))));
5412
+ const normalized = normalizeContactLookupText(text);
5413
+ return matches.find((row) => normalizeContactLookupText(row.name) === normalized) || null;
5414
+ }
5415
+
5416
+ function stripYandexContactPrivateFields(row = {}) {
5417
+ const { card: _card, ...rest } = row;
5418
+ return rest;
5419
+ }
5420
+
5421
+ async function saveYandexContactCard(href, card) {
5422
+ const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
5423
+ await yandexDavRequest(new URL(href, "https://carddav.yandex.ru/").toString(), token, {
5424
+ method: "PUT",
5425
+ headers: { "content-type": "text/vcard; charset=utf-8" },
5426
+ body: ensureVcardCrlf(card),
5427
+ timeout: 45000,
5428
+ });
5429
+ }
5430
+
5431
+ function buildVcard(args = {}) {
5432
+ const uid = args.uid || `${randomUUID()}@iola-cli`;
5433
+ const lines = [
5434
+ "BEGIN:VCARD",
5435
+ "VERSION:3.0",
5436
+ `UID:${uid}`,
5437
+ `FN:${escapeVcardValue(args.name || args.email || args.phone || "Контакт")}`,
5438
+ `N:${escapeVcardValue(args.name || args.email || args.phone || "Контакт")};;;;`,
5439
+ args.email ? `EMAIL;TYPE=INTERNET:${String(args.email).trim()}` : "",
5440
+ args.phone ? `TEL;TYPE=CELL:${escapeVcardValue(args.phone)}` : "",
5441
+ args.address ? `ADR;TYPE=HOME:;;;;${escapeVcardValue(args.address)};;` : "",
5442
+ args.org ? `ORG:${escapeVcardValue(args.org)}` : "",
5443
+ args.title ? `TITLE:${escapeVcardValue(args.title)}` : "",
5444
+ args.birthday ? `BDAY:${normalizeVcardBirthday(args.birthday)}` : "",
5445
+ args.note ? `NOTE:${escapeVcardValue(args.note)}` : "",
5446
+ args.categories ? `CATEGORIES:${escapeVcardValue(Array.isArray(args.categories) ? args.categories.join(",") : args.categories)}` : "",
5447
+ "END:VCARD",
5448
+ "",
5449
+ ].filter((line) => line !== "");
5450
+ return lines.join("\r\n");
4916
5451
  }
4917
5452
 
4918
5453
  function upsertVcardEmail(card, email, options = {}) {
5454
+ return upsertVcardProperty(card, "EMAIL;TYPE=INTERNET", email, { replace: Boolean(options.overwrite) });
5455
+ }
5456
+
5457
+ function upsertVcardProperty(card, property, value, options = {}) {
4919
5458
  const text = String(card || "").replace(/\r/g, "").trim();
4920
5459
  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}`);
5460
+ const propName = String(property || "").split(";")[0].toLocaleUpperCase("en-US");
5461
+ const rawValue = options.raw ? String(value || "") : escapeVcardValue(value);
5462
+ if (!rawValue) return text;
5463
+ const line = `${property}:${rawValue}`;
5464
+ const pattern = new RegExp(`^${escapeRegExp(propName)}[^:]*:[^\\n]*`, "imu");
5465
+ if (pattern.test(text)) {
5466
+ if (!options.replace) return text;
5467
+ return text.replace(pattern, line);
4924
5468
  }
4925
- return text.replace(/\nEND:VCARD/iu, `\nEMAIL;TYPE=INTERNET:${email}\nEND:VCARD`);
5469
+ return text.replace(/\nEND:VCARD/iu, `\n${line}\nEND:VCARD`);
5470
+ }
5471
+
5472
+ function removeVcardProperty(card, property, value = "") {
5473
+ const text = String(card || "").replace(/\r/g, "").trim();
5474
+ const propName = String(property || "").split(";")[0].toLocaleUpperCase("en-US");
5475
+ const lines = text.split(/\n/u);
5476
+ const needle = String(value || "").toLocaleLowerCase("ru-RU");
5477
+ const next = lines.filter((line) => {
5478
+ if (!new RegExp(`^${escapeRegExp(propName)}(?:[;:]|$)`, "iu").test(line)) return true;
5479
+ if (!needle) return false;
5480
+ return !line.toLocaleLowerCase("ru-RU").includes(needle);
5481
+ });
5482
+ return next.join("\n");
4926
5483
  }
4927
5484
 
4928
5485
  function escapeVcardValue(value) {
@@ -4930,10 +5487,28 @@ function escapeVcardValue(value) {
4930
5487
  }
4931
5488
 
4932
5489
  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);
5490
+ const query = normalizeContactLookupText(normalizedQuery);
5491
+ const contactText = normalizeContactLookupText([
5492
+ contact.name,
5493
+ contact.email,
5494
+ ...(contact.emails || []),
5495
+ contact.phone,
5496
+ ...(contact.phones || []),
5497
+ contact.address,
5498
+ contact.org,
5499
+ contact.title,
5500
+ contact.note,
5501
+ contact.categories,
5502
+ ].filter(Boolean).join(" "));
5503
+ const queryPhone = normalizePhone(normalizedQuery);
5504
+ if (queryPhone.length >= 7 && [...(contact.phones || []), contact.phone].some((phone) => {
5505
+ const contactPhone = normalizePhone(phone);
5506
+ return contactPhone.length >= 7 && (contactPhone.includes(queryPhone) || queryPhone.includes(contactPhone));
5507
+ })) return true;
5508
+ const normalizedQueryText = query;
5509
+ if (!contactText || !normalizedQueryText) return false;
5510
+ if (contactText.includes(normalizedQueryText)) return true;
5511
+ const queryTokens = normalizedQueryText.split(/\s+/u).filter(Boolean);
4937
5512
  const contactTokens = contactText.split(/\s+/u).filter(Boolean);
4938
5513
  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
5514
  }
@@ -4942,6 +5517,99 @@ function normalizeContactLookupText(value) {
4942
5517
  return normalizeGeoText(String(value || "").replace(/\b(?:кому|контакт|письмо|сообщение)\b/giu, " ")).trim();
4943
5518
  }
4944
5519
 
5520
+ function normalizePhone(value) {
5521
+ return String(value || "").replace(/[^\d]+/g, "");
5522
+ }
5523
+
5524
+ function normalizeVcardBirthday(value) {
5525
+ const text = String(value || "").trim();
5526
+ const iso = text.match(/^(\d{4})-(\d{2})-(\d{2})$/u);
5527
+ if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
5528
+ const ru = text.match(/^(\d{1,2})[.\-/](\d{1,2})(?:[.\-/](\d{2,4}))?$/u);
5529
+ if (ru) {
5530
+ const year = ru[3] ? (ru[3].length === 2 ? `20${ru[3]}` : ru[3]) : "1900";
5531
+ return `${year}-${String(ru[2]).padStart(2, "0")}-${String(ru[1]).padStart(2, "0")}`;
5532
+ }
5533
+ return text;
5534
+ }
5535
+
5536
+ function contactsToCsv(rows) {
5537
+ const headers = ["name", "email", "emails", "phone", "phones", "address", "org", "title", "birthday", "note", "categories"];
5538
+ return [
5539
+ headers.join(","),
5540
+ ...rows.map((row) => headers.map((key) => contactsCsvCell(Array.isArray(row[key]) ? row[key].join("; ") : row[key])).join(",")),
5541
+ ].join("\n");
5542
+ }
5543
+
5544
+ function contactsCsvCell(value) {
5545
+ return `"${String(value || "").replace(/"/g, '""')}"`;
5546
+ }
5547
+
5548
+ function parseContactsCsv(text) {
5549
+ const rows = parseSimpleCsv(text);
5550
+ return rows.map((row) => ({
5551
+ name: row.name || row.Name || row["Имя"] || row["ФИО"] || "",
5552
+ email: row.email || row.Email || row["Почта"] || row["Email"] || "",
5553
+ phone: row.phone || row.Phone || row["Телефон"] || "",
5554
+ address: row.address || row.Address || row["Адрес"] || "",
5555
+ org: row.org || row.organization || row["Организация"] || "",
5556
+ title: row.title || row.position || row["Должность"] || "",
5557
+ birthday: row.birthday || row.Birthday || row["День рождения"] || "",
5558
+ note: row.note || row.Note || row["Заметка"] || "",
5559
+ categories: row.categories || row.group || row["Группа"] || "",
5560
+ })).filter((row) => row.name || row.email || row.phone);
5561
+ }
5562
+
5563
+ function parseSimpleCsv(text) {
5564
+ const lines = String(text || "").replace(/\r/g, "").split("\n").filter((line) => line.trim());
5565
+ if (!lines.length) return [];
5566
+ const headers = splitContactsCsvLine(lines[0]).map((header) => header.trim());
5567
+ return lines.slice(1).map((line) => {
5568
+ const values = splitContactsCsvLine(line);
5569
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || ""]));
5570
+ });
5571
+ }
5572
+
5573
+ function splitContactsCsvLine(line) {
5574
+ const cells = [];
5575
+ let current = "";
5576
+ let quoted = false;
5577
+ for (let index = 0; index < String(line || "").length; index += 1) {
5578
+ const char = line[index];
5579
+ const next = line[index + 1];
5580
+ if (char === "\"" && quoted && next === "\"") {
5581
+ current += "\"";
5582
+ index += 1;
5583
+ } else if (char === "\"") {
5584
+ quoted = !quoted;
5585
+ } else if (char === "," && !quoted) {
5586
+ cells.push(current);
5587
+ current = "";
5588
+ } else {
5589
+ current += char;
5590
+ }
5591
+ }
5592
+ cells.push(current);
5593
+ return cells.map((cell) => cell.trim());
5594
+ }
5595
+
5596
+ function nextBirthdayDate(value) {
5597
+ const normalized = normalizeVcardBirthday(value);
5598
+ const match = normalized.match(/^\d{4}-(\d{2})-(\d{2})$/u)
5599
+ || normalized.match(/^\d{4}(\d{2})(\d{2})$/u)
5600
+ || normalized.match(/^(\d{2})-(\d{2})$/u)
5601
+ || normalized.match(/^(\d{2})(\d{2})$/u);
5602
+ if (!match) return null;
5603
+ const now = new Date();
5604
+ let date = new Date(now.getFullYear(), Number(match[1]) - 1, Number(match[2]), 9, 0, 0);
5605
+ if (date < now) date = new Date(now.getFullYear() + 1, Number(match[1]) - 1, Number(match[2]), 9, 0, 0);
5606
+ return { start: date.toISOString(), end: new Date(date.getTime() + 3600000).toISOString() };
5607
+ }
5608
+
5609
+ function escapeRegExp(value) {
5610
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5611
+ }
5612
+
4945
5613
  async function yandexContactsBaseUrl(token) {
4946
5614
  const root = "https://carddav.yandex.ru/";
4947
5615
  const principalXml = await yandexDavRequest(root, token, {
@@ -4980,8 +5648,12 @@ async function yandexTelemostCreateEvent(args = {}) {
4980
5648
  return yandexCalendarCreateEvent({ ...args, description });
4981
5649
  }
4982
5650
 
4983
- function buildIcsEvent({ uid, start, end, summary, description, location }) {
5651
+ function buildIcsEvent({ uid, start, end, summary, description, location, attendees = [] }) {
4984
5652
  const escape = (value) => String(value || "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
5653
+ const attendeeLines = (Array.isArray(attendees) ? attendees : [attendees])
5654
+ .map((email) => String(email || "").trim())
5655
+ .filter((email) => /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email))
5656
+ .map((email) => `ATTENDEE;CN=${escape(email)};ROLE=REQ-PARTICIPANT:mailto:${email}`);
4985
5657
  return [
4986
5658
  "BEGIN:VCALENDAR",
4987
5659
  "VERSION:2.0",
@@ -4994,6 +5666,7 @@ function buildIcsEvent({ uid, start, end, summary, description, location }) {
4994
5666
  `SUMMARY:${escape(summary)}`,
4995
5667
  description ? `DESCRIPTION:${escape(description)}` : "",
4996
5668
  location ? `LOCATION:${escape(location)}` : "",
5669
+ ...attendeeLines,
4997
5670
  "END:VEVENT",
4998
5671
  "END:VCALENDAR",
4999
5672
  "",
@@ -5057,19 +5730,75 @@ function extractVcardHrefs(xml) {
5057
5730
  function parseVCards(xmlOrCards) {
5058
5731
  const decoded = decodeXml(stripXmlTags(xmlOrCards)).replace(/\r/g, "");
5059
5732
  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 };
5733
+ const card = `BEGIN:VCARD\n${chunk}`.replace(/\n+/g, "\n").trim();
5734
+ const lines = unfoldVcardLines(chunk);
5735
+ const emails = vcardValues(lines, "EMAIL");
5736
+ const phones = vcardValues(lines, "TEL");
5737
+ const name = cleanVcardName(vcardValue(lines, "FN") || vcardValue(lines, "N"), emails[0] || phones[0] || "");
5738
+ const address = cleanVcardAddress(vcardValue(lines, "ADR"));
5739
+ const row = {
5740
+ uid: vcardValue(lines, "UID"),
5741
+ name,
5742
+ email: emails[0] || "",
5743
+ emails,
5744
+ phone: phones[0] || "",
5745
+ phones,
5746
+ address,
5747
+ org: cleanVcardName(vcardValue(lines, "ORG")),
5748
+ title: cleanVcardName(vcardValue(lines, "TITLE")),
5749
+ note: unescapeVcardValue(vcardValue(lines, "NOTE")),
5750
+ birthday: vcardValue(lines, "BDAY"),
5751
+ categories: vcardValue(lines, "CATEGORIES"),
5752
+ card: ensureVcardCrlf(card),
5753
+ };
5754
+ return row;
5064
5755
  }).filter((item) => item.name || item.email || item.phone);
5065
5756
  }
5066
5757
 
5067
5758
  function cleanVcardName(value, fallback = "") {
5068
- const text = String(value || "").replace(/;/g, " ").replace(/\s+/g, " ").trim();
5759
+ const text = unescapeVcardValue(value).replace(/;/g, " ").replace(/\s+/g, " ").trim();
5069
5760
  if (!text || /^[\s;]+$/u.test(String(value || ""))) return fallback || "";
5070
5761
  return text;
5071
5762
  }
5072
5763
 
5764
+ function unfoldVcardLines(value) {
5765
+ return String(value || "")
5766
+ .replace(/\r/g, "")
5767
+ .replace(/\n[ \t]/g, "")
5768
+ .split(/\n/u)
5769
+ .map((line) => line.trim())
5770
+ .filter(Boolean);
5771
+ }
5772
+
5773
+ function vcardValues(lines, property) {
5774
+ const prop = String(property || "").toLocaleUpperCase("en-US");
5775
+ return lines
5776
+ .filter((line) => line.toLocaleUpperCase("en-US").startsWith(`${prop};`) || line.toLocaleUpperCase("en-US").startsWith(`${prop}:`))
5777
+ .map((line) => unescapeVcardValue(line.slice(line.indexOf(":") + 1).trim()))
5778
+ .filter(Boolean);
5779
+ }
5780
+
5781
+ function vcardValue(lines, property) {
5782
+ return vcardValues(lines, property)[0] || "";
5783
+ }
5784
+
5785
+ function cleanVcardAddress(value) {
5786
+ return unescapeVcardValue(value).split(";").map((part) => part.trim()).filter(Boolean).join(", ");
5787
+ }
5788
+
5789
+ function unescapeVcardValue(value) {
5790
+ return String(value || "")
5791
+ .replace(/\\n/giu, "\n")
5792
+ .replace(/\\,/gu, ",")
5793
+ .replace(/\\;/gu, ";")
5794
+ .replace(/\\\\/gu, "\\")
5795
+ .trim();
5796
+ }
5797
+
5798
+ function ensureVcardCrlf(value) {
5799
+ return String(value || "").replace(/\r/g, "").replace(/\n/g, "\r\n").trim() + "\r\n";
5800
+ }
5801
+
5073
5802
  function normalizeYandexServiceList(values) {
5074
5803
  const aliases = {
5075
5804
  id: "identity",
@@ -5278,6 +6007,121 @@ async function yandexDiskFind(query, options = {}) {
5278
6007
  .slice(0, Number(options.limit || 50));
5279
6008
  }
5280
6009
 
6010
+ async function yandexDiskStat(remotePath) {
6011
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6012
+ const payload = await yandexDiskRequest("GET", "/resources", {
6013
+ query: {
6014
+ path: normalizeYandexDiskPath(remotePath),
6015
+ fields: "name,path,type,size,created,modified,mime_type,public_url,preview,md5,sha256,embedded",
6016
+ },
6017
+ });
6018
+ return formatYandexDiskResource(payload);
6019
+ }
6020
+
6021
+ async function yandexDiskExists(remotePath) {
6022
+ try {
6023
+ const stat = await yandexDiskStat(remotePath);
6024
+ return { provider: "yandex-disk", path: stat.path, exists: true, type: stat.type, name: stat.name };
6025
+ } catch (error) {
6026
+ if (/404|DiskNotFoundError|Path not found/iu.test(String(error?.message || ""))) {
6027
+ return { provider: "yandex-disk", path: remotePath, exists: false };
6028
+ }
6029
+ throw error;
6030
+ }
6031
+ }
6032
+
6033
+ async function yandexDiskReadText(remotePath, options = {}) {
6034
+ if (!remotePath) throw new Error("Путь к текстовому файлу на Яндекс Диске обязателен.");
6035
+ const maxBytes = Number(options.maxBytes || 200000);
6036
+ const download = await yandexDiskRequest("GET", "/resources/download", { query: { path: normalizeYandexDiskPath(remotePath) } });
6037
+ const response = await fetch(download.href, { signal: AbortSignal.timeout(120000) });
6038
+ if (!response.ok) throw new Error(`Yandex Disk read failed: ${response.status} ${response.statusText}`);
6039
+ const buffer = Buffer.from(await response.arrayBuffer());
6040
+ if (buffer.length > maxBytes) throw new Error(`Файл слишком большой для чтения: ${buffer.length} байт. Лимит: ${maxBytes}.`);
6041
+ return { provider: "yandex-disk", remote: remotePath, text: buffer.toString("utf8"), size: buffer.length };
6042
+ }
6043
+
6044
+ async function yandexDiskMove(from, to, options = {}) {
6045
+ if (!options.confirm) throw new Error("Для перемещения на Яндекс Диске нужен аргумент confirm=true.");
6046
+ if (!from || !to) throw new Error("Для перемещения нужны from и to.");
6047
+ await ensureYandexDiskDir(path.dirname(to), { allowExisting: true });
6048
+ await yandexDiskRequest("POST", "/resources/move", {
6049
+ query: { from: normalizeYandexDiskPath(from), path: normalizeYandexDiskPath(to), overwrite: Boolean(options.overwrite) },
6050
+ timeout: 60000,
6051
+ });
6052
+ return { provider: "yandex-disk", status: "moved", from, remote: to };
6053
+ }
6054
+
6055
+ async function yandexDiskCopy(from, to, options = {}) {
6056
+ if (!options.confirm) throw new Error("Для копирования на Яндекс Диске нужен аргумент confirm=true.");
6057
+ if (!from || !to) throw new Error("Для копирования нужны from и to.");
6058
+ await ensureYandexDiskDir(path.dirname(to), { allowExisting: true });
6059
+ await yandexDiskRequest("POST", "/resources/copy", {
6060
+ query: { from: normalizeYandexDiskPath(from), path: normalizeYandexDiskPath(to), overwrite: Boolean(options.overwrite) },
6061
+ timeout: 60000,
6062
+ });
6063
+ return { provider: "yandex-disk", status: "copied", from, remote: to };
6064
+ }
6065
+
6066
+ async function yandexDiskRename(remotePath, newName, options = {}) {
6067
+ if (!remotePath || !newName) throw new Error("Для переименования нужны путь и новое имя.");
6068
+ const current = denormalizeYandexDiskPath(normalizeYandexDiskPath(remotePath));
6069
+ const target = path.posix.join(path.posix.dirname(current), slugYandexDiskName(newName));
6070
+ return yandexDiskMove(remotePath, target, { ...options, confirm: true });
6071
+ }
6072
+
6073
+ async function yandexDiskTrashList(remotePath = "", options = {}) {
6074
+ const query = { limit: Number(options.limit || 100) };
6075
+ if (remotePath) query.path = normalizeYandexDiskPath(remotePath);
6076
+ const payload = await yandexDiskRequest("GET", "/trash/resources", { query });
6077
+ const items = payload._embedded?.items || [];
6078
+ return items.map(formatYandexDiskResource);
6079
+ }
6080
+
6081
+ async function yandexDiskRestore(remotePath, options = {}) {
6082
+ if (!options.confirm) throw new Error("Для восстановления из корзины нужен аргумент confirm=true.");
6083
+ if (!remotePath) throw new Error("Путь в корзине обязателен.");
6084
+ await yandexDiskRequest("PUT", "/trash/resources/restore", {
6085
+ query: { path: normalizeYandexDiskPath(remotePath), name: options.name || "", overwrite: Boolean(options.overwrite) },
6086
+ timeout: 60000,
6087
+ });
6088
+ return { provider: "yandex-disk", status: "restored", remote: remotePath };
6089
+ }
6090
+
6091
+ async function yandexDiskEmptyTrash(options = {}) {
6092
+ if (!options.confirm) throw new Error("Для очистки корзины нужен аргумент confirm=true.");
6093
+ await yandexDiskRequest("DELETE", "/trash/resources", {
6094
+ query: { path: options.path ? normalizeYandexDiskPath(options.path) : "" },
6095
+ timeout: 60000,
6096
+ });
6097
+ return { provider: "yandex-disk", status: "trash-empty-requested", remote: options.path || "trash" };
6098
+ }
6099
+
6100
+ function formatYandexDiskResource(item = {}) {
6101
+ return {
6102
+ provider: "yandex-disk",
6103
+ type: item.type === "dir" ? "dir" : "file",
6104
+ name: item.name || path.basename(item.path || ""),
6105
+ path: denormalizeYandexDiskPath(item.path || ""),
6106
+ remote: denormalizeYandexDiskPath(item.path || ""),
6107
+ size: item.size || 0,
6108
+ mimeType: item.mime_type || "",
6109
+ created: item.created || "",
6110
+ modified: item.modified || "",
6111
+ publicUrl: item.public_url || "",
6112
+ md5: item.md5 || "",
6113
+ sha256: item.sha256 || "",
6114
+ };
6115
+ }
6116
+
6117
+ function slugYandexDiskName(value) {
6118
+ return String(value || "")
6119
+ .replace(/[\\/:*?"<>|]+/gu, "-")
6120
+ .replace(/\s+/gu, " ")
6121
+ .trim()
6122
+ || `item-${timestampForFile()}`;
6123
+ }
6124
+
5281
6125
  async function yandexDiskListRecursive(remotePath, options = {}) {
5282
6126
  const depth = Number(options.depth || 4);
5283
6127
  const limit = Number(options.limit || 200);
@@ -5325,6 +6169,119 @@ async function yandexDiskShare(remotePath) {
5325
6169
  return { provider: "yandex-disk", remote: remotePath, publicUrl: payload.public_url || "-" };
5326
6170
  }
5327
6171
 
6172
+ async function yandexDiskShareWithQr(remotePath, options = {}) {
6173
+ if (!options.confirm) throw new Error("Для публикации ссылки и QR нужен аргумент confirm=true.");
6174
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6175
+ const shared = await yandexDiskShare(remotePath);
6176
+ const qrRemotePath = options.qrPath || buildYandexDiskQrPath(remotePath);
6177
+ const tempPath = path.join(CONFIG_DIR, `qr-${Date.now()}.png`);
6178
+ await mkdir(CONFIG_DIR, { recursive: true });
6179
+ try {
6180
+ await createQrPng(shared.publicUrl, tempPath);
6181
+ await yandexDiskUpload(tempPath, qrRemotePath, { overwrite: true });
6182
+ } finally {
6183
+ await rm(tempPath, { force: true }).catch(() => {});
6184
+ }
6185
+ const qrShared = await yandexDiskShare(qrRemotePath);
6186
+ return {
6187
+ provider: "yandex-disk",
6188
+ status: "shared-with-qr",
6189
+ remote: remotePath,
6190
+ publicUrl: shared.publicUrl,
6191
+ qrRemote: qrRemotePath,
6192
+ qrPublicUrl: qrShared.publicUrl,
6193
+ };
6194
+ }
6195
+
6196
+ async function yandexDiskShareEmail(args = {}) {
6197
+ if (!args.confirm) throw new Error("Для отправки ссылки по почте нужен аргумент confirm=true.");
6198
+ const remotePath = args.remotePath || args.path || args.targetFolder || args.target;
6199
+ if (!remotePath) throw new Error("Путь на Яндекс Диске обязателен.");
6200
+ const to = await resolveYandexShareRecipients(args);
6201
+ const shared = await yandexDiskShareWithQr(remotePath, { ...args, confirm: true });
6202
+ const subject = args.subject || `Ссылка на Яндекс Диск: ${path.posix.basename(remotePath) || "материалы"}`;
6203
+ const text = [
6204
+ args.text || args.message || "Здравствуйте. Направляю ссылку на материалы.",
6205
+ "",
6206
+ `Ссылка: ${shared.publicUrl}`,
6207
+ `QR-код: ${shared.qrPublicUrl}`,
6208
+ "",
6209
+ "QR-код сохранен на Яндекс Диске:",
6210
+ shared.qrRemote,
6211
+ ].join("\n");
6212
+ const sent = await yandexMailSend({ to, subject, text, confirm: true });
6213
+ return { ...shared, status: "shared-and-sent", to: sent.to, subject };
6214
+ }
6215
+
6216
+ async function yandexDiskPackageShareEmail(args = {}) {
6217
+ if (!args.confirm) throw new Error("Для пакетной отправки папки нужен аргумент confirm=true.");
6218
+ const sourcePath = args.sourcePath || args.source || args.from;
6219
+ const targetFolder = args.targetFolder || args.target || args.to;
6220
+ if (!sourcePath || !targetFolder) throw new Error("Нужны sourcePath и targetFolder.");
6221
+ await cloudCreateFolder("yandex-disk", targetFolder);
6222
+ const items = await yandexDiskList(sourcePath);
6223
+ const mode = /move|перен/iu.test(args.mode || "") ? "move" : "copy";
6224
+ const transferred = [];
6225
+ for (const item of items.slice(0, Number(args.limit || 100))) {
6226
+ const destination = path.posix.join(targetFolder, item.name);
6227
+ if (mode === "move") {
6228
+ await yandexDiskMove(item.path, destination, { confirm: true, overwrite: args.overwrite !== false });
6229
+ } else {
6230
+ await yandexDiskCopy(item.path, destination, { confirm: true, overwrite: args.overwrite !== false });
6231
+ }
6232
+ transferred.push({ from: item.path, to: destination, type: item.type });
6233
+ }
6234
+ const shared = await yandexDiskShareEmail({
6235
+ remotePath: targetFolder,
6236
+ to: args.to,
6237
+ email: args.email,
6238
+ contact: args.contact || args.contactQuery,
6239
+ subject: args.subject || `Материалы на Яндекс Диске: ${path.posix.basename(targetFolder)}`,
6240
+ text: args.text || args.message || `Собрал материалы в папку ${targetFolder}.`,
6241
+ confirm: true,
6242
+ });
6243
+ return {
6244
+ ...shared,
6245
+ status: "package-shared-and-sent",
6246
+ sourcePath,
6247
+ targetFolder,
6248
+ mode,
6249
+ transferred: transferred.length,
6250
+ };
6251
+ }
6252
+
6253
+ async function resolveYandexShareRecipients(args = {}) {
6254
+ const emails = Array.isArray(args.to) ? args.to : String(args.to || args.email || "").split(/[;,]/u).map((item) => item.trim()).filter(Boolean);
6255
+ if (emails.length) return emails;
6256
+ const contactQuery = args.contact || args.contactQuery || args.name || "";
6257
+ if (!contactQuery) throw new Error("Укажите email или контакт получателя.");
6258
+ const result = await resolveYandexMailRecipientFromContacts(contactQuery);
6259
+ if (result.status === "not-found") throw new Error(`В Яндекс Контактах не нашел: ${contactQuery}. Укажите email вручную.`);
6260
+ if (result.status === "no-email") throw new Error(`Контакт найден, но email не указан: ${result.contact.name}. Укажите email вручную.`);
6261
+ if (result.status === "ambiguous") {
6262
+ const rows = result.contacts.map((contact, index) => `${index + 1}. ${contact.name || contact.email || "Контакт"}${contact.email ? `, ${contact.email}` : ", email не указан"}`).join("\n");
6263
+ throw new Error(`Нашел несколько контактов для "${contactQuery}". Уточните получателя:\n${rows}`);
6264
+ }
6265
+ return [result.contact.email];
6266
+ }
6267
+
6268
+ async function createQrPng(text, outputPath) {
6269
+ const QRCode = await import("qrcode");
6270
+ await QRCode.toFile(outputPath, String(text || ""), {
6271
+ type: "png",
6272
+ errorCorrectionLevel: "M",
6273
+ margin: 2,
6274
+ width: 512,
6275
+ });
6276
+ }
6277
+
6278
+ function buildYandexDiskQrPath(remotePath) {
6279
+ const current = denormalizeYandexDiskPath(normalizeYandexDiskPath(remotePath));
6280
+ const dir = path.posix.dirname(current);
6281
+ const base = path.posix.basename(current).replace(/\.[^.]+$/u, "");
6282
+ return path.posix.join(dir, `${slugYandexDiskName(base || "link")}-qr.png`);
6283
+ }
6284
+
5328
6285
  async function ensureYandexDiskDir(remotePath, options = {}) {
5329
6286
  const normalized = normalizeYandexDiskPath(remotePath || CLOUD_DEFAULT_REMOTE_DIR);
5330
6287
  const plain = denormalizeYandexDiskPath(normalized);
@@ -5345,7 +6302,7 @@ async function ensureYandexDiskDir(remotePath, options = {}) {
5345
6302
 
5346
6303
  function normalizeYandexDiskPath(remotePath) {
5347
6304
  const text = String(remotePath || CLOUD_DEFAULT_REMOTE_DIR).trim().replace(/\\/g, "/");
5348
- if (text.startsWith("disk:") || text.startsWith("app:")) return text;
6305
+ if (text.startsWith("disk:") || text.startsWith("app:") || text.startsWith("trash:")) return text;
5349
6306
  return text.startsWith("/") ? text : `/${text}`;
5350
6307
  }
5351
6308
 
@@ -9692,6 +10649,10 @@ function isCronDue(job) {
9692
10649
  if (everyMinutes) {
9693
10650
  return !lastRun || now.getTime() - lastRun.getTime() >= Number(everyMinutes[1]) * 60 * 1000;
9694
10651
  }
10652
+ const everyDays = normalized.match(/кажд(?:ые|ую)\s+(\d+)\s*(?:дн|день|дня|дней)/u) || normalized.match(/every\s+(\d+)\s*(?:d|day|days)/u);
10653
+ if (everyDays) {
10654
+ return !lastRun || now.getTime() - lastRun.getTime() >= Number(everyDays[1]) * 24 * 60 * 60 * 1000;
10655
+ }
9695
10656
  if (normalized.includes("каждый день") || normalized.includes("daily")) {
9696
10657
  return !lastRun || now.toISOString().slice(0, 10) !== lastRun.toISOString().slice(0, 10);
9697
10658
  }
@@ -10061,19 +11022,35 @@ async function aiAsk(args, context = {}) {
10061
11022
  if (!options.quiet) console.log(casualAnswer);
10062
11023
  return casualAnswer;
10063
11024
  }
10064
- const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
10065
- if (yandexAnswer) {
11025
+ const userSkillAnswer = await buildUserSkillDirectAnswer(question);
11026
+ if (userSkillAnswer) {
10066
11027
  if (historyEnabled) {
10067
- recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
10068
- appendSessionExchange(sessionId, question, yandexAnswer, dataContext, "");
11028
+ recordAskHistory({ question, answer: userSkillAnswer, providerConfig, dataContext, error: "", sessionId });
11029
+ appendSessionExchange(sessionId, question, userSkillAnswer, dataContext, "");
10069
11030
  }
10070
- emitEvent(options, "answer", { length: yandexAnswer.length, sessionId, direct: true, yandex: true });
11031
+ emitEvent(options, "answer", { length: userSkillAnswer.length, sessionId, direct: true, skill: true });
10071
11032
  if (options.output) {
10072
11033
  await assertPermission("writeFiles");
10073
- await writeFile(options.output, yandexAnswer, "utf8");
11034
+ await writeFile(options.output, userSkillAnswer, "utf8");
11035
+ }
11036
+ if (!options.quiet) console.log(userSkillAnswer);
11037
+ return userSkillAnswer;
11038
+ }
11039
+ if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
11040
+ const yandexContactAnswer = await buildYandexDirectAnswer(question, context.history || history);
11041
+ if (yandexContactAnswer) {
11042
+ if (historyEnabled) {
11043
+ recordAskHistory({ question, answer: yandexContactAnswer, providerConfig, dataContext, error: "", sessionId });
11044
+ appendSessionExchange(sessionId, question, yandexContactAnswer, dataContext, "");
11045
+ }
11046
+ emitEvent(options, "answer", { length: yandexContactAnswer.length, sessionId, direct: true, yandex: true });
11047
+ if (options.output) {
11048
+ await assertPermission("writeFiles");
11049
+ await writeFile(options.output, yandexContactAnswer, "utf8");
11050
+ }
11051
+ if (!options.quiet) console.log(yandexContactAnswer);
11052
+ return yandexContactAnswer;
10074
11053
  }
10075
- if (!options.quiet) console.log(yandexAnswer);
10076
- return yandexAnswer;
10077
11054
  }
10078
11055
  const cloudAnswer = await buildCloudDirectAnswer(question);
10079
11056
  if (cloudAnswer) {
@@ -10089,6 +11066,20 @@ async function aiAsk(args, context = {}) {
10089
11066
  if (!options.quiet) console.log(cloudAnswer);
10090
11067
  return cloudAnswer;
10091
11068
  }
11069
+ const yandexAnswer = await buildYandexDirectAnswer(question, context.history || history);
11070
+ if (yandexAnswer) {
11071
+ if (historyEnabled) {
11072
+ recordAskHistory({ question, answer: yandexAnswer, providerConfig, dataContext, error: "", sessionId });
11073
+ appendSessionExchange(sessionId, question, yandexAnswer, dataContext, "");
11074
+ }
11075
+ emitEvent(options, "answer", { length: yandexAnswer.length, sessionId, direct: true, yandex: true });
11076
+ if (options.output) {
11077
+ await assertPermission("writeFiles");
11078
+ await writeFile(options.output, yandexAnswer, "utf8");
11079
+ }
11080
+ if (!options.quiet) console.log(yandexAnswer);
11081
+ return yandexAnswer;
11082
+ }
10092
11083
  const geoAnswer = await buildGeoDirectAnswer(question);
10093
11084
  if (geoAnswer) {
10094
11085
  if (historyEnabled) {
@@ -10214,6 +11205,10 @@ async function buildYandexDirectAnswer(question, history = []) {
10214
11205
  ].join("\n");
10215
11206
  }
10216
11207
 
11208
+ if (/(контакт|адресн)/iu.test(normalized) && !mailFollowup && !isExplicitYandexDiskPathDelete(question)) {
11209
+ return await buildYandexContactsDirectAnswer(question, normalized);
11210
+ }
11211
+
10217
11212
  if (mailFollowup || /(почт|письм|email|e-mail|спам|чернов|отправлен|исходящ|корзин)/iu.test(normalized)) {
10218
11213
  if (/(авто|автомат|кажд|период|монитор|следи|проверяй|проверку|режим)/iu.test(normalized) && /(включ|запусти|начни|поставь|создай)/iu.test(normalized)) {
10219
11214
  const minutes = Number(String(question || "").match(/(\d+)\s*(?:мин|минут)/iu)?.[1] || 5);
@@ -10368,6 +11363,38 @@ async function buildYandexDirectAnswer(question, history = []) {
10368
11363
  }
10369
11364
 
10370
11365
  if (/(контакт|адресн)/iu.test(normalized)) {
11366
+ if (/(дубликат|повтор)/iu.test(normalized)) {
11367
+ const rows = await yandexContactsFindDuplicates({ limit: 20 });
11368
+ if (!rows.length) return "Дубликаты контактов не найдены.";
11369
+ return rows.map((row) => formatToolResult({ rows: [row], outputs: [] }, {})).join("\n");
11370
+ }
11371
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(normalized)) {
11372
+ const field = /без\s+(?:email|почт)/iu.test(normalized) ? "email" : /без\s+телефон/iu.test(normalized) ? "phone" : /без\s+адрес/iu.test(normalized) ? "address" : "";
11373
+ const rows = await yandexContactsFindIncomplete({ field, limit: 30 });
11374
+ if (!rows.length) return "Неполные контакты по этому признаку не найдены.";
11375
+ return ["Неполные контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
11376
+ }
11377
+ if (/(экспорт|выгруз|сохрани|резерв|backup|бэкап)/iu.test(normalized) && /(диск|яндекс.?диск|облак)/iu.test(normalized)) {
11378
+ const result = await yandexContactsBackupToDisk({ format: /csv/iu.test(normalized) ? "csv" : "vcard", confirm: true });
11379
+ return `Контакты сохранены на Яндекс Диск: ${result.remote}. Записей: ${result.rows}.`;
11380
+ }
11381
+ if (/(экспорт|выгруз)/iu.test(normalized)) {
11382
+ const result = await yandexContactsExport(/csv/iu.test(normalized) ? "csv" : "vcard", {});
11383
+ return `Контакты экспортированы: ${result.output}. Записей: ${result.rows}.`;
11384
+ }
11385
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(normalized)) {
11386
+ const draft = parseYandexContactCreateRequest(question);
11387
+ if (!draft.name || (!draft.email && !draft.phone)) return "Для создания контакта укажите имя и email или телефон.";
11388
+ const result = await yandexContactsCreate({ ...draft, confirm: true });
11389
+ return `Контакт создан: ${formatYandexContact(result)}.`;
11390
+ }
11391
+ if (/(удали|удалить).{0,40}контакт/iu.test(normalized)) {
11392
+ const query = cleanupYandexContactActionQuery(question);
11393
+ const result = await yandexContactsDelete(query, { confirm: true });
11394
+ if (result.status === "ambiguous") return [`Нашел несколько контактов. Уточните:`, ...result.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
11395
+ if (result.status === "not-found") return `Контакт не найден: ${query}.`;
11396
+ return `Контакт удален: ${formatYandexContact(result)}.`;
11397
+ }
10371
11398
  if (/(добав|запиши|сохрани).{0,40}(email|e-mail|почт)/iu.test(normalized)) {
10372
11399
  const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
10373
11400
  const query = cleanupYandexContactEmailTarget(question, email);
@@ -10383,6 +11410,54 @@ async function buildYandexDirectAnswer(question, history = []) {
10383
11410
  }
10384
11411
  return `Email добавлен в контакт: ${result.name || query}, ${result.email}.`;
10385
11412
  }
11413
+ if (/(добав|запиши|сохрани).{0,40}(телефон|номер)/iu.test(normalized)) {
11414
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
11415
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
11416
+ if (!phone || !query) return "Укажите контакт и телефон.";
11417
+ const result = await yandexContactsUpdate(query, { phone, mode: "add-phone", confirm: true });
11418
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11419
+ return `Телефон добавлен: ${formatYandexContact(result)}.`;
11420
+ }
11421
+ if (/(добав|запиши|сохрани).{0,40}(адрес)/iu.test(normalized)) {
11422
+ const address = question.match(/(?:адрес|по адресу)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11423
+ const query = cleanupYandexContactActionQuery(question.replace(address, " "));
11424
+ if (!address || !query) return "Укажите контакт и адрес.";
11425
+ const result = await yandexContactsUpdate(query, { address, mode: "add-address", confirm: true });
11426
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11427
+ return `Адрес добавлен: ${formatYandexContact(result)}.`;
11428
+ }
11429
+ if (/(добав|запиши|сохрани).{0,40}(заметк|коммент|примеч)/iu.test(normalized)) {
11430
+ const note = question.match(/(?:заметк\p{L}*|коммент\p{L}*|примеч\p{L}*)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11431
+ const query = cleanupYandexContactActionQuery(question.replace(note, " "));
11432
+ if (!note || !query) return "Укажите контакт и текст заметки.";
11433
+ const result = await yandexContactsUpdate(query, { note, mode: "add-note", confirm: true });
11434
+ if (result.status !== "updated") return formatToolResult({ rows: [result], outputs: [] }, {});
11435
+ return `Заметка добавлена: ${formatYandexContact(result)}.`;
11436
+ }
11437
+ if (/(создай|сделай).{0,40}(папк).{0,40}(контакт)/iu.test(normalized)) {
11438
+ const query = cleanupYandexContactActionQuery(question);
11439
+ const result = await yandexContactCreateDiskFolder({ query, confirm: true });
11440
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11441
+ }
11442
+ if (/(отправь|пошли).{0,80}(ссылк|qr|qr-код|диск|яндекс.?диск)/iu.test(normalized)) {
11443
+ const remotePath = extractCloudPath(question);
11444
+ const contact = cleanupYandexContactActionQuery(question.replace(remotePath || "", " "));
11445
+ const result = await yandexContactSendDiskLinkQr({ contact, path: remotePath, confirm: true });
11446
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11447
+ }
11448
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(normalized)) {
11449
+ const draft = parseYandexMailSendRequest(question);
11450
+ const result = await yandexContactSendMail({ contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true });
11451
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11452
+ }
11453
+ if (/(создай|добавь|запланируй).{0,40}(встреч|событи|календар|телемост)/iu.test(normalized)) {
11454
+ const dateTime = extractDateTimeFromText(question);
11455
+ const query = cleanupYandexContactActionQuery(question);
11456
+ const result = /телемост/iu.test(normalized)
11457
+ ? await yandexContactCreateTelemostEvent({ query, ...dateTime, confirm: true })
11458
+ : await yandexContactCreateCalendarEvent({ query, ...dateTime, confirm: true });
11459
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11460
+ }
10386
11461
  if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
10387
11462
  const result = await yandexContactsStatus();
10388
11463
  return `Яндекс Контакты подключены: ${result.displayName || result.url}.`;
@@ -10400,6 +11475,212 @@ async function buildYandexDirectAnswer(question, history = []) {
10400
11475
  return "";
10401
11476
  }
10402
11477
 
11478
+ async function buildYandexContactsDirectAnswer(question, normalized = "") {
11479
+ const text = normalized || String(question || "").toLocaleLowerCase("ru-RU");
11480
+ if (/(авто|автомат|регуляр|кажд|период|обслужив|проверяй|проверку)/iu.test(text) && /(контакт)/iu.test(text) && /(включ|запусти|начни|поставь|создай)/iu.test(text)) {
11481
+ const days = Number(String(question || "").match(/(\d+)\s*(?:дн|день|дня|дней)/iu)?.[1] || 7);
11482
+ const result = await yandexContactsMaintenanceEnable(days, { backup: /(backup|бэкап|резерв|диск)/iu.test(text) });
11483
+ return `Регулярная проверка контактов включена: каждые ${result.days} дней. Backup на Диск: ${result.backup ? "yes" : "no"}.`;
11484
+ }
11485
+ if (/(авто|автомат|регуляр|кажд|период|обслужив|проверяй|проверку)/iu.test(text) && /(контакт)/iu.test(text) && /(выключ|отключ|останов|убери)/iu.test(text)) {
11486
+ await yandexContactsMaintenanceDisable();
11487
+ return "Регулярная проверка контактов выключена.";
11488
+ }
11489
+ if (/(обслужив|провер|диагност|doctor)/iu.test(text) && /(контакт)/iu.test(text) && /(сейчас|запусти|сделай|проверь)/iu.test(text)) {
11490
+ const result = await yandexContactsMaintenanceTick({ force: true, backup: /(backup|бэкап|резерв)/iu.test(text) });
11491
+ return [
11492
+ "Проверка контактов выполнена:",
11493
+ `Всего: ${result.total}`,
11494
+ `Неполных: ${result.incomplete}`,
11495
+ `Групп дубликатов: ${result.duplicateGroups}`,
11496
+ result.backupRemote ? `Backup: ${result.backupRemote}` : "",
11497
+ ].filter(Boolean).join("\n");
11498
+ }
11499
+ if (/(дубликат|повтор)/iu.test(text)) {
11500
+ const rows = await yandexContactsFindDuplicates({ limit: 20 });
11501
+ if (!rows.length) return "Дубликаты контактов не найдены.";
11502
+ return rows.map((row) => formatToolResult({ rows: [row], outputs: [] }, {})).join("\n");
11503
+ }
11504
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(text)) {
11505
+ const field = /без\s+(?:email|почт)/iu.test(text) ? "email" : /без\s+телефон/iu.test(text) ? "phone" : /без\s+адрес/iu.test(text) ? "address" : "";
11506
+ const rows = await yandexContactsFindIncomplete({ field, limit: 30 });
11507
+ if (!rows.length) return "Неполные контакты по этому признаку не найдены.";
11508
+ return ["Неполные контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
11509
+ }
11510
+ if (/(экспорт|выгруз|сохрани|резерв|backup|бэкап)/iu.test(text) && /(диск|яндекс.?диск|облак)/iu.test(text)) {
11511
+ const result = await yandexContactsBackupToDisk({ format: /csv/iu.test(text) ? "csv" : "vcard", confirm: true });
11512
+ return `Контакты сохранены на Яндекс Диск: ${result.remote}. Записей: ${result.rows}.`;
11513
+ }
11514
+ if (/(импорт|импортируй|загрузи).{0,40}контакт/iu.test(text)) {
11515
+ const inputPath = extractLocalInputPath(question) || question.match(/(?:из|файл)\s+([^\s]+(?:\.csv|\.vcf|\.vcard))/iu)?.[1] || "";
11516
+ if (!inputPath) return "Укажите файл CSV или vCard для импорта контактов.";
11517
+ const result = await yandexContactsImport(/\.csv$/iu.test(inputPath) ? "csv" : "vcard", { path: inputPath, confirm: true });
11518
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11519
+ }
11520
+ if (/(дн[еиь]\s+рожден|день\s+рождени|дней\s+рождени|birthday)/iu.test(text) && /(календар|событи|напомин)/iu.test(text)) {
11521
+ const result = await yandexContactsBirthdaysToCalendar({ confirm: true });
11522
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11523
+ }
11524
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(text) && /(школ|детск\w*\s+сад|детсад|садик|инн)/iu.test(text)) {
11525
+ const result = await yandexContactFromPublicEntity({ query: cleanupYandexContactActionQuery(question), confirm: true });
11526
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11527
+ }
11528
+ if (/(экспорт|выгруз)/iu.test(text)) {
11529
+ const result = await yandexContactsExport(/csv/iu.test(text) ? "csv" : "vcard", {});
11530
+ return `Контакты экспортированы: ${result.output}. Записей: ${result.rows}.`;
11531
+ }
11532
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(text)) {
11533
+ const draft = parseYandexContactCreateRequest(question);
11534
+ if (!draft.name || (!draft.email && !draft.phone)) return "Для создания контакта укажите имя и email или телефон.";
11535
+ const result = await yandexContactsCreate({ ...draft, confirm: true });
11536
+ return `Контакт создан: ${formatYandexContact(result)}.`;
11537
+ }
11538
+ if (/(удали|удалить).{0,40}контакт/iu.test(text)) {
11539
+ const query = extractEmailAddress(question) || String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || cleanupYandexContactActionQuery(question);
11540
+ const result = await yandexContactsDelete(query, { confirm: true });
11541
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11542
+ }
11543
+ if (/(добав|запиши|сохрани).{0,40}(email|e-mail|почт)/iu.test(text)) {
11544
+ const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
11545
+ const query = cleanupYandexContactEmailTarget(question, email);
11546
+ if (!email || !query) return "Укажите контакт и email. Пример: добавь email petrov@example.com к контакту Петров.";
11547
+ const result = await yandexContactsAddEmail(query, email, { confirm: true });
11548
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11549
+ }
11550
+ if (/(удали|удалить|убери).{0,40}(email|e-mail|почт)/iu.test(text)) {
11551
+ const email = String(question || "").match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
11552
+ const query = cleanupYandexContactEmailTarget(question, email) || cleanupYandexContactActionQuery(question);
11553
+ if (!query) return "Укажите контакт, у которого удалить email.";
11554
+ const result = await yandexContactsUpdate(query, { removeEmail: email || true, mode: "remove-email", confirm: true });
11555
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11556
+ }
11557
+ if (/(добав|запиши|сохрани).{0,40}(телефон|номер)/iu.test(text)) {
11558
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
11559
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
11560
+ if (!phone || !query) return "Укажите контакт и телефон.";
11561
+ const result = await yandexContactsUpdate(query, { phone, mode: "add-phone", confirm: true });
11562
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11563
+ }
11564
+ if (/(удали|удалить|убери).{0,40}(телефон|номер)/iu.test(text)) {
11565
+ const phone = String(question || "").match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
11566
+ const query = cleanupYandexContactActionQuery(question.replace(phone, " "));
11567
+ if (!query) return "Укажите контакт, у которого удалить телефон.";
11568
+ const result = await yandexContactsUpdate(query, { removePhone: phone || true, mode: "remove-phone", confirm: true });
11569
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11570
+ }
11571
+ if (/(переимен|измени\s+имя|смени\s+имя)/iu.test(text) && /(контакт)/iu.test(text)) {
11572
+ const newName = question.match(/(?:в|на|как)\s+["«]?([^"».,!?]+)["»]?\s*$/iu)?.[1]?.trim() || "";
11573
+ const query = cleanupYandexContactActionQuery(question.replace(newName, " "));
11574
+ if (!query || !newName) return "Укажите контакт и новое имя.";
11575
+ const result = await yandexContactsUpdate(query, { name: newName, confirm: true });
11576
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11577
+ }
11578
+ if (/(добав|запиши|сохрани).{0,40}(день\s+рожд|дат[ау]\s+рожд|birthday)/iu.test(text)) {
11579
+ const birthday = question.match(/(\d{1,2}[.\-/]\d{1,2}(?:[.\-/]\d{2,4})?|\d{4}-\d{2}-\d{2})/u)?.[1] || "";
11580
+ const query = cleanupYandexContactActionQuery(question.replace(birthday, " "));
11581
+ if (!birthday || !query) return "Укажите контакт и дату рождения.";
11582
+ const result = await yandexContactsUpdate(query, { birthday, mode: "add-birthday", confirm: true });
11583
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11584
+ }
11585
+ if (/(добав|запиши|сохрани).{0,40}(организац|должност)/iu.test(text)) {
11586
+ const org = question.match(/(?:организац\p{L}*|орг)\s*:?\s*(.*?)(?=\s+должност\p{L}*\s*:|$)/iu)?.[1]?.trim() || "";
11587
+ const title = question.match(/должност\p{L}*\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11588
+ const query = cleanupYandexContactActionQuery(question.replace(org, " ").replace(title, " "));
11589
+ if (!query || (!org && !title)) return "Укажите контакт и организацию или должность.";
11590
+ const result = await yandexContactsUpdate(query, { org, title, mode: "add-org", confirm: true });
11591
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11592
+ }
11593
+ if (/(добав|запиши|сохрани).{0,40}(адрес)/iu.test(text)) {
11594
+ const address = question.match(/(?:адрес|по адресу)\s*:?\s*(.+)$/iu)?.[1]?.trim() || "";
11595
+ const query = cleanupYandexContactActionQuery(question.replace(address, " "));
11596
+ if (!address || !query) return "Укажите контакт и адрес.";
11597
+ const result = await yandexContactsUpdate(query, { address, mode: "add-address", confirm: true });
11598
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11599
+ }
11600
+ if (/(добав|запиши|сохрани).{0,40}(заметк|коммент|примеч)/iu.test(text)) {
11601
+ const note = question.match(/(?:заметка|комментарий|примечание)\s*:\s*(.+)$/iu)?.[1]?.trim() || "";
11602
+ const query = cleanupYandexContactActionQuery(question.replace(note, " "));
11603
+ if (!note || !query) return "Укажите контакт и текст заметки.";
11604
+ const result = await yandexContactsUpdate(query, { note, mode: "add-note", confirm: true });
11605
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11606
+ }
11607
+ if (/(создай|сделай).{0,40}(папк).{0,40}(контакт)/iu.test(text)) {
11608
+ const query = cleanupYandexContactActionQuery(question);
11609
+ const result = await yandexContactCreateDiskFolder({ query, confirm: true });
11610
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11611
+ }
11612
+ if (/(отправь|пошли).{0,80}(ссылк|qr|qr-код|диск|яндекс.?диск)/iu.test(text)) {
11613
+ const remotePath = extractCloudPath(question);
11614
+ const contact = cleanupYandexContactActionQuery(question.replace(remotePath || "", " "));
11615
+ const result = await yandexContactSendDiskLinkQr({ contact, path: remotePath, confirm: true });
11616
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11617
+ }
11618
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(text)) {
11619
+ const draft = parseYandexMailSendRequest(question);
11620
+ const result = await yandexContactSendMail({ contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true });
11621
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11622
+ }
11623
+ if (/(создай|добавь|запланируй).{0,40}(встреч|событи|календар|телемост)/iu.test(text)) {
11624
+ const dateTime = extractDateTimeFromText(question);
11625
+ const query = cleanupYandexContactActionQuery(question);
11626
+ const result = /телемост/iu.test(text)
11627
+ ? await yandexContactCreateTelemostEvent({ query, ...dateTime, confirm: true })
11628
+ : await yandexContactCreateCalendarEvent({ query, ...dateTime, confirm: true });
11629
+ return formatToolResult({ rows: [result], outputs: [] }, {});
11630
+ }
11631
+ if (/(статус|проверь|работает|доступ)/iu.test(text)) {
11632
+ const result = await yandexContactsStatus();
11633
+ return `Яндекс Контакты подключены: ${result.displayName || result.url}.`;
11634
+ }
11635
+ const query = cleanupYandexQuery(question);
11636
+ const rows = /(найди|поиск|покажи|посмотри)/iu.test(text) && query
11637
+ ? await yandexContactsSearch(query, { limit: 10 })
11638
+ : await yandexContactsList({ limit: 20 });
11639
+ if (!rows.length) return "Контакты по запросу не найдены.";
11640
+ return ["Яндекс Контакты:", ...rows.map((row, index) => `${index + 1}. ${formatYandexContact(row)}`)].join("\n");
11641
+ }
11642
+
11643
+ async function buildUserSkillDirectAnswer(question) {
11644
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
11645
+ if (!/(skill|скилл|скил|навык)/iu.test(normalized)) return "";
11646
+ if (/(создай|добавь|сделай|create|new)/iu.test(normalized)) {
11647
+ const name = extractUserSkillNameFromQuestion(question) || "user-skill";
11648
+ const result = await userSkillCreate({
11649
+ name,
11650
+ description: extractUserSkillDescription(question, name),
11651
+ instructions: question,
11652
+ tools: inferUserSkillTools(question),
11653
+ enable: true,
11654
+ confirm: true,
11655
+ });
11656
+ return `Skill создан: ${result.name}\nФайл: ${result.file}\nSkill включен.`;
11657
+ }
11658
+ if (/(выключ|отключ|disable)/iu.test(normalized)) {
11659
+ const name = extractUserSkillNameFromQuestion(question);
11660
+ if (!name) return "Какой skill выключить? Укажите имя.";
11661
+ const result = await userSkillSetEnabled(name, false);
11662
+ return `Skill ${result.name}: disabled`;
11663
+ }
11664
+ if (/(включ|enable)/iu.test(normalized)) {
11665
+ const name = extractUserSkillNameFromQuestion(question);
11666
+ if (!name) return "Какой skill включить? Укажите имя.";
11667
+ const result = await userSkillSetEnabled(name, true);
11668
+ return `Skill ${result.name}: enabled`;
11669
+ }
11670
+ if (/(удали|удалить|remove|delete)/iu.test(normalized)) {
11671
+ const name = extractUserSkillNameFromQuestion(question);
11672
+ if (!name) return "Какой skill удалить? Укажите имя.";
11673
+ const result = await userSkillDelete(name, { confirm: true });
11674
+ return `Skill удален: ${result.name}`;
11675
+ }
11676
+ if (/(список|покажи|какие|list)/iu.test(normalized)) {
11677
+ const skills = listSkills(await loadConfig()).filter((skill) => skill.source === "user");
11678
+ if (!skills.length) return "Пользовательских skills пока нет.";
11679
+ return ["Пользовательские skills:", ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)].join("\n");
11680
+ }
11681
+ return "";
11682
+ }
11683
+
10403
11684
  function isYandexServiceQuestion(normalized) {
10404
11685
  return /(яндекс|яндес|язндекс|язндекс|яндкс|yandex|почт|письм|календар|контакт|телемост|спам|чернов|отправлен|исходящ|корзин)/iu.test(String(normalized || ""));
10405
11686
  }
@@ -10485,6 +11766,13 @@ function isYandexMailReadRequest(normalizedQuestion) {
10485
11766
  || /(покажи\s+содерж|о чем|о чём|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(normalizedQuestion);
10486
11767
  }
10487
11768
 
11769
+ function isExplicitYandexDiskPathDelete(question) {
11770
+ const text = String(question || "");
11771
+ return /(удали|удалить|перемести\s+в\s+корзин)/iu.test(text)
11772
+ && /(яндекс.?диск|диск|облак|\/IOLA\/)/iu.test(text)
11773
+ && Boolean(extractCloudPath(text));
11774
+ }
11775
+
10488
11776
  function parseYandexMailReplyRequest(question, previousAssistantText = "") {
10489
11777
  const text = String(question || "").replace(/\s+/g, " ").trim();
10490
11778
  const uid = resolveYandexMailUidFromQuestion(text, previousAssistantText);
@@ -10542,6 +11830,49 @@ function cleanupYandexContactEmailTarget(question, email) {
10542
11830
  .trim();
10543
11831
  }
10544
11832
 
11833
+ function parseYandexContactCreateRequest(question) {
11834
+ const text = String(question || "").replace(/\s+/g, " ").trim();
11835
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
11836
+ const phone = text.match(/(?:\+?\d[\d\s()\-]{6,}\d)/u)?.[0]?.trim() || "";
11837
+ const nameMatch = text.match(/(?:контакт|контакта)\s+["«]?([^"»:,]+)["»]?/iu)
11838
+ || text.match(/(?:создай|добавь|запиши|сохрани)\s+["«]?([^"»:,]+?)["»]?\s+(?:контакт|email|телефон|номер|почт)/iu);
11839
+ const address = text.match(/(?:адрес|по адресу)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|организация|должность)\s*:|$)/iu)?.[1]?.trim() || "";
11840
+ const note = text.match(/(?:заметка|примечание)\s*:?\s*(.*)$/iu)?.[1]?.trim() || "";
11841
+ const org = text.match(/(?:организация|орг)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|должность)\s*:|$)/iu)?.[1]?.trim() || "";
11842
+ const title = text.match(/(?:должность)\s*:?\s*(.*?)(?=\s+(?:заметка|телефон|email|почта|организация)\s*:|$)/iu)?.[1]?.trim() || "";
11843
+ const rawName = (nameMatch?.[1] || text.replace(email, " ").replace(phone, " "))
11844
+ .replace(/\b(?:email|e-mail|почта|телефон|номер|адрес|заметка|организация|должность)\b[\s\S]*$/iu, "")
11845
+ .trim();
11846
+ return {
11847
+ name: cleanupYandexContactQuery(rawName),
11848
+ email,
11849
+ phone,
11850
+ address,
11851
+ note,
11852
+ org,
11853
+ title,
11854
+ };
11855
+ }
11856
+
11857
+ function cleanupYandexContactActionQuery(question) {
11858
+ const stopWords = new Set([
11859
+ "и", "к", "ко", "с", "со", "у", "для", "из", "на", "в", "во", "сегодня", "завтра", "послезавтра", "час", "часа", "часов", "день", "дня", "рождения", "рождение", "дату", "дата", "диске", "облаке", "контакт", "контакта", "контакту", "контактом", "адресная", "книга", "создай", "сделай",
11860
+ "добавь", "добавить", "запиши", "сохрани", "удали", "удалить", "папку", "папка", "отправь", "пошли",
11861
+ "напиши", "письмо", "сообщение", "ссылку", "ссылка", "qr", "qr-код", "диск", "яндекс", "телемост",
11862
+ "встречу", "встреча", "событие", "календарь", "телефон", "номер", "адрес", "заметка", "заметку", "комментарий",
11863
+ "примечание", "тема", "текст",
11864
+ ]);
11865
+ return String(question || "")
11866
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, " ")
11867
+ .replace(/(?:\+?\d[\d\s()\-]{6,}\d)/gu, " ")
11868
+ .replace(/\/[^\s"»]+/gu, " ")
11869
+ .replace(/[,:;.!?«»"()]+/gu, " ")
11870
+ .split(/\s+/u)
11871
+ .filter((token) => token && !stopWords.has(token.toLocaleLowerCase("ru-RU")))
11872
+ .join(" ")
11873
+ .trim();
11874
+ }
11875
+
10545
11876
  function formatYandexMailSummary(row) {
10546
11877
  return `#${row.uid} ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}${row.date ? `, ${row.date}` : ""}`;
10547
11878
  }
@@ -10636,6 +11967,64 @@ async function buildCloudDirectAnswer(question) {
10636
11967
  const normalized = String(question || "").toLocaleLowerCase("ru-RU");
10637
11968
  try {
10638
11969
  const provider = await getCloudProvider();
11970
+ if (provider === "yandex-disk" && /(мест[оа]|сколько.*занято|сколько.*свобод|инфо|статус)/iu.test(normalized)) {
11971
+ const info = await yandexDiskInfo();
11972
+ return [
11973
+ "Яндекс Диск:",
11974
+ `Занято: ${formatBytes(info.usedSpace)} из ${formatBytes(info.totalSpace)}.`,
11975
+ `Корзина: ${formatBytes(info.trashSize)}.`,
11976
+ ].join("\n");
11977
+ }
11978
+ if (provider === "yandex-disk" && /(перенеси|перемести|скопируй|копир).{0,80}(из|с).{0,30}папк/iu.test(normalized) && /(ссылк|qr|qr-код|почт|email|контакт|@)/iu.test(normalized)) {
11979
+ const packageRequest = parseYandexDiskPackageRequest(question);
11980
+ if (!packageRequest.sourcePath || !packageRequest.targetFolder) return "Укажите исходную и целевую папку на Яндекс Диске.";
11981
+ if (!packageRequest.email && !packageRequest.contact) return "Укажите email или контакт, кому отправить ссылку.";
11982
+ const result = await yandexDiskPackageShareEmail({
11983
+ ...packageRequest,
11984
+ confirm: true,
11985
+ });
11986
+ return [
11987
+ `${result.mode === "move" ? "Перенес" : "Скопировал"} объектов: ${result.transferred}.`,
11988
+ `Папка: ${result.targetFolder}`,
11989
+ `Отправил: ${result.to.join(", ")}.`,
11990
+ `Ссылка: ${result.publicUrl}`,
11991
+ `QR-код: ${result.qrPublicUrl}`,
11992
+ ].join("\n");
11993
+ }
11994
+ if (provider === "yandex-disk" && /(корзин|удаленн|удалённ)/iu.test(normalized) && /(покажи|список|что|есть)/iu.test(normalized)) {
11995
+ const rows = await yandexDiskTrashList("", { limit: 20 });
11996
+ if (!rows.length) return "Корзина Яндекс Диска пуста.";
11997
+ return ["Корзина Яндекс Диска:", ...rows.map((row, index) => `${index + 1}. ${row.type === "dir" ? "папка" : "файл"} ${row.name} — ${row.path}`)].join("\n");
11998
+ }
11999
+ if (provider === "yandex-disk" && /(есть\s+ли|существует|проверь).{0,40}(файл|папк|\/)/iu.test(normalized)) {
12000
+ const remotePath = extractCloudPath(question);
12001
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12002
+ const result = await yandexDiskExists(normalizeCloudUserPath(remotePath, provider));
12003
+ return result.exists ? `На Яндекс Диске найдено: ${result.path} (${result.type}).` : `На Яндекс Диске не найдено: ${result.path}.`;
12004
+ }
12005
+ if (provider === "yandex-disk" && /(свойств|карточк|метадан|размер|информац).{0,40}(файл|папк|\/)/iu.test(normalized)) {
12006
+ const remotePath = extractCloudPath(question);
12007
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12008
+ const result = await yandexDiskStat(normalizeCloudUserPath(remotePath, provider));
12009
+ return [
12010
+ `${result.type === "dir" ? "Папка" : "Файл"}: ${result.name}`,
12011
+ `Путь: ${result.path}`,
12012
+ `Размер: ${formatBytes(result.size)}`,
12013
+ result.modified ? `Изменен: ${result.modified}` : "",
12014
+ result.mimeType ? `Тип: ${result.mimeType}` : "",
12015
+ result.publicUrl ? `Публичная ссылка: ${result.publicUrl}` : "",
12016
+ ].filter(Boolean).join("\n");
12017
+ }
12018
+ if (provider === "yandex-disk" && /(очисти|очистить).{0,30}корзин/iu.test(normalized)) {
12019
+ const result = await yandexDiskEmptyTrash({ confirm: true });
12020
+ return `Очистка корзины запрошена: ${result.remote}.`;
12021
+ }
12022
+ if (provider === "yandex-disk" && /(восстанов|верни).{0,40}(корзин|диск|файл|папк)/iu.test(normalized)) {
12023
+ const remotePath = extractCloudPath(question);
12024
+ if (!remotePath) return "Укажите путь объекта в корзине Яндекс Диска.";
12025
+ const result = await yandexDiskRestore(normalizeCloudUserPath(remotePath, provider), { confirm: true });
12026
+ return `Восстановил из корзины: ${result.remote}.`;
12027
+ }
10639
12028
  if (/(созда|сдела|добав).{0,30}(папк|директор)/iu.test(normalized) || /(папк|директор).{0,30}(созда|сдела|добав)/iu.test(normalized)) {
10640
12029
  const folderName = extractCloudFolderName(question) || "Новая папка";
10641
12030
  const remotePath = normalizeCloudUserPath(folderName, provider);
@@ -10663,17 +12052,101 @@ async function buildCloudDirectAnswer(question) {
10663
12052
  ].join("\n");
10664
12053
  }
10665
12054
 
12055
+ if (provider === "yandex-disk" && /(сними|убери|закрой).{0,30}(ссылк|публик)/iu.test(normalized)) {
12056
+ const remotePath = extractCloudPath(question);
12057
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12058
+ const result = await yandexDiskUnshare(normalizeCloudUserPath(remotePath, provider));
12059
+ return `Публичная ссылка снята: ${result.remote}.`;
12060
+ }
12061
+
12062
+ if (provider === "yandex-disk" && /(отправь|отправить|пошли|перешли).{0,80}(ссылк|qr|qr-код|код)/iu.test(normalized) && /(почт|email|e-mail|контакт|@)/iu.test(normalized)) {
12063
+ const remotePath = extractCloudPath(question);
12064
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12065
+ const recipient = extractShareRecipient(question);
12066
+ if (!recipient.email && !recipient.contact) return "Укажите email или имя контакта, кому отправить ссылку.";
12067
+ const result = await yandexDiskShareEmail({
12068
+ remotePath: normalizeCloudUserPath(remotePath, provider),
12069
+ to: recipient.email,
12070
+ contact: recipient.contact,
12071
+ subject: extractMailSubject(question) || "",
12072
+ text: extractShareMessage(question) || "",
12073
+ confirm: true,
12074
+ });
12075
+ return [
12076
+ `Отправил ссылку на Яндекс Диск: ${result.to.join(", ")}.`,
12077
+ `Ссылка: ${result.publicUrl}`,
12078
+ `QR-код: ${result.qrPublicUrl}`,
12079
+ ].join("\n");
12080
+ }
12081
+
10666
12082
  if (/(ссылк|поделись|опубликуй)/iu.test(normalized)) {
10667
12083
  const remotePath = extractCloudPath(question);
10668
12084
  if (!remotePath) return "Укажите путь к файлу на облачном диске, например: /IOLA/reports/report.md";
10669
- const result = await cloudShare(provider, normalizeCloudUserPath(remotePath, provider));
10670
- return `Публичная ссылка: ${result.publicUrl}`;
12085
+ const withQr = provider === "yandex-disk" && /(qr|qr-код|код)/iu.test(normalized);
12086
+ const result = withQr
12087
+ ? await yandexDiskShareWithQr(normalizeCloudUserPath(remotePath, provider), { confirm: true })
12088
+ : await cloudShare(provider, normalizeCloudUserPath(remotePath, provider));
12089
+ return withQr
12090
+ ? `Публичная ссылка: ${result.publicUrl}\nQR-код: ${result.qrPublicUrl}`
12091
+ : `Публичная ссылка: ${result.publicUrl}`;
12092
+ }
12093
+
12094
+ if (provider === "yandex-disk" && /(прочитай|открой|покажи содержим|текст)/iu.test(normalized) && /(файл|\.txt|\.md|\.json|\.csv)/iu.test(normalized) && !/(сохрани|запиши)/iu.test(normalized)) {
12095
+ const remotePath = extractCloudPath(question);
12096
+ if (!remotePath) return "Укажите путь к текстовому файлу на Яндекс Диске.";
12097
+ const result = await yandexDiskReadText(normalizeCloudUserPath(remotePath, provider));
12098
+ return [`Файл ${result.remote}:`, result.text.slice(0, 4000)].join("\n");
12099
+ }
12100
+
12101
+ if (provider === "yandex-disk" && /(скачай|загрузи\s+с\s+диска|сохрани\s+на\s+комп)/iu.test(normalized)) {
12102
+ const remotePath = extractCloudPath(question);
12103
+ if (!remotePath) return "Укажите путь к файлу на Яндекс Диске.";
12104
+ const outputPath = extractLocalOutputPath(question) || path.basename(remotePath);
12105
+ const result = await yandexDiskDownload(normalizeCloudUserPath(remotePath, provider), outputPath);
12106
+ return `Скачал файл с Яндекс Диска: ${result.local}`;
12107
+ }
12108
+
12109
+ if (provider === "yandex-disk" && /(загрузи|отправь|положи).{0,40}(на яндекс.?диск|на диск|в облак)/iu.test(normalized)) {
12110
+ const localPath = extractLocalInputPath(question);
12111
+ if (!localPath) return "Укажите локальный путь к файлу для загрузки на Яндекс Диск.";
12112
+ const remotePath = extractCloudPath(question) || `${cloudRootForProvider(provider)}/${path.basename(localPath)}`;
12113
+ const result = await yandexDiskUpload(localPath, normalizeCloudUserPath(remotePath, provider), { overwrite: true });
12114
+ return `Загрузил файл на Яндекс Диск: ${result.remote}`;
12115
+ }
12116
+
12117
+ if (provider === "yandex-disk" && /(?:^|\s)(?:переименуй|переименовать|переименовать|rename)(?:\s|$)/iu.test(normalized)) {
12118
+ const remotePath = extractCloudPath(question);
12119
+ const newName = extractCloudNewName(question);
12120
+ if (!remotePath || !newName) return "Укажите путь и новое имя. Пример: переименуй /IOLA/a.txt в b.txt на Яндекс Диске.";
12121
+ const result = await yandexDiskRename(normalizeCloudUserPath(remotePath, provider), newName, { confirm: true, overwrite: true });
12122
+ return `Переименовал на Яндекс Диске: ${result.from} -> ${result.remote}`;
12123
+ }
12124
+
12125
+ if (provider === "yandex-disk" && /(перемести|move)/iu.test(normalized)) {
12126
+ const { from, to } = extractCloudTwoPaths(question);
12127
+ if (!from || !to) return "Укажите откуда и куда переместить на Яндекс Диске.";
12128
+ const result = await yandexDiskMove(normalizeCloudUserPath(from, provider), normalizeCloudUserPath(to, provider), { confirm: true, overwrite: true });
12129
+ return `Переместил на Яндекс Диске: ${result.from} -> ${result.remote}`;
12130
+ }
12131
+
12132
+ if (provider === "yandex-disk" && /(скопируй|копир|copy)/iu.test(normalized)) {
12133
+ const { from, to } = extractCloudTwoPaths(question);
12134
+ if (!from || !to) return "Укажите откуда и куда скопировать на Яндекс Диске.";
12135
+ const result = await yandexDiskCopy(normalizeCloudUserPath(from, provider), normalizeCloudUserPath(to, provider), { confirm: true, overwrite: true });
12136
+ return `Скопировал на Яндекс Диске: ${result.from} -> ${result.remote}`;
12137
+ }
12138
+
12139
+ if (provider === "yandex-disk" && /(удали|удалить|перемести.*корзин)/iu.test(normalized)) {
12140
+ const remotePath = extractCloudPath(question);
12141
+ if (!remotePath) return "Укажите путь к файлу или папке на Яндекс Диске.";
12142
+ const result = await yandexDiskDelete(normalizeCloudUserPath(remotePath, provider), { confirm: true, permanently: /навсегда|окончательно|безвозвратно/iu.test(normalized) });
12143
+ return `Удалил на Яндекс Диске: ${result.remote} (${result.status}).`;
10671
12144
  }
10672
12145
 
10673
12146
  if (/(сохрани|запиши).{0,40}(на яндекс диске|в облак|на диск)/iu.test(normalized)) {
10674
12147
  const text = cleanupCloudSaveText(question);
10675
12148
  if (!text) return "Что сохранить на облачный диск?";
10676
- const remotePath = `${cloudRootForProvider(provider)}/notes/iola-${timestampForFile()}.txt`;
12149
+ const remotePath = extractCloudPath(question) || `${cloudRootForProvider(provider)}/notes/iola-${timestampForFile()}.txt`;
10677
12150
  const tempPath = path.join(CONFIG_DIR, `cloud-save-${Date.now()}.txt`);
10678
12151
  await mkdir(CONFIG_DIR, { recursive: true });
10679
12152
  await writeFile(tempPath, text, "utf8");
@@ -10691,7 +12164,7 @@ async function buildCloudDirectAnswer(question) {
10691
12164
  }
10692
12165
 
10693
12166
  function isCloudQuestion(question) {
10694
- return /(яндекс.?диск|yandex.?disk|облак|облачн|на диск|с диска|в диск|cloud|mail\.?ru|публичн.*ссылк|поделиться.*файл)/iu.test(String(question || ""));
12167
+ return /(\/IOLA\/|яндекс.?диск|yandex.?disk|облак|облачн|на диск|с диска|в диск|cloud|mail\.?ru|публичн.*ссылк|поделиться.*файл|qr-код|qr\s+код)/iu.test(String(question || ""));
10695
12168
  }
10696
12169
 
10697
12170
  function normalizeCloudUserPath(value, provider = "yandex-disk") {
@@ -10714,16 +12187,112 @@ function extractCloudFolderName(question) {
10714
12187
 
10715
12188
  function extractCloudPath(question) {
10716
12189
  const text = String(question || "").trim();
10717
- const pathMatch = text.match(/(?:^|\s)(\/IOLA\/[^\s]+|\/[^\s]+)/iu);
10718
- if (pathMatch?.[1]) return pathMatch[1];
10719
12190
  const quoted = text.match(/["«]([^"»]+)["»]/u);
10720
- if (quoted?.[1]) return quoted[1];
12191
+ if (quoted?.[1]) return cleanupCloudPathCandidate(quoted[1]);
12192
+ const iolaPath = text.match(/(?:^|\s)(\/IOLA\/.+)$/iu)?.[1];
12193
+ if (iolaPath) return cleanupCloudPathCandidate(iolaPath);
12194
+ const pathMatch = text.match(/(?:^|\s)(\/IOLA\/[^\s]+|\/[^\s]+)/iu);
12195
+ if (pathMatch?.[1]) return cleanupCloudPathCandidate(pathMatch[1]);
10721
12196
  const afterFolder = text.match(/(?:папк[аеуы]?|файл[ае]?)\s+([^,.!?]+)/iu);
10722
12197
  return afterFolder?.[1] ? cleanupCloudObjectName(afterFolder[1]) : "";
10723
12198
  }
10724
12199
 
12200
+ function cleanupCloudPathCandidate(value) {
12201
+ return String(value || "")
12202
+ .replace(/\s+(?:по\s+почт[еуы]|на\s+почт[уые]|контакту|получател[юя]|кому|с\s+темой|тема\s*:|текст\s*:).*$/iu, "")
12203
+ .replace(/[.!?]+$/u, "")
12204
+ .trim();
12205
+ }
12206
+
12207
+ function extractCloudTwoPaths(question) {
12208
+ const text = String(question || "").trim();
12209
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]).filter(Boolean);
12210
+ if (quoted.length >= 2) return { from: quoted[0], to: quoted[1] };
12211
+ const paths = [...text.matchAll(/(?:^|\s)(\/[^\s,;]+)/gu)].map((match) => match[1]).filter(Boolean);
12212
+ if (paths.length >= 2) return { from: paths[0], to: paths[1] };
12213
+ const match = text.match(/(?:из|с|откуда)\s+(.+?)\s+(?:в|на|куда)\s+(.+?)(?:\s+на\s+яндекс|\s+на\s+диск|\s+в\s+облак|$)/iu)
12214
+ || text.match(/(?:перемести|скопируй|копируй|copy|move)\s+(.+?)\s+(?:в|на|куда)\s+(.+?)(?:\s+на\s+яндекс|\s+на\s+диск|\s+в\s+облак|$)/iu);
12215
+ return match ? { from: cleanupCloudObjectName(match[1]), to: cleanupCloudObjectName(match[2]) } : { from: "", to: "" };
12216
+ }
12217
+
12218
+ function extractCloudNewName(question) {
12219
+ const text = String(question || "").trim();
12220
+ return text.match(/(?:в|на|как)\s+["«]?([^"».,!?/\\]+(?:\.[a-z0-9а-яё]+)?)["»]?\s*(?:на\s+яндекс|на\s+диск|в\s+облак|$)/iu)?.[1]?.trim()
12221
+ || text.match(/(?:нов(?:ое|ый|ым)?\s+им(?:я|енем)|названи(?:е|ем))\s+["«]?([^"».,!?/\\]+)["»]?/iu)?.[1]?.trim()
12222
+ || "";
12223
+ }
12224
+
12225
+ function extractLocalInputPath(question) {
12226
+ const text = String(question || "").trim();
12227
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]).find((item) => /^[a-z]:[\\/]|\.{0,2}[\\/]/iu.test(item));
12228
+ if (quoted) return quoted;
12229
+ return text.match(/([a-z]:[\\/][^"»\s]+|\.\.?[\\/][^"»\s]+)/iu)?.[1] || "";
12230
+ }
12231
+
12232
+ function extractLocalOutputPath(question) {
12233
+ const text = String(question || "").trim();
12234
+ return text.match(/(?:в|на|как|куда)\s+([a-z]:[\\/][^"»\s]+|\.\.?[\\/][^"»\s]+)/iu)?.[1] || "";
12235
+ }
12236
+
12237
+ function extractShareRecipient(question) {
12238
+ const text = String(question || "");
12239
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0] || "";
12240
+ if (email) return { email, contact: "" };
12241
+ const contact = text.match(/(?:контакт[ау]?|получател[юя]|кому|для|почт[уе])\s+["«]?([^"».,;!?@]+)["»]?/iu)?.[1]
12242
+ || text.match(/(?:отправь|отправить|пошли|перешли)\s+([^"».,;!?@]+?)\s+(?:ссылк|qr|qr-код|код)/iu)?.[1]
12243
+ || "";
12244
+ return { email: "", contact: cleanupYandexContactQuery(contact) };
12245
+ }
12246
+
12247
+ function extractMailSubject(question) {
12248
+ return String(question || "").match(/(?:тема|subject)\s*:\s*(.*?)(?=\s+(?:текст|сообщение|body)\s*:|$)/iu)?.[1]?.trim() || "";
12249
+ }
12250
+
12251
+ function extractShareMessage(question) {
12252
+ return String(question || "").match(/(?:текст|сообщение|body)\s*:\s*(.*)$/iu)?.[1]?.trim() || "";
12253
+ }
12254
+
12255
+ function parseYandexDiskPackageRequest(question) {
12256
+ const text = String(question || "").trim();
12257
+ const quoted = [...text.matchAll(/["«]([^"»]+)["»]/gu)].map((match) => match[1]);
12258
+ const paths = [...text.matchAll(/(?:^|\s)(\/[^\s,;]+)/gu)].map((match) => match[1]);
12259
+ const sourcePath = text.match(/(?:из|с)\s+папк[иы]?\s+([^,;]+?)(?=\s+(?:создай|сделай|и|отправь|перешли|на\s+яндекс)|[,;]|$)/iu)?.[1]?.trim()
12260
+ || quoted[1]
12261
+ || paths[1]
12262
+ || "";
12263
+ const targetFolder = text.match(/(?:создай|сделай)\s+папк[уи]?\s+([^,;]+?)(?=\s+(?:на\s+яндекс|и|,|$)|[,;]|$)/iu)?.[1]?.trim()
12264
+ || text.match(/(?:в|куда|целев\w*)\s+папк[уи]?\s+([^,;]+?)(?=\s+(?:на\s+яндекс|и|,|$))/iu)?.[1]?.trim()
12265
+ || quoted[0]
12266
+ || paths[0]
12267
+ || "";
12268
+ const recipient = extractShareRecipient(question);
12269
+ return {
12270
+ sourcePath: normalizeCloudUserPath(cleanupCloudObjectName(sourcePath), "yandex-disk"),
12271
+ targetFolder: normalizeCloudUserPath(cleanupCloudObjectName(targetFolder), "yandex-disk"),
12272
+ mode: /(перенеси|перемести|move)/iu.test(text) ? "move" : "copy",
12273
+ email: recipient.email,
12274
+ contact: recipient.contact,
12275
+ subject: extractMailSubject(question),
12276
+ text: extractShareMessage(question),
12277
+ };
12278
+ }
12279
+
12280
+ function formatBytes(value) {
12281
+ const bytes = Number(value || 0);
12282
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 Б";
12283
+ const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
12284
+ let size = bytes;
12285
+ let index = 0;
12286
+ while (size >= 1024 && index < units.length - 1) {
12287
+ size /= 1024;
12288
+ index += 1;
12289
+ }
12290
+ return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
12291
+ }
12292
+
10725
12293
  function cleanupCloudObjectName(value) {
10726
12294
  return String(value || "")
12295
+ .replace(/\s+(?:на\s+яндекс.?диск(?:е)?|на\s+диск(?:е)?|в\s+облак(?:е|о)?).*/iu, " ")
10727
12296
  .replace(/\b(?:на|в|у меня|яндекс.?диск(?:е)?|диск(?:е)?|облак(?:е|о)?|создай|сделай|добавь|покажи)\b/giu, " ")
10728
12297
  .replace(/\s+/g, " ")
10729
12298
  .trim();
@@ -10731,7 +12300,8 @@ function cleanupCloudObjectName(value) {
10731
12300
 
10732
12301
  function cleanupCloudQuery(question) {
10733
12302
  return String(question || "")
10734
- .replace(/\b(?:найди|поиск|где лежит|на|в|яндекс.?диск(?:е)?|облак(?:е|о)?|диск(?:е)?|файл|документ)\b/giu, " ")
12303
+ .replace(/яндекс\s+диск(?:е)?/giu, " ")
12304
+ .replace(/(?:найди|поиск|где лежит|на|в|яндекс|облак(?:е|о)?|диск(?:е)?|файл|документ)/giu, " ")
10735
12305
  .replace(/[?.!]+$/u, "")
10736
12306
  .replace(/\s+/g, " ")
10737
12307
  .trim();
@@ -10740,6 +12310,7 @@ function cleanupCloudQuery(question) {
10740
12310
  function cleanupCloudSaveText(question) {
10741
12311
  return String(question || "")
10742
12312
  .replace(/^.*?(?:сохрани|запиши)\s+/iu, "")
12313
+ .replace(/\s+(?:в|на|как)\s+\/[^\s]+/giu, " ")
10743
12314
  .replace(/\s+(?:на яндекс диске|в облак[ео]|на диск).*$/iu, "")
10744
12315
  .trim();
10745
12316
  }
@@ -11027,16 +12598,28 @@ async function localToolAsk(question, providerConfig, options) {
11027
12598
  if (!options.quiet) console.log(casualAnswer);
11028
12599
  return casualAnswer;
11029
12600
  }
11030
- const yandexAnswer = await buildYandexDirectAnswer(question, []);
11031
- if (yandexAnswer) {
11032
- if (!options.quiet) console.log(yandexAnswer);
11033
- return yandexAnswer;
12601
+ const userSkillAnswer = await buildUserSkillDirectAnswer(question);
12602
+ if (userSkillAnswer) {
12603
+ if (!options.quiet) console.log(userSkillAnswer);
12604
+ return userSkillAnswer;
12605
+ }
12606
+ if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
12607
+ const yandexContactAnswer = await buildYandexDirectAnswer(question, []);
12608
+ if (yandexContactAnswer) {
12609
+ if (!options.quiet) console.log(yandexContactAnswer);
12610
+ return yandexContactAnswer;
12611
+ }
11034
12612
  }
11035
12613
  const cloudAnswer = await buildCloudDirectAnswer(question);
11036
12614
  if (cloudAnswer) {
11037
12615
  if (!options.quiet) console.log(cloudAnswer);
11038
12616
  return cloudAnswer;
11039
12617
  }
12618
+ const yandexAnswer = await buildYandexDirectAnswer(question, []);
12619
+ if (yandexAnswer) {
12620
+ if (!options.quiet) console.log(yandexAnswer);
12621
+ return yandexAnswer;
12622
+ }
11040
12623
  const geoAnswer = await buildGeoDirectAnswer(question);
11041
12624
  if (geoAnswer) {
11042
12625
  if (!options.quiet) console.log(geoAnswer);
@@ -11131,6 +12714,9 @@ function isUnsupportedPublicEntityQuestion(normalized) {
11131
12714
 
11132
12715
  function buildCasualDirectAnswer(question) {
11133
12716
  const normalized = String(question || "").toLocaleLowerCase("ru-RU").trim();
12717
+ if (isCurrentDateTimeQuestion(normalized)) {
12718
+ return formatCurrentDateTimeAnswer(normalized);
12719
+ }
11134
12720
  if (/^(кто ты|что ты|какая ты модель|что ты за модель|что за модель|какая модель|назови модель|ты какая модель|ты кто)([?.!\s]*)$/iu.test(normalized)) {
11135
12721
  return "Я IOLA, первая городская модель искусственного интеллекта Йошкар-Олы. Работаю локально в CLI и отвечаю по открытым городским данным через проверяемые слои и API.";
11136
12722
  }
@@ -11240,8 +12826,9 @@ async function buildLocalToolPlan(question, providerConfig, options) {
11240
12826
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
11241
12827
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
11242
12828
  "Минимальные 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.",
12829
+ "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_list {start,end}, yandex_calendar_create_event {title,start,end,location,attendees,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}.",
12830
+ "Опасные 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_telemost_create_event.",
12831
+ "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
12832
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
11246
12833
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
11247
12834
  `Вопрос: ${question}`,
@@ -11299,14 +12886,48 @@ function parseJsonObject(text) {
11299
12886
 
11300
12887
  function inferToolPlan(question, options = {}) {
11301
12888
  const normalized = question.toLocaleLowerCase("ru-RU");
12889
+ if (isCurrentDateTimeQuestion(normalized)) {
12890
+ return { steps: [{ tool: "get_current_date", args: {} }] };
12891
+ }
12892
+ if (/(создай|добавь|сделай).{0,40}(skill|скилл|скил|навык)/iu.test(normalized)) {
12893
+ const name = normalizeUserSkillName(
12894
+ question.match(/(?:skill|скилл|скил|навык)\s+["«]?([^"».,:;\n]+)["»]?/iu)?.[1]
12895
+ || question.match(/(?:создай|добавь|сделай)\s+["«]?([^"».,:;\n]+?)["»]?\s+(?:skill|скилл|скил|навык)/iu)?.[1]
12896
+ || "user-skill"
12897
+ );
12898
+ return {
12899
+ steps: [{
12900
+ tool: "user_skill_create",
12901
+ args: {
12902
+ name,
12903
+ description: `Пользовательский skill: ${name}`,
12904
+ instructions: question,
12905
+ enable: true,
12906
+ confirm: true,
12907
+ },
12908
+ }],
12909
+ };
12910
+ }
11302
12911
  if (/(яндекс|yandex)/iu.test(normalized) && /(аккаунт|профил|логин|почт[аы]|email|e-mail|кто подключен)/iu.test(normalized)) {
11303
12912
  return { steps: [{ tool: "yandex_identity_me", args: {} }] };
11304
12913
  }
11305
12914
  if (/(яндекс|диск|облак)/iu.test(normalized)) {
12915
+ const diskPath = extractCloudPath(question) || CLOUD_DEFAULT_REMOTE_DIR;
12916
+ if (/(мест[оа]|сколько.*занято|сколько.*свобод|статус|инфо)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_info", args: {} }] };
12917
+ if (/(корзин|удаленн|удалённ)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_trash_list", args: { limit: 20 } }] };
11306
12918
  if (/(создай|сделай).{0,30}папк/iu.test(normalized)) {
11307
12919
  const folder = question.match(/папк[ауи]?\s+["«]?([^"»\n]+)["»]?/iu)?.[1]?.trim() || "Новая папка";
11308
12920
  return { steps: [{ tool: "yandex_disk_mkdir", args: { path: `${CLOUD_DEFAULT_REMOTE_DIR}/${folder}` } }] };
11309
12921
  }
12922
+ if (/(прочитай|открой|содержим|текст)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_read_text", args: { path: diskPath } }] };
12923
+ if (/(скачай|download)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_download", args: { remotePath: diskPath, outputPath: path.basename(diskPath) } }] };
12924
+ if (/(ссылк|поделись|опубликуй)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_share", args: { path: diskPath, confirm: true } }] };
12925
+ if (/(удали|удалить)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_delete", args: { path: diskPath, confirm: true } }] };
12926
+ if (/(переимен|rename)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_rename", args: { path: diskPath, name: extractCloudNewName(question), confirm: true } }] };
12927
+ if (/(перемести|move|скопируй|копир|copy)/iu.test(normalized)) {
12928
+ const { from, to } = extractCloudTwoPaths(question);
12929
+ return { steps: [{ tool: /(скопируй|копир|copy)/iu.test(normalized) ? "yandex_disk_copy" : "yandex_disk_move", args: { from, to, confirm: true, overwrite: true } }] };
12930
+ }
11310
12931
  if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_find", args: { query: question, path: CLOUD_DEFAULT_REMOTE_DIR, limit: 20 } }] };
11311
12932
  return { steps: [{ tool: "yandex_disk_ls", args: { path: CLOUD_DEFAULT_REMOTE_DIR } }] };
11312
12933
  }
@@ -11325,6 +12946,18 @@ function inferToolPlan(question, options = {}) {
11325
12946
  return { steps: [{ tool: normalized.includes("телемост") ? "yandex_telemost_create_event" : "yandex_calendar_list", args: { limit: 20 } }] };
11326
12947
  }
11327
12948
  if (/(контакт|адресн)/iu.test(normalized)) {
12949
+ if (/(дубликат|повтор)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_find_duplicates", args: { limit: 20 } }] };
12950
+ if (/(неполн|без\s+email|без\s+почт|без\s+телефон|без\s+адрес)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_find_incomplete", args: { limit: 30 } }] };
12951
+ if (/(экспорт|выгруз)/iu.test(normalized)) return { steps: [{ tool: /csv/iu.test(normalized) ? "yandex_contacts_export_csv" : "yandex_contacts_export_vcard", args: {} }] };
12952
+ 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 } }] };
12953
+ if (/(создай|добавь|запиши|сохрани)\s+контакт/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_create", args: { ...parseYandexContactCreateRequest(question), confirm: true } }] };
12954
+ if (/(удали|удалить)/iu.test(normalized)) return { steps: [{ tool: "yandex_contacts_delete", args: { query: cleanupYandexContactActionQuery(question), confirm: true } }] };
12955
+ 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 } }] };
12956
+ if (/(отправь|пошли|напиши).{0,40}(письм|сообщ)/iu.test(normalized)) {
12957
+ const draft = parseYandexMailSendRequest(question);
12958
+ return { steps: [{ tool: "yandex_contact_send_mail", args: { contact: draft.contactQuery || cleanupYandexContactActionQuery(question), subject: draft.subject, text: draft.text, confirm: true } }] };
12959
+ }
12960
+ 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
12961
  return { steps: [{ tool: "yandex_contacts_search", args: { query: question, limit: 20 } }] };
11329
12962
  }
11330
12963
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
@@ -11680,7 +13313,7 @@ function formatToolExecutionError(error, plan) {
11680
13313
  }
11681
13314
 
11682
13315
  function availableToolNames(options = {}) {
11683
- const names = new Set([...LOCAL_TOOLS, ...YANDEX_TOOLS]);
13316
+ const names = new Set([...LOCAL_TOOLS, ...YANDEX_TOOLS, ...USER_SKILL_TOOLS]);
11684
13317
  if (options.files) {
11685
13318
  for (const tool of FILE_TOOLS) names.add(tool);
11686
13319
  }
@@ -11749,6 +13382,10 @@ async function executeToolPlan(plan, options = {}) {
11749
13382
  const result = await executeYandexTool(step.tool, step.args || {});
11750
13383
  current = Array.isArray(result) ? result : [result];
11751
13384
  outputs.push({ tool: step.tool, rows: current.length });
13385
+ } else if (USER_SKILL_TOOLS.includes(step.tool)) {
13386
+ const result = await executeUserSkillTool(step.tool, step.args || {});
13387
+ current = Array.isArray(result) ? result : [result];
13388
+ outputs.push({ tool: step.tool, rows: current.length });
11752
13389
  } else if (String(step.tool || "").startsWith("mcp:")) {
11753
13390
  const result = await callConfiguredMcpTool(step.tool, step.args || {});
11754
13391
  current = Array.isArray(result) ? result : [result];
@@ -11789,14 +13426,34 @@ async function executeToolPlan(plan, options = {}) {
11789
13426
 
11790
13427
  function getCurrentDateInfo() {
11791
13428
  const now = new Date();
13429
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
11792
13430
  return {
11793
13431
  name: "текущая дата",
11794
13432
  date: new Intl.DateTimeFormat("ru-RU", { dateStyle: "long" }).format(now),
11795
13433
  time: new Intl.DateTimeFormat("ru-RU", { timeStyle: "short" }).format(now),
13434
+ weekday: new Intl.DateTimeFormat("ru-RU", { weekday: "long" }).format(now),
13435
+ timezone: timeZone,
11796
13436
  iso: now.toISOString(),
11797
13437
  };
11798
13438
  }
11799
13439
 
13440
+ function isCurrentDateTimeQuestion(normalized) {
13441
+ const text = String(normalized || "");
13442
+ return /^(?:какая|какой|какое|скажи|подскажи|что)\s+(?:сегодня\s+)?(?:дата|день|число|время|день недели)|^(?:сегодня|сейчас)\??$/iu.test(text)
13443
+ || /(?:какая\s+сегодня\s+дата|какой\s+сегодня\s+день|который\s+час|сколько\s+времени|текущее\s+время|текущая\s+дата|дата\s+сегодня)/iu.test(text);
13444
+ }
13445
+
13446
+ function formatCurrentDateTimeAnswer(normalized) {
13447
+ const info = getCurrentDateInfo();
13448
+ if (/(время|час|сейчас|сколько)/iu.test(normalized) && !/(дата|день|число)/iu.test(normalized)) {
13449
+ return `Сейчас ${info.time}. Часовой пояс: ${info.timezone}.`;
13450
+ }
13451
+ if (/(день недели)/iu.test(normalized) && !/(дата|число)/iu.test(normalized)) {
13452
+ return `Сегодня ${info.weekday}.`;
13453
+ }
13454
+ return `Сегодня ${info.date}, ${info.weekday}. Время: ${info.time}. Часовой пояс: ${info.timezone}.`;
13455
+ }
13456
+
11800
13457
  function getLocalMcpToolNames() {
11801
13458
  return mcpTools().map((tool) => `mcp:iola-local:${tool.name}`);
11802
13459
  }
@@ -11880,23 +13537,62 @@ function formatToolResult(result, options) {
11880
13537
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
11881
13538
  }
11882
13539
  if (row.date && row.time) return `Сегодня ${row.date}, ${row.time}.`;
13540
+ if (row.status === "contact-mail-sent") return `Письмо контакту отправлено: ${row.contact}. Тема: ${row.subject || "-"}`;
13541
+ if (row.status === "contact-disk-link-sent") return `Отправил контакту ${row.contact} ссылку на Яндекс Диск.\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
13542
+ if (row.status === "contact-folder-created") return `Папка контакта создана: ${row.remote}\nКарточка: ${row.cardPath}`;
13543
+ 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 || "-"}`;
13544
+ if (row.status === "contacts-backup") return `Контакты сохранены на Яндекс Диск: ${row.remote}. Записей: ${row.rows}.`;
13545
+ if (row.status === "contacts-imported") return `Контакты импортированы из ${row.input}. Создано: ${row.created}, пропущено: ${row.skipped}.`;
13546
+ if (row.status === "birthday-events-created") return `События дней рождения созданы: ${row.created}. Контактов с днем рождения: ${row.totalWithBirthday}.`;
11883
13547
  if (row.provider === "yandex-disk" && row.publicUrl) return `Публичная ссылка: ${row.publicUrl}`;
13548
+ if (row.provider === "yandex-disk" && row.status === "shared-with-qr") return `Публичная ссылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
13549
+ 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}`;
13550
+ 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}`;
13551
+ if (row.provider === "yandex-disk" && typeof row.exists === "boolean") return `Яндекс Диск: ${row.path || row.remote} ${row.exists ? "найден" : "не найден"}`;
13552
+ if (row.provider === "yandex-disk" && (row.status === "moved" || row.status === "copied" || row.status === "restored")) return `Яндекс Диск: ${row.status} ${row.from ? `${row.from} -> ` : ""}${row.remote}`;
13553
+ if (row.provider === "yandex-disk" && row.status === "unpublished") return `Яндекс Диск: публичная ссылка снята ${row.remote}`;
13554
+ if (row.provider === "yandex-disk" && row.status === "trash-empty-requested") return `Яндекс Диск: очистка корзины запрошена`;
13555
+ if (row.provider === "yandex-disk" && row.text) return `Яндекс Диск: ${row.remote}\n${String(row.text).slice(0, 2000)}`;
11884
13556
  if (row.provider === "yandex-disk" && row.remote) return `Яндекс Диск: ${row.status || "ok"} ${row.remote}`;
11885
13557
  if (row.uid && (row.subject || row.from)) return `Письмо #${row.uid}: ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`;
11886
13558
  if (row.status === "moved-to-trash") return `Письмо #${row.uid} перемещено в корзину: ${row.to}`;
11887
13559
  if (row.status === "seen" || row.status === "unseen") return `Письмо #${row.uid}: ${row.status === "seen" ? "прочитано" : "непрочитано"}`;
11888
13560
  if (row.status === "sent" && row.to) return `Письмо отправлено: ${Array.isArray(row.to) ? row.to.join(", ") : row.to}. Тема: ${row.subject || "-"}`;
13561
+ if (row.status === "contact-mail-sent") return `Письмо контакту отправлено: ${row.contact}. Тема: ${row.subject || "-"}`;
13562
+ if (row.status === "contact-disk-link-sent") return `Отправил контакту ${row.contact} ссылку на Яндекс Диск.\nСсылка: ${row.publicUrl}\nQR-код: ${row.qrPublicUrl}`;
13563
+ if (row.status === "contact-folder-created") return `Папка контакта создана: ${row.remote}\nКарточка: ${row.cardPath}`;
13564
+ 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 || "-"}`;
13565
+ if (row.status === "contacts-backup") return `Контакты сохранены на Яндекс Диск: ${row.remote}. Записей: ${row.rows}.`;
13566
+ if (row.status === "exported" && row.output) return `Контакты экспортированы: ${row.output}. Записей: ${row.rows}.`;
13567
+ if (row.status === "deleted" && (row.email || row.phone || row.name)) return `Контакт удален: ${formatYandexContact(row)}`;
13568
+ if (row.status === "updated" && (row.email || row.phone || row.name)) return `Контакт обновлен: ${formatYandexContact(row)}`;
13569
+ if (row.status === "created" && (row.email || row.phone || row.name)) return `Контакт создан: ${formatYandexContact(row)}`;
13570
+ if (row.status === "no-email" && row.contact) return `У контакта нет email: ${formatYandexContact(row.contact)}`;
13571
+ if (row.status === "ambiguous" && row.contacts) return [`Нашел несколько контактов. Уточните:`, ...row.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
13572
+ if (row.status === "not-found" && row.query !== undefined) return `Контакт не найден: ${row.query}`;
13573
+ if (row.status === "duplicate-group" && row.contacts) return [`Дубликаты (${row.key}):`, ...row.contacts.map((contact, index) => `${index + 1}. ${formatYandexContact(contact)}`)].join("\n");
13574
+ if (row.status === "created" && row.file && row.name) return `Skill создан: ${row.name}\nФайл: ${row.file}${row.enabled ? "\nSkill включен." : ""}`;
13575
+ if ((row.status === "enabled" || row.status === "disabled") && row.name) return `Skill ${row.name}: ${row.status}`;
13576
+ if (row.status === "deleted" && row.name) return `Skill удален: ${row.name}`;
11889
13577
  if (row.special || row.delimiter) return `Папка почты: ${row.name}${row.special ? ` (${row.special})` : ""}`;
11890
13578
  if (row.login || row.defaultEmail) return `Yandex ID: ${row.displayName || row.login || "-"}${row.defaultEmail ? `, ${row.defaultEmail}` : ""}`;
11891
13579
  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);
13580
+ if (row.email || row.phone || row.emails || row.phones) return formatYandexContact(row);
11893
13581
  return `${row.name || row.check || row.inn || "строка"}: ${row.address || row.phone || row.email || row.website || row.count || ""}`;
11894
13582
  }).join("\n");
11895
13583
  }
11896
13584
 
11897
13585
  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(", ");
13586
+ const emails = row.emails?.length ? row.emails : (row.email ? [row.email] : []);
13587
+ const phones = row.phones?.length ? row.phones : (row.phone ? [row.phone] : []);
13588
+ const name = row.name && row.name !== emails[0] ? row.name : "";
13589
+ return [
13590
+ name || emails[0] || phones[0] || "Контакт",
13591
+ emails.length ? `email: ${emails.join(", ")}` : "",
13592
+ phones.length ? `тел: ${phones.join(", ")}` : "",
13593
+ row.org ? `орг: ${row.org}` : "",
13594
+ row.address ? `адрес: ${row.address}` : "",
13595
+ ].filter(Boolean).join(", ");
11900
13596
  }
11901
13597
 
11902
13598
  function applyRuntimeConfig(target, value) {
@@ -12242,8 +13938,11 @@ async function buildAiMessages(question, dataContext, history, options = {}, con
12242
13938
  const projectContext = options.bare ? "" : await buildProjectContextText();
12243
13939
  const skillsText = options.bare ? "" : await buildSkillsText(config, question, options);
12244
13940
  const hasDataContext = dataContext.enabled !== false;
13941
+ const currentDate = getCurrentDateInfo();
12245
13942
  const system = [
12246
13943
  "Ты терминальный AI-агент городского округа Йошкар-Ола.",
13944
+ `Текущие дата и время CLI: ${currentDate.date}, ${currentDate.weekday}, ${currentDate.time}; часовой пояс: ${currentDate.timezone}; ISO: ${currentDate.iso}.`,
13945
+ "Если пользователь спрашивает про сегодня, завтра, вчера, текущую дату, время или относительные сроки, опирайся на текущие дату и время CLI.",
12247
13946
  "Отвечай на русском языке естественно и по смыслу запроса пользователя.",
12248
13947
  "Не смешивай языки. Не выдумывай факты, географию и числа.",
12249
13948
  "Если пользователь просто здоровается, ответь коротким приветствием и спроси, чем помочь.",
@@ -13395,12 +15094,12 @@ function parseOptions(args) {
13395
15094
 
13396
15095
  for (let index = 0; index < args.length; index += 1) {
13397
15096
  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") {
15097
+ 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
15098
  result[arg.slice(2)] = true;
13400
15099
  } else if (arg === "--check" || arg === "--upgrade-node") {
13401
15100
  result.check = true;
13402
15101
  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") {
15102
+ } 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
15103
  result[arg.slice(2)] = args[index + 1];
13405
15104
  index += 1;
13406
15105
  } else {
@@ -13607,6 +15306,209 @@ function findSkill(name, config) {
13607
15306
  return listSkills(config).find((skill) => skill.name === name);
13608
15307
  }
13609
15308
 
15309
+ function printSkillsList(skills, config) {
15310
+ if (!skills.length) {
15311
+ console.log("Нет данных.");
15312
+ return;
15313
+ }
15314
+ const nameWidth = Math.min(28, Math.max(5, ...skills.map((skill) => visibleLength(skill.name))));
15315
+ console.log(`${padCell("Вкл", 3)} ${padCell("Skill", nameWidth)} Описание`);
15316
+ console.log(`${"-".repeat(3)} ${"-".repeat(nameWidth)} ${"-".repeat(8)}`);
15317
+ for (const skill of skills) {
15318
+ const enabled = isSkillEnabled(config, skill.name) ? "yes" : "no";
15319
+ console.log(`${padCell(enabled, 3)} ${padCell(skill.name, nameWidth)} ${skill.description || "-"}`);
15320
+ }
15321
+ }
15322
+
15323
+ async function executeUserSkillTool(tool, args = {}) {
15324
+ if (tool === "user_skill_list") return listSkills(await loadConfig()).filter((skill) => skill.source === "user");
15325
+ if (tool === "user_skill_create") return userSkillCreate({ ...args, confirm: args.confirm === true });
15326
+ if (tool === "user_skill_enable") return userSkillSetEnabled(args.name, true);
15327
+ if (tool === "user_skill_disable") return userSkillSetEnabled(args.name, false);
15328
+ if (tool === "user_skill_delete") return userSkillDelete(args.name, { confirm: args.confirm === true });
15329
+ throw new Error(`Неизвестный user skill tool: ${tool}`);
15330
+ }
15331
+
15332
+ async function userSkillCreate(args = {}) {
15333
+ if (!args.confirm) throw new Error("Для создания пользовательского skill нужен аргумент confirm=true.");
15334
+ const name = normalizeUserSkillName(args.name || args.skill || args.title);
15335
+ if (!name) throw new Error("Имя skill обязательно.");
15336
+ const description = String(args.description || `Пользовательский skill: ${name}`).trim();
15337
+ const instructions = String(args.instructions || args.text || args.prompt || "").trim();
15338
+ if (!instructions) throw new Error("Инструкции skill обязательны.");
15339
+ const tools = parseCommaList(args.tools || args.allowedTools || args.allowed_tools || args.uses || "");
15340
+ const dir = path.join(USER_SKILLS_DIR, name);
15341
+ const file = path.join(dir, "SKILL.md");
15342
+ if (existsSync(file) && !args.overwrite) throw new Error(`Skill уже существует: ${name}. Используйте overwrite=true или --force.`);
15343
+ await mkdir(dir, { recursive: true });
15344
+ const body = buildUserSkillMarkdown({ name, description, instructions, tools });
15345
+ await writeFile(file, body, "utf8");
15346
+ let enabled = false;
15347
+ if (args.enable) {
15348
+ await userSkillSetEnabled(name, true);
15349
+ enabled = true;
15350
+ }
15351
+ return { name, description, file, enabled, tools, status: "created" };
15352
+ }
15353
+
15354
+ async function userSkillSetEnabled(name, enabled) {
15355
+ const skillName = normalizeUserSkillName(name);
15356
+ if (!skillName) throw new Error("Имя skill обязательно.");
15357
+ const config = await loadConfig();
15358
+ const skill = findSkill(skillName, config);
15359
+ if (!skill && enabled) throw new Error(`Skill не найден: ${skillName}`);
15360
+ const enabledSet = new Set(config.skills?.enabled || []);
15361
+ if (enabled) enabledSet.add(skillName);
15362
+ else enabledSet.delete(skillName);
15363
+ await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabledSet] } });
15364
+ return { name: skillName, enabled, status: enabled ? "enabled" : "disabled" };
15365
+ }
15366
+
15367
+ async function userSkillDelete(name, options = {}) {
15368
+ if (!options.confirm) throw new Error("Для удаления пользовательского skill нужен --yes или confirm=true.");
15369
+ const skillName = normalizeUserSkillName(name);
15370
+ if (!skillName) throw new Error("Имя skill обязательно.");
15371
+ const file = path.join(USER_SKILLS_DIR, skillName, "SKILL.md");
15372
+ const dir = path.dirname(file);
15373
+ if (!existsSync(file)) throw new Error(`Пользовательский skill не найден: ${skillName}`);
15374
+ await rm(dir, { recursive: true, force: true });
15375
+ await userSkillSetEnabled(skillName, false);
15376
+ return { name: skillName, status: "deleted" };
15377
+ }
15378
+
15379
+ function normalizeUserSkillName(value) {
15380
+ return String(value || "")
15381
+ .toLocaleLowerCase("ru-RU")
15382
+ .replace(/[^a-z0-9а-яё_-]+/giu, "-")
15383
+ .replace(/^-+|-+$/gu, "")
15384
+ .slice(0, 80);
15385
+ }
15386
+
15387
+ function parseCommaList(value) {
15388
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
15389
+ return String(value || "")
15390
+ .split(/[,\n;]/u)
15391
+ .map((item) => item.trim())
15392
+ .filter(Boolean);
15393
+ }
15394
+
15395
+ function extractUserSkillNameFromQuestion(question) {
15396
+ const text = String(question || "");
15397
+ const quoted = text.match(/[«"]([^»"]+)[»"]/u)?.[1];
15398
+ if (quoted) return normalizeUserSkillName(quoted);
15399
+ const explicit = text.match(/(?:skill|скилл|скил|навык)\s+([a-z0-9а-яё_-]{3,80})/iu)?.[1]
15400
+ || text.match(/(?:создай|добавь|сделай|включи|выключи|удали|удалить)\s+([a-z0-9а-яё_-]{3,80})\s+(?:skill|скилл|скил|навык)/iu)?.[1];
15401
+ return normalizeUserSkillName(explicit || "");
15402
+ }
15403
+
15404
+ function extractUserSkillDescription(question, name) {
15405
+ const text = String(question || "").replace(/\s+/g, " ").trim();
15406
+ const afterColon = text.match(/[:\-]\s*(.+)$/u)?.[1];
15407
+ return (afterColon || `Пользовательский skill: ${name}`).slice(0, 180);
15408
+ }
15409
+
15410
+ function inferUserSkillTools(question) {
15411
+ const text = String(question || "").toLocaleLowerCase("ru-RU");
15412
+ const tools = new Set();
15413
+ if (/(почт|письм|email|e-mail)/iu.test(text)) {
15414
+ tools.add("yandex_mail_list");
15415
+ tools.add("yandex_mail_search");
15416
+ tools.add("yandex_mail_read");
15417
+ }
15418
+ if (/(отправь|отправить|отправляй|ответь|ответить|перешли|переслать|удали|удалить)/iu.test(text) && /(почт|письм|email|e-mail)/iu.test(text)) {
15419
+ tools.add("yandex_mail_send");
15420
+ tools.add("yandex_mail_reply");
15421
+ tools.add("yandex_mail_forward");
15422
+ tools.add("yandex_mail_delete");
15423
+ }
15424
+ if (/(диск|облак|яндекс.?диск)/iu.test(text)) {
15425
+ tools.add("yandex_disk_info");
15426
+ tools.add("yandex_disk_ls");
15427
+ tools.add("yandex_disk_find");
15428
+ tools.add("yandex_disk_stat");
15429
+ tools.add("yandex_disk_exists");
15430
+ tools.add("yandex_disk_read_text");
15431
+ tools.add("yandex_disk_save_text");
15432
+ tools.add("yandex_disk_upload");
15433
+ tools.add("yandex_disk_download");
15434
+ tools.add("yandex_disk_share");
15435
+ tools.add("yandex_disk_unshare");
15436
+ tools.add("yandex_disk_move");
15437
+ tools.add("yandex_disk_copy");
15438
+ tools.add("yandex_disk_rename");
15439
+ tools.add("yandex_disk_delete");
15440
+ tools.add("yandex_disk_trash_list");
15441
+ tools.add("yandex_disk_restore");
15442
+ }
15443
+ if (/(календар|событи|встреч|телемост)/iu.test(text)) {
15444
+ tools.add("yandex_calendar_list");
15445
+ tools.add("yandex_calendar_create_event");
15446
+ tools.add("yandex_telemost_create_event");
15447
+ }
15448
+ if (/(контакт|адресн)/iu.test(text)) {
15449
+ tools.add("yandex_contacts_search");
15450
+ tools.add("yandex_contacts_get");
15451
+ tools.add("yandex_contacts_create");
15452
+ tools.add("yandex_contacts_update");
15453
+ tools.add("yandex_contacts_delete");
15454
+ tools.add("yandex_contacts_add_email");
15455
+ tools.add("yandex_contacts_add_phone");
15456
+ tools.add("yandex_contacts_add_address");
15457
+ tools.add("yandex_contacts_add_note");
15458
+ tools.add("yandex_contacts_add_birthday");
15459
+ tools.add("yandex_contacts_add_org");
15460
+ tools.add("yandex_contacts_export_csv");
15461
+ tools.add("yandex_contacts_find_incomplete");
15462
+ tools.add("yandex_contacts_find_duplicates");
15463
+ tools.add("yandex_contacts_backup_to_disk");
15464
+ tools.add("yandex_contact_send_mail");
15465
+ tools.add("yandex_contact_send_disk_link_qr");
15466
+ tools.add("yandex_contact_create_disk_folder");
15467
+ tools.add("yandex_contact_create_calendar_event");
15468
+ tools.add("yandex_contact_create_telemost_event");
15469
+ }
15470
+ if (/(файл|папк|документ|архив)/iu.test(text)) {
15471
+ tools.add("files_tree");
15472
+ tools.add("files_read");
15473
+ tools.add("files_search");
15474
+ }
15475
+ if (/(запиши|сохрани|создай файл|измени|исправ)/iu.test(text) && /(файл|папк|документ|архив)/iu.test(text)) {
15476
+ tools.add("files_write");
15477
+ tools.add("files_patch");
15478
+ }
15479
+ if (/(школ|сад|детсад|инн|адрес|телефон|открыт)/iu.test(text)) {
15480
+ tools.add("search_data");
15481
+ tools.add("get_card");
15482
+ tools.add("export_report");
15483
+ }
15484
+ if (/(сайт|страниц|браузер|url|ссылка)/iu.test(text)) tools.add("browser_open");
15485
+ return [...tools];
15486
+ }
15487
+
15488
+ function buildUserSkillMarkdown({ name, description, instructions, tools = [] }) {
15489
+ const toolLines = tools.length
15490
+ ? ["", "Разрешенные/ожидаемые tools для этого skill:", "", ...tools.map((tool) => `- \`${tool}\``)]
15491
+ : [];
15492
+ return [
15493
+ "---",
15494
+ `name: ${name}`,
15495
+ `description: ${description.replace(/\r?\n/g, " ")}`,
15496
+ "source: user",
15497
+ "---",
15498
+ "",
15499
+ instructions.trim(),
15500
+ "",
15501
+ "Правила безопасности:",
15502
+ "",
15503
+ "- Используй только встроенные tools `iola-cli` и подключенные MCP/Yandex/local-files механизмы.",
15504
+ "- Не выводи секреты, OAuth-токены, API-ключи и пароли.",
15505
+ "- Для записи, удаления, отправки писем, публикации ссылок и изменения внешних сервисов требуется явная просьба пользователя.",
15506
+ "- Если действие неоднозначно, сначала уточни у пользователя цель и место выполнения.",
15507
+ ...toolLines,
15508
+ "",
15509
+ ].join("\n");
15510
+ }
15511
+
13610
15512
  function readSkillMeta(file) {
13611
15513
  try {
13612
15514
  const text = readFileSyncUtf8(file);
@@ -15376,6 +17278,10 @@ function sanitizeConfig(config) {
15376
17278
  if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("local-files") && !next.skills.enabled.includes("personal-docs")) {
15377
17279
  next.skills.enabled = [...next.skills.enabled, "personal-docs"];
15378
17280
  }
17281
+ next.toolsets = next.toolsets || {};
17282
+ next.toolsets.enabled = [...new Set([...(next.toolsets.enabled || []), "user-skills"])];
17283
+ next.skills = next.skills || {};
17284
+ next.skills.enabled = [...new Set([...(next.skills.enabled || []), "user-skills"])];
15379
17285
  if (Array.isArray(next.yandex?.enabledServices)) {
15380
17286
  next.yandex.enabledServices = next.yandex.enabledServices.filter((service) => Boolean(YANDEX_CONNECTOR_SERVICES[service]));
15381
17287
  }