@iola_adm/iola-cli 0.1.22 → 0.1.24

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
@@ -1,22 +1,64 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdirSync } from "node:fs";
3
- import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { appendFile, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
  import readline from "node:readline/promises";
7
8
  import { stdin as input, stdout as output } from "node:process";
8
9
  import { DatabaseSync } from "node:sqlite";
10
+ import { fileURLToPath } from "node:url";
9
11
 
10
12
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
11
13
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
12
14
  const MIN_NODE_VERSION = "22.5.0";
13
15
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
14
16
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
+ const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
15
18
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
19
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
- const DB_SCHEMA_VERSION = 3;
20
+ const DB_SCHEMA_VERSION = 4;
18
21
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
19
22
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
23
+ const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const BUILTIN_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
26
+ const USER_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
27
+ const PROJECT_CONTEXT_FILE = path.join(process.cwd(), "IOLA.md");
28
+ const PROJECT_CONTEXT_DIR_FILE = path.join(process.cwd(), ".iola", "context.md");
29
+ const TOOLSETS = {
30
+ "data-read": {
31
+ description: "Чтение открытых данных и локальный поиск.",
32
+ permissions: { externalApi: true, localTools: { search_local: true, get_card: true, run_report: true } },
33
+ },
34
+ reports: {
35
+ description: "Отчеты, выгрузки и сохранение view.",
36
+ permissions: { writeFiles: true, localTools: { export_data: true, save_view: true, run_report: true } },
37
+ },
38
+ sync: {
39
+ description: "Обновление локальной копии данных из публичного API.",
40
+ permissions: { sync: true, externalApi: true },
41
+ },
42
+ ai: {
43
+ description: "Внешние AI-провайдеры и Codex CLI.",
44
+ permissions: { externalAi: true, codex: true },
45
+ },
46
+ safe: {
47
+ description: "Безопасный режим: чтение данных без записи файлов и без sync.",
48
+ permissions: { writeFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
49
+ },
50
+ full: {
51
+ description: "Полный локальный режим для доверенного пользователя.",
52
+ permissions: {
53
+ writeFiles: true,
54
+ sync: true,
55
+ externalApi: true,
56
+ externalAi: true,
57
+ codex: true,
58
+ localTools: Object.fromEntries(LOCAL_TOOLS.map((tool) => [tool, true])),
59
+ },
60
+ },
61
+ };
20
62
  const FEATURES = {
21
63
  "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
22
64
  sessions: { stage: "stable", defaultEnabled: true, description: "Сессии, resume и fork для AI-диалогов." },
@@ -74,8 +116,22 @@ const DEFAULT_AI_CONFIG = {
74
116
  externalAi: true,
75
117
  codex: true,
76
118
  },
119
+ toolsets: {
120
+ enabled: ["data-read", "reports", "sync", "ai"],
121
+ },
77
122
  memory: {
78
123
  enabled: true,
124
+ suggestions: true,
125
+ },
126
+ skills: {
127
+ enabled: ["open-data", "reports", "local-model"],
128
+ },
129
+ daemon: {
130
+ host: "127.0.0.1",
131
+ port: DAEMON_PORT,
132
+ },
133
+ cron: {
134
+ enabled: true,
79
135
  },
80
136
  hooks: {},
81
137
  };
@@ -154,6 +210,12 @@ const COMMANDS = new Map([
154
210
  ["fork", forkSession],
155
211
  ["features", handleFeatures],
156
212
  ["wiki", handleWiki],
213
+ ["context", handleContext],
214
+ ["skills", handleSkills],
215
+ ["tools", handleTools],
216
+ ["cron", handleCron],
217
+ ["daemon", handleDaemon],
218
+ ["rpc", handleRpc],
157
219
  ["permissions", handlePermissions],
158
220
  ["memory", handleMemory],
159
221
  ["hooks", handleHooks],
@@ -250,6 +312,12 @@ Usage:
250
312
  iola fork SESSION_ID [TEXT]
251
313
  iola features list|enable|disable
252
314
  iola wiki [open|links]
315
+ iola context list|show|init
316
+ iola skills list|show|paths|enable|disable
317
+ iola tools list|toolsets|enable|disable|profile
318
+ iola cron list|add|delete|run|tick
319
+ iola daemon start|status
320
+ iola rpc call METHOD [ARGS] [--json]
253
321
  iola permissions list|allow|deny
254
322
  iola memory show|add|set|clear|export
255
323
  iola hooks list|add|delete|run
@@ -270,6 +338,8 @@ Usage:
270
338
  iola alias add NAME COMMAND
271
339
  iola run "выгрузи школы на Петрова в csv"
272
340
  iola config get
341
+ iola config validate
342
+ iola config schema
273
343
  iola config set api.baseUrl URL
274
344
  iola config set api.mcpBaseUrl URL
275
345
  iola config reset
@@ -360,6 +430,7 @@ async function handleAgentLine(line, state) {
360
430
  }
361
431
 
362
432
  const [command, ...args] = splitCommandLine(line.slice(1));
433
+ state.lastCommand = { command, args };
363
434
 
364
435
  if (command === "exit" || command === "quit") {
365
436
  return true;
@@ -376,6 +447,40 @@ async function handleAgentLine(line, state) {
376
447
  return false;
377
448
  }
378
449
 
450
+ if (command === "new" || command === "reset") {
451
+ state.history = [];
452
+ console.log("Начата новая agent-сессия.");
453
+ return false;
454
+ }
455
+
456
+ if (command === "undo") {
457
+ state.history.splice(Math.max(0, state.history.length - 2), 2);
458
+ console.log("Последний обмен удален из agent-истории.");
459
+ return false;
460
+ }
461
+
462
+ if (command === "retry") {
463
+ const lastUser = [...state.history].reverse().find((item) => item.role === "user");
464
+ if (!lastUser) {
465
+ console.log("Нет предыдущего вопроса для повтора.");
466
+ return false;
467
+ }
468
+ const answer = await aiAsk([lastUser.content], { history: state.history.slice(0, -2) });
469
+ state.history.push({ role: "assistant", content: answer });
470
+ return false;
471
+ }
472
+
473
+ if (command === "compact") {
474
+ state.history = compactAgentHistory(state.history);
475
+ console.log(`Контекст сжат. Сообщений в agent-истории: ${state.history.length}`);
476
+ return false;
477
+ }
478
+
479
+ if (command === "usage") {
480
+ printAgentUsage(state.history);
481
+ return false;
482
+ }
483
+
379
484
  if (command === "history") {
380
485
  if (args.length > 0) {
381
486
  await handleHistory(args);
@@ -415,6 +520,16 @@ async function handleAgentLine(line, state) {
415
520
  return false;
416
521
  }
417
522
 
523
+ if (command === "context") {
524
+ await handleContext(args.length > 0 ? args : ["list"]);
525
+ return false;
526
+ }
527
+
528
+ if (command === "skills") {
529
+ await handleSkills(args);
530
+ return false;
531
+ }
532
+
418
533
  if (command === "permissions") {
419
534
  await handlePermissions(args);
420
535
  return false;
@@ -436,7 +551,22 @@ async function handleAgentLine(line, state) {
436
551
  }
437
552
 
438
553
  if (command === "tools") {
439
- await handlePermissions(["tools"]);
554
+ await handleTools(args.length > 0 ? args : ["list"]);
555
+ return false;
556
+ }
557
+
558
+ if (command === "cron") {
559
+ await handleCron(args);
560
+ return false;
561
+ }
562
+
563
+ if (command === "daemon") {
564
+ await handleDaemon(args);
565
+ return false;
566
+ }
567
+
568
+ if (command === "rpc") {
569
+ await handleRpc(args);
440
570
  return false;
441
571
  }
442
572
 
@@ -545,11 +675,16 @@ async function handleAgentLine(line, state) {
545
675
  fork: ["fork", args],
546
676
  features: ["features", args],
547
677
  wiki: ["wiki", args],
678
+ context: ["context", args],
679
+ skills: ["skills", args],
680
+ cron: ["cron", args],
681
+ daemon: ["daemon", args],
682
+ rpc: ["rpc", args],
548
683
  permissions: ["permissions", args],
549
684
  memory: ["memory", args],
550
685
  hooks: ["hooks", args],
551
686
  agents: ["agents", args],
552
- tools: ["permissions", ["tools", ...args]],
687
+ tools: ["tools", args],
553
688
  mcp: ["mcp", args],
554
689
  cache: ["cache", args],
555
690
  sync: ["sync", args],
@@ -585,8 +720,13 @@ function printAgentHelp() {
585
720
  /resume SESSION_ID
586
721
  /features list
587
722
  /wiki
723
+ /context list
724
+ /skills list
588
725
  /permissions
589
726
  /tools
727
+ /cron list
728
+ /daemon status
729
+ /rpc call status
590
730
  /memory show
591
731
  /hooks list
592
732
  /agents list
@@ -622,6 +762,12 @@ function printAgentHelp() {
622
762
  /model
623
763
  /history
624
764
  /history --limit 20
765
+ /new
766
+ /reset
767
+ /retry
768
+ /undo
769
+ /compact
770
+ /usage
625
771
  /clear
626
772
  /banner
627
773
  /update
@@ -642,6 +788,27 @@ function printAgentHistory(history) {
642
788
  }
643
789
  }
644
790
 
791
+ function compactAgentHistory(history) {
792
+ if (history.length <= 8) return history;
793
+ const summary = history.slice(0, -6)
794
+ .map((item) => `${item.role}: ${item.content}`)
795
+ .join("\n")
796
+ .slice(0, 3000);
797
+ return [
798
+ { role: "system", content: `Сжатая история предыдущего диалога:\n${summary}` },
799
+ ...history.slice(-6),
800
+ ];
801
+ }
802
+
803
+ function printAgentUsage(history) {
804
+ const chars = history.reduce((sum, item) => sum + String(item.content || "").length, 0);
805
+ printKeyValue({
806
+ messages: history.length,
807
+ characters: chars,
808
+ approximate_tokens: Math.ceil(chars / 4),
809
+ });
810
+ }
811
+
645
812
  function safePrompt(rl, closed = false) {
646
813
  if (closed) {
647
814
  return;
@@ -740,6 +907,12 @@ async function doctor(args = []) {
740
907
  nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
741
908
  },
742
909
  db: getDbStatus(),
910
+ config: {
911
+ file: CONFIG_FILE,
912
+ valid: validateConfig(config).length === 0 ? "yes" : "no",
913
+ errors: validateConfig(config),
914
+ lastGood: existsSync(LAST_GOOD_CONFIG_FILE) ? LAST_GOOD_CONFIG_FILE : "-",
915
+ },
743
916
  api: {
744
917
  baseUrl: apiBaseUrl,
745
918
  mcpBaseUrl,
@@ -754,9 +927,31 @@ async function doctor(args = []) {
754
927
  openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
755
928
  ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
756
929
  },
930
+ skills: {
931
+ enabled: config.skills?.enabled?.join(", ") || "-",
932
+ found: listSkills(config).length,
933
+ },
934
+ toolsets: {
935
+ enabled: config.toolsets?.enabled?.join(", ") || "-",
936
+ },
937
+ daemon: {
938
+ endpoint: `http://${config.daemon?.host || "127.0.0.1"}:${config.daemon?.port || DAEMON_PORT}`,
939
+ status: await probeEndpoint(`http://${config.daemon?.host || "127.0.0.1"}:${config.daemon?.port || DAEMON_PORT}/health`),
940
+ },
757
941
  system: diagnostics,
758
942
  };
759
943
 
944
+ if (options.fix) {
945
+ initDatabase();
946
+ const errors = validateConfig(config);
947
+ if (errors.length > 0) {
948
+ await writeConfig(mergeConfig(DEFAULT_AI_CONFIG, config));
949
+ }
950
+ await mkdir(USER_SKILLS_DIR, { recursive: true });
951
+ console.log("Автоисправление выполнено: БД и пользовательская папка skills проверены.");
952
+ return;
953
+ }
954
+
760
955
  if (options.json) {
761
956
  printJson(report);
762
957
  return;
@@ -766,6 +961,7 @@ async function doctor(args = []) {
766
961
  printTable([
767
962
  { group: "cli", status: report.cli.nodeStatus === "ok" && report.cli.update !== "available" ? "ok" : "check" },
768
963
  { group: "sqlite", status: report.db.status },
964
+ { group: "config", status: report.config.valid === "yes" ? "ok" : "error" },
769
965
  { group: "api", status: report.api.health },
770
966
  { group: "ai", status: report.ai.provider },
771
967
  { group: "ollama", status: report.ai.ollama },
@@ -782,12 +978,18 @@ async function doctor(args = []) {
782
978
  console.log("SQLite");
783
979
  printKeyValue(report.db);
784
980
  console.log("");
981
+ console.log("Config");
982
+ printKeyValue(report.config);
983
+ console.log("");
785
984
  console.log("API/MCP");
786
985
  printKeyValue(report.api);
787
986
  console.log("");
788
987
  console.log("AI");
789
988
  printKeyValue(report.ai);
790
989
  console.log("");
990
+ console.log("Skills/Toolsets/Daemon");
991
+ printKeyValue({ ...report.skills, toolsets: report.toolsets.enabled, daemon: report.daemon.status });
992
+ console.log("");
791
993
  printDiagnostics(diagnostics, recommendOllamaModel(diagnostics));
792
994
  if (options.all) {
793
995
  console.log("");
@@ -1046,13 +1248,29 @@ async function handleConfig(args) {
1046
1248
  return;
1047
1249
  }
1048
1250
 
1251
+ if (action === "validate") {
1252
+ const config = await loadConfig();
1253
+ const errors = validateConfig(config);
1254
+ if (errors.length > 0) {
1255
+ printTable(errors.map((error) => ({ error })), [["error", "Ошибка"]]);
1256
+ throw new Error("Конфигурация содержит ошибки.");
1257
+ }
1258
+ console.log("Конфигурация корректна.");
1259
+ return;
1260
+ }
1261
+
1262
+ if (action === "schema") {
1263
+ printJson(configSchema());
1264
+ return;
1265
+ }
1266
+
1049
1267
  if (action === "reset") {
1050
1268
  await writeConfig(DEFAULT_AI_CONFIG);
1051
1269
  console.log(`Конфигурация сброшена: ${CONFIG_FILE}`);
1052
1270
  return;
1053
1271
  }
1054
1272
 
1055
- throw new Error("Команды config: get, set, reset.");
1273
+ throw new Error("Команды config: get, set, validate, schema, reset.");
1056
1274
  }
1057
1275
 
1058
1276
  async function handleDb(args) {
@@ -1091,6 +1309,15 @@ async function handleHistory(args) {
1091
1309
  const [action] = args;
1092
1310
  const options = parseOptions(args);
1093
1311
 
1312
+ if (action === "search") {
1313
+ const query = options._.slice(1).join(" ").trim() || options.query || options.search;
1314
+ if (!query) throw new Error('Пример: iola history search "Петрова"');
1315
+ const rows = searchHistory(query, Number(options.limit || 20));
1316
+ if (options.json) printJson(rows);
1317
+ else printTable(rows, [["id", "ID"], ["created_at", "Дата"], ["profile", "Профиль"], ["question", "Вопрос"], ["answer", "Ответ"]]);
1318
+ return;
1319
+ }
1320
+
1094
1321
  if (action === "clear") {
1095
1322
  clearHistory();
1096
1323
  console.log("История очищена.");
@@ -1123,6 +1350,24 @@ async function handleSessions(args) {
1123
1350
  }
1124
1351
 
1125
1352
  const options = parseOptions(args);
1353
+
1354
+ if (action === "search") {
1355
+ const query = options._.slice(1).join(" ").trim() || options.query || options.search;
1356
+ if (!query) throw new Error('Пример: iola sessions search "Петрова"');
1357
+ const rows = searchSessions(query, Number(options.limit || 20));
1358
+ if (options.json) printJson(rows);
1359
+ else printTable(rows, [["session_id", "Сессия"], ["message_id", "Сообщ."], ["role", "Роль"], ["content", "Текст"]]);
1360
+ return;
1361
+ }
1362
+
1363
+ if (action === "compact") {
1364
+ const sessionId = Number(args[1]);
1365
+ if (!sessionId) throw new Error("Пример: iola sessions compact 1");
1366
+ const result = compactSessionInDb(sessionId);
1367
+ printKeyValue(result);
1368
+ return;
1369
+ }
1370
+
1126
1371
  const rows = listSessions(Number(options.limit || 20));
1127
1372
 
1128
1373
  if (options.json) {
@@ -1207,6 +1452,9 @@ async function handleWiki(args) {
1207
1452
  ["Первый запуск", `${base}/Первый-запуск`],
1208
1453
  ["AI-профили", `${base}/AI-профили`],
1209
1454
  ["Локальный инструментальный агент", `${base}/Локальный-инструментальный-агент`],
1455
+ ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1456
+ ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
1457
+ ["Контекст и память", `${base}/Контекст-и-память`],
1210
1458
  ["Команды", `${base}/Команды`],
1211
1459
  ["Решение проблем", `${base}/Решение-проблем`],
1212
1460
  ].map(([title, url]) => ({ title, url }));
@@ -1227,6 +1475,229 @@ async function handleWiki(args) {
1227
1475
  throw new Error("Команды wiki: links, open.");
1228
1476
  }
1229
1477
 
1478
+ async function handleContext(args) {
1479
+ const [action = "list"] = args;
1480
+ const files = await listContextFiles();
1481
+
1482
+ if (action === "list" || action === "ls") {
1483
+ printTable(files, [
1484
+ ["scope", "Область"],
1485
+ ["file", "Файл"],
1486
+ ["exists", "Есть"],
1487
+ ["size", "Размер"],
1488
+ ]);
1489
+ return;
1490
+ }
1491
+
1492
+ if (action === "show") {
1493
+ const text = await buildProjectContextText();
1494
+ console.log(text || "Контекстные файлы не найдены.");
1495
+ return;
1496
+ }
1497
+
1498
+ if (action === "init") {
1499
+ await mkdir(path.dirname(PROJECT_CONTEXT_DIR_FILE), { recursive: true });
1500
+ if (!existsSync(PROJECT_CONTEXT_FILE)) {
1501
+ await writeFile(PROJECT_CONTEXT_FILE, [
1502
+ "# Контекст iola",
1503
+ "",
1504
+ "Проект работает с открытыми данными городского округа \"Город Йошкар-Ола\".",
1505
+ "Ответы должны опираться на публичный API, локальную SQLite-БД и подключенные skills.",
1506
+ "",
1507
+ ].join("\n"), "utf8");
1508
+ }
1509
+ if (!existsSync(PROJECT_CONTEXT_DIR_FILE)) {
1510
+ await writeFile(PROJECT_CONTEXT_DIR_FILE, [
1511
+ "# Рабочий контекст",
1512
+ "",
1513
+ "- Основные слои первого релиза: школы и детские сады.",
1514
+ "- Не выдумывать сведения, которых нет в источниках данных.",
1515
+ "",
1516
+ ].join("\n"), "utf8");
1517
+ }
1518
+ console.log(`Контекст создан: ${PROJECT_CONTEXT_FILE}`);
1519
+ console.log(`Контекст создан: ${PROJECT_CONTEXT_DIR_FILE}`);
1520
+ return;
1521
+ }
1522
+
1523
+ throw new Error("Команды context: list, show, init.");
1524
+ }
1525
+
1526
+ async function handleSkills(args) {
1527
+ const [action = "list", name] = args;
1528
+ const config = await loadConfig();
1529
+
1530
+ if (action === "list" || action === "ls") {
1531
+ const rows = listSkills(config).map((skill) => ({
1532
+ enabled: isSkillEnabled(config, skill.name) ? "yes" : "no",
1533
+ name: skill.name,
1534
+ source: skill.source,
1535
+ description: skill.description,
1536
+ file: skill.file,
1537
+ }));
1538
+ printTable(rows, [
1539
+ ["enabled", "Вкл"],
1540
+ ["name", "Skill"],
1541
+ ["source", "Источник"],
1542
+ ["description", "Описание"],
1543
+ ["file", "Файл"],
1544
+ ]);
1545
+ return;
1546
+ }
1547
+
1548
+ if (action === "paths") {
1549
+ printTable(skillRoots().map((root) => ({ root, exists: existsSync(root) ? "yes" : "no" })), [
1550
+ ["root", "Папка"],
1551
+ ["exists", "Есть"],
1552
+ ]);
1553
+ return;
1554
+ }
1555
+
1556
+ if (action === "show") {
1557
+ const skill = findSkill(name, config);
1558
+ if (!skill) throw new Error(`Skill не найден: ${name}`);
1559
+ console.log(await readFile(skill.file, "utf8"));
1560
+ return;
1561
+ }
1562
+
1563
+ if (action === "enable" || action === "disable") {
1564
+ if (!name) throw new Error("Имя skill обязательно.");
1565
+ const enabled = new Set(config.skills?.enabled || []);
1566
+ if (action === "enable") enabled.add(name);
1567
+ else enabled.delete(name);
1568
+ await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabled] } });
1569
+ console.log(`${name}: ${action === "enable" ? "enabled" : "disabled"}`);
1570
+ return;
1571
+ }
1572
+
1573
+ throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME.");
1574
+ }
1575
+
1576
+ async function handleTools(args) {
1577
+ const [action = "list", name] = args;
1578
+ const config = await loadConfig();
1579
+
1580
+ if (action === "list" || action === "ls") {
1581
+ await handlePermissions(["tools"]);
1582
+ return;
1583
+ }
1584
+
1585
+ if (action === "toolsets") {
1586
+ const enabled = new Set(config.toolsets?.enabled || []);
1587
+ printTable(Object.entries(TOOLSETS).map(([toolset, meta]) => ({
1588
+ enabled: enabled.has(toolset) ? "yes" : "no",
1589
+ toolset,
1590
+ description: meta.description,
1591
+ })), [
1592
+ ["enabled", "Вкл"],
1593
+ ["toolset", "Toolset"],
1594
+ ["description", "Описание"],
1595
+ ]);
1596
+ return;
1597
+ }
1598
+
1599
+ if (action === "enable" || action === "disable") {
1600
+ if (!TOOLSETS[name]) throw new Error(`Toolset неизвестен. Доступно: ${Object.keys(TOOLSETS).join(", ")}`);
1601
+ const enabled = new Set(config.toolsets?.enabled || []);
1602
+ if (action === "enable") enabled.add(name);
1603
+ else enabled.delete(name);
1604
+ await saveConfig({ toolsets: { ...(config.toolsets || {}), enabled: [...enabled] } });
1605
+ console.log(`${name}: ${action === "enable" ? "enabled" : "disabled"}`);
1606
+ return;
1607
+ }
1608
+
1609
+ if (action === "profile") {
1610
+ if (!TOOLSETS[name]) throw new Error(`Профиль неизвестен. Доступно: ${Object.keys(TOOLSETS).join(", ")}`);
1611
+ const permissions = applyToolsetPermissions(DEFAULT_AI_CONFIG.permissions, [name]);
1612
+ await saveConfig({ toolsets: { enabled: [name] }, permissions });
1613
+ console.log(`Toolset-профиль применен: ${name}`);
1614
+ return;
1615
+ }
1616
+
1617
+ throw new Error("Команды tools: list, toolsets, enable NAME, disable NAME, profile NAME.");
1618
+ }
1619
+
1620
+ async function handleCron(args) {
1621
+ const [action = "list", ...rest] = args;
1622
+ const options = parseOptions(rest);
1623
+
1624
+ if (action === "list" || action === "ls") {
1625
+ const rows = listCronJobs();
1626
+ if (options.json) printJson(rows);
1627
+ else printTable(rows, [["id", "ID"], ["enabled", "Вкл"], ["schedule_text", "Расписание"], ["command", "Команда"], ["last_run_at", "Последний запуск"]]);
1628
+ return;
1629
+ }
1630
+
1631
+ if (action === "add") {
1632
+ const text = rest.join(" ").trim();
1633
+ const separator = text.includes(" -- ") ? " -- " : " :: ";
1634
+ const [scheduleText, command] = text.split(separator).map((part) => part?.trim());
1635
+ if (!scheduleText || !command) {
1636
+ throw new Error('Пример: iola cron add "каждый день 09:00 -- quality"');
1637
+ }
1638
+ const id = addCronJob(scheduleText, command);
1639
+ console.log(`Cron-задача добавлена: ${id}`);
1640
+ return;
1641
+ }
1642
+
1643
+ if (action === "delete" || action === "remove" || action === "rm") {
1644
+ const id = Number(rest[0]);
1645
+ if (!id) throw new Error("Пример: iola cron delete 1");
1646
+ deleteCronJob(id);
1647
+ console.log(`Cron-задача удалена: ${id}`);
1648
+ return;
1649
+ }
1650
+
1651
+ if (action === "run") {
1652
+ const id = Number(rest[0]);
1653
+ if (!id) throw new Error("Пример: iola cron run 1");
1654
+ await runCronJob(id);
1655
+ return;
1656
+ }
1657
+
1658
+ if (action === "tick") {
1659
+ const rows = dueCronJobs();
1660
+ for (const row of rows) await runCronJob(row.id);
1661
+ console.log(`Выполнено cron-задач: ${rows.length}`);
1662
+ return;
1663
+ }
1664
+
1665
+ throw new Error('Команды cron: list, add "каждый день 09:00 -- quality", delete ID, run ID, tick.');
1666
+ }
1667
+
1668
+ async function handleDaemon(args) {
1669
+ const [action = "status"] = args;
1670
+ const config = await loadConfig();
1671
+ const host = config.daemon?.host || "127.0.0.1";
1672
+ const port = Number(config.daemon?.port || DAEMON_PORT);
1673
+
1674
+ if (action === "status") {
1675
+ try {
1676
+ const payload = await fetchJson(`http://${host}:${port}/health`);
1677
+ printKeyValue(payload);
1678
+ } catch {
1679
+ printKeyValue({ status: "stopped", endpoint: `http://${host}:${port}` });
1680
+ }
1681
+ return;
1682
+ }
1683
+
1684
+ if (action === "start" || action === "run") {
1685
+ await startDaemon(host, port);
1686
+ return;
1687
+ }
1688
+
1689
+ throw new Error("Команды daemon: status, start.");
1690
+ }
1691
+
1692
+ async function handleRpc(args) {
1693
+ const [action = "call", method, ...rest] = args;
1694
+ if (action !== "call" || !method) {
1695
+ throw new Error("Пример: iola rpc call search --query Петрова --dataset schools");
1696
+ }
1697
+ const result = await executeRpc(method, parseOptions(rest));
1698
+ printJson(result);
1699
+ }
1700
+
1230
1701
  async function openUrl(url) {
1231
1702
  if (process.platform === "win32") {
1232
1703
  await runCommand("rundll32", ["url.dll,FileProtocolHandler", url], { inherit: false });
@@ -1316,6 +1787,29 @@ async function handleMemory(args) {
1316
1787
  return;
1317
1788
  }
1318
1789
 
1790
+ if (action === "suggest" || action === "suggestions") {
1791
+ const rows = listMemorySuggestions(rest[0] || "pending");
1792
+ if (options.json) printJson(rows);
1793
+ else printTable(rows, [["id", "ID"], ["status", "Статус"], ["content", "Предложение"], ["reason", "Причина"], ["created_at", "Дата"]]);
1794
+ return;
1795
+ }
1796
+
1797
+ if (action === "approve") {
1798
+ const id = Number(rest[0]);
1799
+ if (!id) throw new Error("Пример: iola memory approve 1");
1800
+ const memoryId = approveMemorySuggestion(id);
1801
+ console.log(`Предложение принято. Память сохранена: ${memoryId}`);
1802
+ return;
1803
+ }
1804
+
1805
+ if (action === "reject") {
1806
+ const id = Number(rest[0]);
1807
+ if (!id) throw new Error("Пример: iola memory reject 1");
1808
+ resolveMemorySuggestion(id, "rejected");
1809
+ console.log(`Предложение отклонено: ${id}`);
1810
+ return;
1811
+ }
1812
+
1319
1813
  if (action === "delete" || action === "remove" || action === "rm") {
1320
1814
  const id = rest[0];
1321
1815
  if (!id) throw new Error("Пример: iola memory delete 1");
@@ -1338,7 +1832,7 @@ async function handleMemory(args) {
1338
1832
  return;
1339
1833
  }
1340
1834
 
1341
- throw new Error("Команды memory: show, add TEXT, delete ID, clear, export [FILE].");
1835
+ throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE].");
1342
1836
  }
1343
1837
 
1344
1838
  async function handleHooks(args) {
@@ -2185,6 +2679,8 @@ function initDatabase() {
2185
2679
  error TEXT
2186
2680
  );
2187
2681
  CREATE INDEX IF NOT EXISTS idx_ask_history_created_at ON ask_history(created_at DESC);
2682
+ DROP TABLE IF EXISTS ask_history_fts;
2683
+ CREATE VIRTUAL TABLE IF NOT EXISTS ask_history_fts USING fts5(question, answer);
2188
2684
  CREATE TABLE IF NOT EXISTS sessions (
2189
2685
  id INTEGER PRIMARY KEY AUTOINCREMENT,
2190
2686
  parent_id INTEGER,
@@ -2206,6 +2702,8 @@ function initDatabase() {
2206
2702
  FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
2207
2703
  );
2208
2704
  CREATE INDEX IF NOT EXISTS idx_session_messages_session_id ON session_messages(session_id, id);
2705
+ DROP TABLE IF EXISTS session_messages_fts;
2706
+ CREATE VIRTUAL TABLE IF NOT EXISTS session_messages_fts USING fts5(session_id UNINDEXED, role, content);
2209
2707
  CREATE TABLE IF NOT EXISTS feature_flags (
2210
2708
  name TEXT PRIMARY KEY,
2211
2709
  enabled INTEGER NOT NULL,
@@ -2265,7 +2763,27 @@ function initDatabase() {
2265
2763
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
2266
2764
  );
2267
2765
  CREATE INDEX IF NOT EXISTS idx_memory_created_at ON memory(created_at DESC);
2766
+ CREATE TABLE IF NOT EXISTS memory_suggestions (
2767
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2768
+ scope TEXT NOT NULL DEFAULT 'user',
2769
+ content TEXT NOT NULL,
2770
+ reason TEXT,
2771
+ status TEXT NOT NULL DEFAULT 'pending',
2772
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2773
+ resolved_at TEXT
2774
+ );
2775
+ CREATE INDEX IF NOT EXISTS idx_memory_suggestions_status ON memory_suggestions(status, created_at DESC);
2776
+ CREATE TABLE IF NOT EXISTS cron_jobs (
2777
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2778
+ schedule_text TEXT NOT NULL,
2779
+ command TEXT NOT NULL,
2780
+ enabled INTEGER NOT NULL DEFAULT 1,
2781
+ last_run_at TEXT,
2782
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2783
+ );
2784
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled ON cron_jobs(enabled, last_run_at);
2268
2785
  `);
2786
+ rebuildFtsIfEmpty(db);
2269
2787
  db.prepare(`
2270
2788
  INSERT INTO meta(key, value) VALUES ('schema_version', ?)
2271
2789
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
@@ -2283,6 +2801,26 @@ function mkdirSyncSafe(directory) {
2283
2801
  }
2284
2802
  }
2285
2803
 
2804
+ function rebuildFtsIfEmpty(db) {
2805
+ try {
2806
+ const askCount = db.prepare("SELECT COUNT(*) AS count FROM ask_history_fts").get()?.count || 0;
2807
+ if (askCount === 0) {
2808
+ const rows = db.prepare("SELECT id, question, answer FROM ask_history ORDER BY id ASC").all();
2809
+ const insert = db.prepare("INSERT INTO ask_history_fts(rowid, question, answer) VALUES (?, ?, ?)");
2810
+ for (const row of rows) insert.run(row.id, row.question || "", row.answer || "");
2811
+ }
2812
+ const sessionCount = db.prepare("SELECT COUNT(*) AS count FROM session_messages_fts").get()?.count || 0;
2813
+ if (sessionCount === 0) {
2814
+ const rows = db.prepare("SELECT id, session_id, role, content FROM session_messages ORDER BY id ASC").all();
2815
+ const insert = db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)");
2816
+ for (const row of rows) insert.run(row.id, row.session_id, row.role || "", row.content || "");
2817
+ }
2818
+ } catch {
2819
+ // FTS rebuild is best-effort and must not block startup.
2820
+ }
2821
+ }
2822
+
2823
+
2286
2824
  function getDbStatus() {
2287
2825
  try {
2288
2826
  initDatabase();
@@ -2294,6 +2832,8 @@ function getDbStatus() {
2294
2832
  const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
2295
2833
  const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
2296
2834
  const memory = db.prepare("SELECT COUNT(*) AS count FROM memory").get();
2835
+ const memorySuggestions = db.prepare("SELECT COUNT(*) AS count FROM memory_suggestions WHERE status = 'pending'").get();
2836
+ const cron = db.prepare("SELECT COUNT(*) AS count FROM cron_jobs").get();
2297
2837
  return {
2298
2838
  status: "ok",
2299
2839
  file: DB_FILE,
@@ -2303,6 +2843,8 @@ function getDbStatus() {
2303
2843
  local_records: local?.count ?? 0,
2304
2844
  cache: cache?.count ?? 0,
2305
2845
  memory: memory?.count ?? 0,
2846
+ memory_suggestions: memorySuggestions?.count ?? 0,
2847
+ cron_jobs: cron?.count ?? 0,
2306
2848
  };
2307
2849
  } finally {
2308
2850
  db.close();
@@ -2323,7 +2865,7 @@ function recordAskHistory({ question, answer, providerConfig, dataContext, error
2323
2865
  initDatabase();
2324
2866
  const db = openDatabase();
2325
2867
  try {
2326
- db.prepare(`
2868
+ const result = db.prepare(`
2327
2869
  INSERT INTO ask_history(profile, provider, model, question, answer, context_json, error)
2328
2870
  VALUES (?, ?, ?, ?, ?, ?, ?)
2329
2871
  `).run(
@@ -2335,6 +2877,7 @@ function recordAskHistory({ question, answer, providerConfig, dataContext, error
2335
2877
  JSON.stringify(dataContext),
2336
2878
  error || "",
2337
2879
  );
2880
+ db.prepare("INSERT INTO ask_history_fts(rowid, question, answer) VALUES (?, ?, ?)").run(Number(result.lastInsertRowid), question, answer || error || "");
2338
2881
  } finally {
2339
2882
  db.close();
2340
2883
  }
@@ -2362,7 +2905,7 @@ function clearHistory() {
2362
2905
  initDatabase();
2363
2906
  const db = openDatabase();
2364
2907
  try {
2365
- db.exec("DELETE FROM ask_history");
2908
+ db.exec("DELETE FROM ask_history; DELETE FROM ask_history_fts;");
2366
2909
  } finally {
2367
2910
  db.close();
2368
2911
  }
@@ -2372,7 +2915,7 @@ function clearSessions() {
2372
2915
  initDatabase();
2373
2916
  const db = openDatabase();
2374
2917
  try {
2375
- db.exec("DELETE FROM session_messages; DELETE FROM sessions;");
2918
+ db.exec("DELETE FROM session_messages; DELETE FROM session_messages_fts; DELETE FROM sessions;");
2376
2919
  } finally {
2377
2920
  db.close();
2378
2921
  }
@@ -2405,10 +2948,15 @@ function appendSessionExchange(sessionId, question, answer, dataContext, error)
2405
2948
  }
2406
2949
  const db = openDatabase();
2407
2950
  try {
2408
- db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'user', ?, ?)")
2951
+ const userResult = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'user', ?, ?)")
2409
2952
  .run(sessionId, question, JSON.stringify(dataContext));
2410
- db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'assistant', ?, ?)")
2411
- .run(sessionId, error || answer || "", JSON.stringify({ error: error || "" }));
2953
+ const assistantContent = error || answer || "";
2954
+ const assistantResult = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'assistant', ?, ?)")
2955
+ .run(sessionId, assistantContent, JSON.stringify({ error: error || "" }));
2956
+ db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)")
2957
+ .run(Number(userResult.lastInsertRowid), sessionId, "user", question);
2958
+ db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)")
2959
+ .run(Number(assistantResult.lastInsertRowid), sessionId, "assistant", assistantContent);
2412
2960
  db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
2413
2961
  } finally {
2414
2962
  db.close();
@@ -2491,6 +3039,78 @@ function forkSessionInDb(sessionId) {
2491
3039
  }
2492
3040
  }
2493
3041
 
3042
+ function searchHistory(query, limit = 20) {
3043
+ initDatabase();
3044
+ const db = openDatabase();
3045
+ try {
3046
+ const ftsQuery = toFtsQuery(query);
3047
+ return db.prepare(`
3048
+ SELECT h.id, h.created_at, h.profile, h.provider, h.model, h.question, h.answer
3049
+ FROM ask_history_fts f
3050
+ JOIN ask_history h ON h.id = f.rowid
3051
+ WHERE ask_history_fts MATCH ?
3052
+ ORDER BY rank
3053
+ LIMIT ?
3054
+ `).all(ftsQuery, limit);
3055
+ } finally {
3056
+ db.close();
3057
+ }
3058
+ }
3059
+
3060
+ function searchSessions(query, limit = 20) {
3061
+ initDatabase();
3062
+ const db = openDatabase();
3063
+ try {
3064
+ const ftsQuery = toFtsQuery(query);
3065
+ return db.prepare(`
3066
+ SELECT f.session_id, f.rowid AS message_id, f.role, f.content
3067
+ FROM session_messages_fts f
3068
+ WHERE session_messages_fts MATCH ?
3069
+ ORDER BY rank
3070
+ LIMIT ?
3071
+ `).all(ftsQuery, limit);
3072
+ } finally {
3073
+ db.close();
3074
+ }
3075
+ }
3076
+
3077
+ function compactSessionInDb(sessionId) {
3078
+ const messages = getSessionAiHistory(sessionId);
3079
+ if (messages.length <= 8) {
3080
+ return { session_id: sessionId, before: messages.length, after: messages.length, status: "skip" };
3081
+ }
3082
+ const keep = messages.slice(-6);
3083
+ const summary = messages.slice(0, -6)
3084
+ .map((message) => `${message.role}: ${message.content}`)
3085
+ .join("\n")
3086
+ .slice(0, 4000);
3087
+ const compacted = [
3088
+ { role: "system", content: `Сжатая история предыдущей части сессии:\n${summary}` },
3089
+ ...keep,
3090
+ ];
3091
+ initDatabase();
3092
+ const db = openDatabase();
3093
+ try {
3094
+ db.prepare("DELETE FROM session_messages WHERE session_id = ?").run(sessionId);
3095
+ db.prepare("DELETE FROM session_messages_fts WHERE session_id = ?").run(sessionId);
3096
+ const insert = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, ?, ?, ?)");
3097
+ const insertFts = db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)");
3098
+ for (const message of compacted) {
3099
+ const result = insert.run(sessionId, message.role, message.content, "{}");
3100
+ insertFts.run(Number(result.lastInsertRowid), sessionId, message.role, message.content);
3101
+ }
3102
+ db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
3103
+ return { session_id: sessionId, before: messages.length, after: compacted.length, status: "ok" };
3104
+ } finally {
3105
+ db.close();
3106
+ }
3107
+ }
3108
+
3109
+ function toFtsQuery(query) {
3110
+ const terms = String(query).split(/\s+/).map((term) => term.replace(/["*]/g, "").trim()).filter(Boolean);
3111
+ return terms.length > 0 ? terms.map((term) => `"${term}"`).join(" OR ") : '""';
3112
+ }
3113
+
2494
3114
  function listFeatures() {
2495
3115
  initDatabase();
2496
3116
  const db = openDatabase();
@@ -2895,11 +3515,160 @@ function clearMemory() {
2895
3515
  }
2896
3516
  }
2897
3517
 
3518
+ function listCronJobs() {
3519
+ initDatabase();
3520
+ const db = openDatabase();
3521
+ try {
3522
+ return db.prepare("SELECT id, schedule_text, command, enabled, COALESCE(last_run_at, '-') AS last_run_at, created_at FROM cron_jobs ORDER BY id DESC").all()
3523
+ .map((row) => ({ ...row, enabled: row.enabled ? "yes" : "no" }));
3524
+ } finally {
3525
+ db.close();
3526
+ }
3527
+ }
3528
+
3529
+ function addCronJob(scheduleText, command) {
3530
+ initDatabase();
3531
+ const db = openDatabase();
3532
+ try {
3533
+ const result = db.prepare("INSERT INTO cron_jobs(schedule_text, command) VALUES (?, ?)").run(scheduleText, command);
3534
+ return Number(result.lastInsertRowid);
3535
+ } finally {
3536
+ db.close();
3537
+ }
3538
+ }
3539
+
3540
+ function deleteCronJob(id) {
3541
+ initDatabase();
3542
+ const db = openDatabase();
3543
+ try {
3544
+ db.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id);
3545
+ } finally {
3546
+ db.close();
3547
+ }
3548
+ }
3549
+
3550
+ function dueCronJobs() {
3551
+ initDatabase();
3552
+ const db = openDatabase();
3553
+ try {
3554
+ return db.prepare("SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY id ASC").all()
3555
+ .filter((job) => isCronDue(job));
3556
+ } finally {
3557
+ db.close();
3558
+ }
3559
+ }
3560
+
3561
+ function isCronDue(job) {
3562
+ const normalized = job.schedule_text.toLocaleLowerCase("ru-RU");
3563
+ const lastRun = job.last_run_at ? new Date(`${job.last_run_at}Z`) : null;
3564
+ const now = new Date();
3565
+ if (lastRun && now.getTime() - lastRun.getTime() < 60_000) return false;
3566
+ if (normalized.includes("каждый час") || normalized.includes("hourly")) {
3567
+ return !lastRun || now.getTime() - lastRun.getTime() >= 60 * 60 * 1000;
3568
+ }
3569
+ if (normalized.includes("каждый день") || normalized.includes("daily")) {
3570
+ return !lastRun || now.toISOString().slice(0, 10) !== lastRun.toISOString().slice(0, 10);
3571
+ }
3572
+ if (normalized.includes("каждую неделю") || normalized.includes("weekly")) {
3573
+ return !lastRun || now.getTime() - lastRun.getTime() >= 7 * 24 * 60 * 60 * 1000;
3574
+ }
3575
+ return !lastRun;
3576
+ }
3577
+
3578
+ async function runCronJob(id) {
3579
+ initDatabase();
3580
+ const db = openDatabase();
3581
+ let job;
3582
+ try {
3583
+ job = db.prepare("SELECT * FROM cron_jobs WHERE id = ?").get(id);
3584
+ } finally {
3585
+ db.close();
3586
+ }
3587
+ if (!job) throw new Error(`Cron-задача не найдена: ${id}`);
3588
+ console.log(`> iola ${job.command}`);
3589
+ await main(splitCommandLine(job.command));
3590
+ const updateDb = openDatabase();
3591
+ try {
3592
+ updateDb.prepare("UPDATE cron_jobs SET last_run_at = datetime('now') WHERE id = ?").run(id);
3593
+ } finally {
3594
+ updateDb.close();
3595
+ }
3596
+ }
3597
+
2898
3598
  function buildMemoryText(limit = 20) {
2899
3599
  const rows = listMemory(limit).reverse();
2900
3600
  return rows.map((row) => `- ${row.content}`).join("\n");
2901
3601
  }
2902
3602
 
3603
+ function addMemorySuggestion(content, reason, scope = "user") {
3604
+ initDatabase();
3605
+ const db = openDatabase();
3606
+ try {
3607
+ const existing = db.prepare("SELECT id FROM memory_suggestions WHERE status = 'pending' AND content = ?").get(content);
3608
+ if (existing) return Number(existing.id);
3609
+ const result = db.prepare("INSERT INTO memory_suggestions(scope, content, reason) VALUES (?, ?, ?)").run(scope, content, reason || "");
3610
+ return Number(result.lastInsertRowid);
3611
+ } finally {
3612
+ db.close();
3613
+ }
3614
+ }
3615
+
3616
+ function listMemorySuggestions(status = "pending") {
3617
+ initDatabase();
3618
+ const db = openDatabase();
3619
+ try {
3620
+ if (status === "all") {
3621
+ return db.prepare("SELECT id, scope, content, reason, status, created_at FROM memory_suggestions ORDER BY id DESC LIMIT 100").all();
3622
+ }
3623
+ return db.prepare("SELECT id, scope, content, reason, status, created_at FROM memory_suggestions WHERE status = ? ORDER BY id DESC LIMIT 100").all(status);
3624
+ } finally {
3625
+ db.close();
3626
+ }
3627
+ }
3628
+
3629
+ function approveMemorySuggestion(id) {
3630
+ initDatabase();
3631
+ const db = openDatabase();
3632
+ try {
3633
+ const row = db.prepare("SELECT * FROM memory_suggestions WHERE id = ?").get(id);
3634
+ if (!row) throw new Error(`Предложение памяти не найдено: ${id}`);
3635
+ const result = db.prepare("INSERT INTO memory(scope, content) VALUES (?, ?)").run(row.scope || "user", row.content);
3636
+ db.prepare("UPDATE memory_suggestions SET status = 'approved', resolved_at = datetime('now') WHERE id = ?").run(id);
3637
+ return Number(result.lastInsertRowid);
3638
+ } finally {
3639
+ db.close();
3640
+ }
3641
+ }
3642
+
3643
+ function resolveMemorySuggestion(id, status) {
3644
+ initDatabase();
3645
+ const db = openDatabase();
3646
+ try {
3647
+ db.prepare("UPDATE memory_suggestions SET status = ?, resolved_at = datetime('now') WHERE id = ?").run(status, id);
3648
+ } finally {
3649
+ db.close();
3650
+ }
3651
+ }
3652
+
3653
+ async function maybeSuggestMemory(question, answer, providerConfig) {
3654
+ const config = await loadConfig();
3655
+ if (config.memory?.suggestions === false) return;
3656
+ const normalized = `${question}\n${answer}`.toLocaleLowerCase("ru-RU");
3657
+ const suggestions = [];
3658
+ if (normalized.includes("кратко") || normalized.includes("коротко")) {
3659
+ suggestions.push(["Пользователь предпочитает краткие ответы.", "В запросе или ответе упоминался краткий формат."]);
3660
+ }
3661
+ if (normalized.includes("word") || normalized.includes("docx")) {
3662
+ suggestions.push(["Пользователь часто работает с документами Word/DOCX.", "В сессии упоминался формат Word/DOCX."]);
3663
+ }
3664
+ if (providerConfig?.name) {
3665
+ suggestions.push([`Последний активный AI-профиль: ${providerConfig.name}.`, "Зафиксирован используемый профиль AI."]);
3666
+ }
3667
+ for (const [content, reason] of suggestions.slice(0, 2)) {
3668
+ addMemorySuggestion(content, reason);
3669
+ }
3670
+ }
3671
+
2903
3672
  function listAliases() {
2904
3673
  initDatabase();
2905
3674
  const db = openDatabase();
@@ -3075,7 +3844,7 @@ async function aiAsk(args, context = {}) {
3075
3844
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
3076
3845
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
3077
3846
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
3078
- const messages = buildAiMessages(question, dataContext, history, options);
3847
+ const messages = await buildAiMessages(question, dataContext, history, options, config);
3079
3848
  let answer = "";
3080
3849
  let errorMessage = "";
3081
3850
 
@@ -3095,6 +3864,7 @@ async function aiAsk(args, context = {}) {
3095
3864
  recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
3096
3865
  appendSessionExchange(sessionId, question, answer, dataContext, "");
3097
3866
  }
3867
+ await maybeSuggestMemory(question, answer, providerConfig);
3098
3868
 
3099
3869
  emitEvent(options, "answer", { length: answer.length, sessionId });
3100
3870
 
@@ -3300,7 +4070,7 @@ async function runHooks(event, payload = {}) {
3300
4070
 
3301
4071
  async function assertPermission(name) {
3302
4072
  const config = await loadConfig();
3303
- const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
4073
+ const permissions = applyToolsetPermissions(config.permissions || DEFAULT_AI_CONFIG.permissions, config.toolsets?.enabled || []);
3304
4074
  if (LOCAL_TOOLS.includes(name)) {
3305
4075
  if (permissions.localTools?.[name] === false) {
3306
4076
  throw new Error(`Tool запрещен политикой permissions: ${name}`);
@@ -3312,6 +4082,23 @@ async function assertPermission(name) {
3312
4082
  }
3313
4083
  }
3314
4084
 
4085
+ function applyToolsetPermissions(basePermissions, enabledToolsets) {
4086
+ const next = {
4087
+ ...basePermissions,
4088
+ localTools: { ...(basePermissions.localTools || {}) },
4089
+ };
4090
+ for (const name of enabledToolsets || []) {
4091
+ const toolset = TOOLSETS[name];
4092
+ if (!toolset) continue;
4093
+ Object.assign(next, toolset.permissions || {});
4094
+ next.localTools = {
4095
+ ...(next.localTools || {}),
4096
+ ...(toolset.permissions?.localTools || {}),
4097
+ };
4098
+ }
4099
+ return next;
4100
+ }
4101
+
3315
4102
  function emitEvent(options, type, data) {
3316
4103
  if (!options.events) {
3317
4104
  return;
@@ -3442,9 +4229,11 @@ function scoreItem(item, terms, patterns, layer) {
3442
4229
  return score;
3443
4230
  }
3444
4231
 
3445
- function buildAiMessages(question, dataContext, history, options = {}) {
4232
+ async function buildAiMessages(question, dataContext, history, options = {}, config = DEFAULT_AI_CONFIG) {
3446
4233
  const sourceLines = buildSourceLines(dataContext);
3447
4234
  const memoryText = options.bare ? "" : buildMemoryText();
4235
+ const projectContext = options.bare ? "" : await buildProjectContextText();
4236
+ const skillsText = options.bare ? "" : await buildSkillsText(config);
3448
4237
  const system = [
3449
4238
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
3450
4239
  "Отвечай на русском языке.",
@@ -3455,6 +4244,8 @@ function buildAiMessages(question, dataContext, history, options = {}) {
3455
4244
  options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
3456
4245
  options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
3457
4246
  memoryText ? `Учитывай пользовательскую память:\n${memoryText}` : "",
4247
+ projectContext ? `Учитывай локальный контекст проекта:\n${projectContext}` : "",
4248
+ skillsText ? `Подключенные skills:\n${skillsText}` : "",
3458
4249
  "Отвечай кратко и по делу.",
3459
4250
  ].filter(Boolean).join(" ");
3460
4251
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -3793,12 +4584,12 @@ function parseOptions(args) {
3793
4584
 
3794
4585
  for (let index = 0; index < args.length; index += 1) {
3795
4586
  const arg = args[index];
3796
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug") {
4587
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix") {
3797
4588
  result[arg.slice(2)] = true;
3798
4589
  } else if (arg === "--check" || arg === "--upgrade-node") {
3799
4590
  result.check = true;
3800
4591
  result[arg.slice(2)] = true;
3801
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || 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 === "--debug-file") {
4592
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || 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 === "--debug-file") {
3802
4593
  result[arg.slice(2)] = args[index + 1];
3803
4594
  index += 1;
3804
4595
  } else {
@@ -3949,6 +4740,101 @@ async function getLocalDiagnostics() {
3949
4740
  };
3950
4741
  }
3951
4742
 
4743
+ async function listContextFiles() {
4744
+ const files = [
4745
+ { scope: "project", file: PROJECT_CONTEXT_FILE },
4746
+ { scope: "project-dir", file: PROJECT_CONTEXT_DIR_FILE },
4747
+ ];
4748
+ const rows = [];
4749
+ for (const item of files) {
4750
+ try {
4751
+ const info = await stat(item.file);
4752
+ rows.push({ ...item, exists: "yes", size: info.size });
4753
+ } catch {
4754
+ rows.push({ ...item, exists: "no", size: "-" });
4755
+ }
4756
+ }
4757
+ return rows;
4758
+ }
4759
+
4760
+ async function buildProjectContextText() {
4761
+ const chunks = [];
4762
+ for (const item of await listContextFiles()) {
4763
+ if (item.exists !== "yes") continue;
4764
+ const text = await readFile(item.file, "utf8");
4765
+ chunks.push(`# ${item.scope}: ${item.file}\n${text.trim()}`);
4766
+ }
4767
+ return chunks.join("\n\n");
4768
+ }
4769
+
4770
+ function skillRoots() {
4771
+ return [BUILTIN_SKILLS_DIR, USER_SKILLS_DIR, path.join(process.cwd(), ".iola", "skills")];
4772
+ }
4773
+
4774
+ function listSkills(config = DEFAULT_AI_CONFIG) {
4775
+ const rows = [];
4776
+ for (const root of skillRoots()) {
4777
+ if (!existsSync(root)) continue;
4778
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
4779
+ const file = entry.isDirectory() ? path.join(root, entry.name, "SKILL.md") : entry.name.endsWith(".md") ? path.join(root, entry.name) : null;
4780
+ if (!file || !existsSync(file)) continue;
4781
+ const meta = readSkillMeta(file);
4782
+ rows.push({
4783
+ name: meta.name || path.basename(entry.name, ".md"),
4784
+ description: meta.description || "-",
4785
+ source: root === BUILTIN_SKILLS_DIR ? "builtin" : root === USER_SKILLS_DIR ? "user" : "project",
4786
+ file,
4787
+ enabled: isSkillEnabled(config, meta.name || path.basename(entry.name, ".md")),
4788
+ });
4789
+ }
4790
+ }
4791
+ return rows.sort((left, right) => left.name.localeCompare(right.name));
4792
+ }
4793
+
4794
+ function findSkill(name, config) {
4795
+ if (!name) return null;
4796
+ return listSkills(config).find((skill) => skill.name === name);
4797
+ }
4798
+
4799
+ function readSkillMeta(file) {
4800
+ try {
4801
+ const text = readFileSyncUtf8(file);
4802
+ const frontmatter = text.match(/^---\n([\s\S]*?)\n---/);
4803
+ const meta = {};
4804
+ if (frontmatter) {
4805
+ for (const line of frontmatter[1].split(/\r?\n/)) {
4806
+ const [key, ...parts] = line.split(":");
4807
+ if (key && parts.length > 0) meta[key.trim()] = parts.join(":").trim().replace(/^["']|["']$/g, "");
4808
+ }
4809
+ }
4810
+ return meta;
4811
+ } catch {
4812
+ return {};
4813
+ }
4814
+ }
4815
+
4816
+ function readFileSyncUtf8(file) {
4817
+ return readFileSync(file, "utf8");
4818
+ }
4819
+
4820
+ function isSkillEnabled(config, name) {
4821
+ return (config.skills?.enabled || []).includes(name);
4822
+ }
4823
+
4824
+ async function buildSkillsText(config) {
4825
+ const chunks = [];
4826
+ for (const skill of listSkills(config)) {
4827
+ if (!skill.enabled) continue;
4828
+ const text = await readFile(skill.file, "utf8");
4829
+ chunks.push(`## Skill: ${skill.name}\n${stripFrontmatter(text).trim()}`);
4830
+ }
4831
+ return chunks.join("\n\n").slice(0, 12000);
4832
+ }
4833
+
4834
+ function stripFrontmatter(text) {
4835
+ return String(text).replace(/^---\n[\s\S]*?\n---\n?/, "");
4836
+ }
4837
+
3952
4838
  async function getNvidiaGpu() {
3953
4839
  try {
3954
4840
  const { stdout } = await runCommand("nvidia-smi", [
@@ -4029,6 +4915,90 @@ async function probeEndpoint(url) {
4029
4915
  }
4030
4916
  }
4031
4917
 
4918
+ async function startDaemon(host, port) {
4919
+ const server = createServer(async (req, res) => {
4920
+ try {
4921
+ const url = new URL(req.url || "/", `http://${host}:${port}`);
4922
+ res.setHeader("content-type", "application/json; charset=utf-8");
4923
+
4924
+ if (req.method === "GET" && url.pathname === "/health") {
4925
+ res.end(JSON.stringify({ status: "running", endpoint: `http://${host}:${port}`, db: getDbStatus().status }));
4926
+ return;
4927
+ }
4928
+
4929
+ if (req.method === "GET" && url.pathname === "/status") {
4930
+ res.end(JSON.stringify({ status: "running", db: getDbStatus(), sync: getSyncStatus() }));
4931
+ return;
4932
+ }
4933
+
4934
+ if (req.method === "POST" && url.pathname === "/rpc") {
4935
+ const body = await readRequestBody(req);
4936
+ const payload = body ? JSON.parse(body) : {};
4937
+ const result = await executeRpc(payload.method, { ...(payload.params || {}), _: [] });
4938
+ res.end(JSON.stringify({ ok: true, result }));
4939
+ return;
4940
+ }
4941
+
4942
+ res.statusCode = 404;
4943
+ res.end(JSON.stringify({ ok: false, error: "not found" }));
4944
+ } catch (error) {
4945
+ res.statusCode = 500;
4946
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
4947
+ }
4948
+ });
4949
+
4950
+ await new Promise((resolve, reject) => {
4951
+ server.once("error", reject);
4952
+ server.listen(port, host, resolve);
4953
+ });
4954
+ console.log(`iola daemon запущен: http://${host}:${port}`);
4955
+ console.log("Остановить: Ctrl+C");
4956
+ await new Promise(() => {});
4957
+ }
4958
+
4959
+ function readRequestBody(req) {
4960
+ return new Promise((resolve, reject) => {
4961
+ let body = "";
4962
+ req.setEncoding("utf8");
4963
+ req.on("data", (chunk) => {
4964
+ body += chunk;
4965
+ if (body.length > 1024 * 1024) {
4966
+ reject(new Error("request body too large"));
4967
+ req.destroy();
4968
+ }
4969
+ });
4970
+ req.on("end", () => resolve(body));
4971
+ req.on("error", reject);
4972
+ });
4973
+ }
4974
+
4975
+ async function executeRpc(method, options = {}) {
4976
+ if (method === "status") {
4977
+ return { db: getDbStatus(), sync: getSyncStatus(), activeProfile: getActiveProfileName(await loadConfig()) };
4978
+ }
4979
+ if (method === "search") {
4980
+ await ensureLocalData();
4981
+ return searchLocalRecords(options.query || options.search || options._?.join(" ") || "", {
4982
+ dataset: options.dataset || "all",
4983
+ limit: Number(options.limit || 20),
4984
+ fts: options.fts !== false,
4985
+ });
4986
+ }
4987
+ if (method === "card") {
4988
+ await ensureLocalData();
4989
+ return findCard(options.query || options.search || options._?.join(" ") || "");
4990
+ }
4991
+ if (method === "quality") {
4992
+ await ensureLocalData();
4993
+ return runQuality(options.scope || "all");
4994
+ }
4995
+ if (method === "sync") {
4996
+ await assertPermission("sync");
4997
+ return syncDataset(options.dataset || "schools");
4998
+ }
4999
+ throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
5000
+ }
5001
+
4032
5002
  async function getLatestNpmVersion(packageName) {
4033
5003
  try {
4034
5004
  const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
@@ -4141,7 +5111,14 @@ async function saveConfig(value) {
4141
5111
  }
4142
5112
 
4143
5113
  async function writeConfig(value) {
5114
+ const errors = validateConfig(value);
5115
+ if (errors.length > 0) {
5116
+ throw new Error(`Конфигурация не сохранена: ${errors.join("; ")}`);
5117
+ }
4144
5118
  await mkdir(CONFIG_DIR, { recursive: true });
5119
+ if (existsSync(CONFIG_FILE)) {
5120
+ await copyFile(CONFIG_FILE, LAST_GOOD_CONFIG_FILE).catch(() => {});
5121
+ }
4145
5122
  await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
4146
5123
  }
4147
5124
 
@@ -4170,6 +5147,69 @@ function mergeConfig(base, override) {
4170
5147
  ...(override.ai?.profiles || {}),
4171
5148
  },
4172
5149
  },
5150
+ permissions: {
5151
+ ...base.permissions,
5152
+ ...(override.permissions || {}),
5153
+ localTools: {
5154
+ ...(base.permissions?.localTools || {}),
5155
+ ...(override.permissions?.localTools || {}),
5156
+ },
5157
+ },
5158
+ memory: {
5159
+ ...base.memory,
5160
+ ...(override.memory || {}),
5161
+ },
5162
+ skills: {
5163
+ ...base.skills,
5164
+ ...(override.skills || {}),
5165
+ },
5166
+ toolsets: {
5167
+ ...base.toolsets,
5168
+ ...(override.toolsets || {}),
5169
+ },
5170
+ daemon: {
5171
+ ...base.daemon,
5172
+ ...(override.daemon || {}),
5173
+ },
5174
+ cron: {
5175
+ ...base.cron,
5176
+ ...(override.cron || {}),
5177
+ },
5178
+ };
5179
+ }
5180
+
5181
+ function validateConfig(config) {
5182
+ const errors = [];
5183
+ if (!config || typeof config !== "object") errors.push("config must be object");
5184
+ if (!config.api?.baseUrl) errors.push("api.baseUrl обязателен");
5185
+ if (!config.api?.mcpBaseUrl) errors.push("api.mcpBaseUrl обязателен");
5186
+ if (!config.ai?.profiles || typeof config.ai.profiles !== "object") errors.push("ai.profiles обязателен");
5187
+ if (config.ai?.activeProfile && !config.ai.profiles?.[config.ai.activeProfile]) errors.push(`ai.activeProfile не найден в profiles: ${config.ai.activeProfile}`);
5188
+ for (const [name, profile] of Object.entries(config.ai?.profiles || {})) {
5189
+ if (!["ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
5190
+ if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
5191
+ }
5192
+ for (const tool of Object.keys(config.permissions?.localTools || {})) {
5193
+ if (!LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
5194
+ }
5195
+ for (const toolset of config.toolsets?.enabled || []) {
5196
+ if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
5197
+ }
5198
+ return errors;
5199
+ }
5200
+
5201
+ function configSchema() {
5202
+ return {
5203
+ type: "object",
5204
+ required: ["api", "ai"],
5205
+ properties: {
5206
+ api: { required: ["baseUrl", "mcpBaseUrl"] },
5207
+ ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
5208
+ permissions: { localTools: LOCAL_TOOLS, runtime: ["writeFiles", "sync", "externalApi", "externalAi", "codex"] },
5209
+ toolsets: { available: Object.keys(TOOLSETS) },
5210
+ skills: { enabled: "array of skill names" },
5211
+ daemon: { host: "127.0.0.1", port: DAEMON_PORT },
5212
+ },
4173
5213
  };
4174
5214
  }
4175
5215