@iola_adm/iola-cli 0.1.24 → 0.1.26

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,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
- import { appendFile, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { appendFile, copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline/promises";
@@ -17,8 +17,10 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
17
  const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
18
18
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
19
19
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
20
- const DB_SCHEMA_VERSION = 4;
20
+ const DB_SCHEMA_VERSION = 5;
21
21
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
22
+ const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
23
+ const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
22
24
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
23
25
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
24
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -43,9 +45,17 @@ const TOOLSETS = {
43
45
  description: "Внешние AI-провайдеры и Codex CLI.",
44
46
  permissions: { externalAi: true, codex: true },
45
47
  },
48
+ "local-files-read": {
49
+ description: "Чтение файлов, дерево папок и поиск внутри workspace.",
50
+ permissions: { readFiles: true, localTools: { files_tree: true, files_read: true, files_search: true } },
51
+ },
52
+ "local-files-write": {
53
+ description: "Запись и patch файлов внутри workspace с учетом approvals.",
54
+ permissions: { readFiles: true, writeFiles: true, editFiles: true, localTools: { files_write: true, files_patch: true } },
55
+ },
46
56
  safe: {
47
57
  description: "Безопасный режим: чтение данных без записи файлов и без sync.",
48
- permissions: { writeFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
58
+ permissions: { readFiles: true, writeFiles: false, editFiles: false, deleteFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
49
59
  },
50
60
  full: {
51
61
  description: "Полный локальный режим для доверенного пользователя.",
@@ -55,7 +65,10 @@ const TOOLSETS = {
55
65
  externalApi: true,
56
66
  externalAi: true,
57
67
  codex: true,
58
- localTools: Object.fromEntries(LOCAL_TOOLS.map((tool) => [tool, true])),
68
+ readFiles: true,
69
+ editFiles: true,
70
+ deleteFiles: false,
71
+ localTools: Object.fromEntries(ALL_LOCAL_TOOLS.map((tool) => [tool, true])),
59
72
  },
60
73
  },
61
74
  };
@@ -109,8 +122,16 @@ const DEFAULT_AI_CONFIG = {
109
122
  export_data: true,
110
123
  run_report: true,
111
124
  save_view: true,
125
+ files_tree: false,
126
+ files_read: false,
127
+ files_search: false,
128
+ files_write: false,
129
+ files_patch: false,
112
130
  },
131
+ readFiles: false,
113
132
  writeFiles: true,
133
+ editFiles: false,
134
+ deleteFiles: false,
114
135
  sync: true,
115
136
  externalApi: true,
116
137
  externalAi: true,
@@ -119,6 +140,13 @@ const DEFAULT_AI_CONFIG = {
119
140
  toolsets: {
120
141
  enabled: ["data-read", "reports", "sync", "ai"],
121
142
  },
143
+ files: {
144
+ mode: "locked",
145
+ approvals: "on-write",
146
+ workspaceRoot: ".",
147
+ maxReadBytes: 200000,
148
+ blockedGlobs: [".env", "*.pem", "*.key", "secrets", ".git", ".ssh", "AppData", "node_modules"],
149
+ },
122
150
  memory: {
123
151
  enabled: true,
124
152
  suggestions: true,
@@ -213,6 +241,14 @@ const COMMANDS = new Map([
213
241
  ["context", handleContext],
214
242
  ["skills", handleSkills],
215
243
  ["tools", handleTools],
244
+ ["files", handleFiles],
245
+ ["workspace", handleWorkspace],
246
+ ["tasks", handleTasks],
247
+ ["artifacts", handleArtifacts],
248
+ ["snapshot", handleSnapshot],
249
+ ["trace", handleTrace],
250
+ ["policy", handlePolicy],
251
+ ["export", handleExport],
216
252
  ["cron", handleCron],
217
253
  ["daemon", handleDaemon],
218
254
  ["rpc", handleRpc],
@@ -315,6 +351,14 @@ Usage:
315
351
  iola context list|show|init
316
352
  iola skills list|show|paths|enable|disable
317
353
  iola tools list|toolsets|enable|disable|profile
354
+ iola files status|mode|approvals|tree|read|search|write|patch
355
+ iola workspace init|status|use|list
356
+ iola tasks list|add|done|run
357
+ iola artifacts list|show|open
358
+ iola snapshot create|list|restore
359
+ iola trace last|show
360
+ iola policy use safe|analyst|developer|full
361
+ iola export REPORT --format docx|xlsx --output FILE
318
362
  iola cron list|add|delete|run|tick
319
363
  iola daemon start|status
320
364
  iola rpc call METHOD [ARGS] [--json]
@@ -344,7 +388,7 @@ Usage:
344
388
  iola config set api.mcpBaseUrl URL
345
389
  iola config reset
346
390
  iola update
347
- iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
391
+ iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--files] [--plan] [--trace] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
348
392
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
349
393
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
350
394
  iola ai context TEXT [--json]
@@ -555,6 +599,41 @@ async function handleAgentLine(line, state) {
555
599
  return false;
556
600
  }
557
601
 
602
+ if (command === "files") {
603
+ await handleFiles(args);
604
+ return false;
605
+ }
606
+
607
+ if (command === "workspace") {
608
+ await handleWorkspace(args);
609
+ return false;
610
+ }
611
+
612
+ if (command === "tasks" || command === "todos") {
613
+ await handleTasks(args);
614
+ return false;
615
+ }
616
+
617
+ if (command === "artifacts") {
618
+ await handleArtifacts(args);
619
+ return false;
620
+ }
621
+
622
+ if (command === "snapshot") {
623
+ await handleSnapshot(args);
624
+ return false;
625
+ }
626
+
627
+ if (command === "trace") {
628
+ await handleTrace(args);
629
+ return false;
630
+ }
631
+
632
+ if (command === "policy") {
633
+ await handlePolicy(args);
634
+ return false;
635
+ }
636
+
558
637
  if (command === "cron") {
559
638
  await handleCron(args);
560
639
  return false;
@@ -677,6 +756,14 @@ async function handleAgentLine(line, state) {
677
756
  wiki: ["wiki", args],
678
757
  context: ["context", args],
679
758
  skills: ["skills", args],
759
+ files: ["files", args],
760
+ workspace: ["workspace", args],
761
+ tasks: ["tasks", args],
762
+ todos: ["tasks", args],
763
+ artifacts: ["artifacts", args],
764
+ snapshot: ["snapshot", args],
765
+ trace: ["trace", args],
766
+ policy: ["policy", args],
680
767
  cron: ["cron", args],
681
768
  daemon: ["daemon", args],
682
769
  rpc: ["rpc", args],
@@ -724,6 +811,12 @@ function printAgentHelp() {
724
811
  /skills list
725
812
  /permissions
726
813
  /tools
814
+ /files status
815
+ /workspace status
816
+ /tasks list
817
+ /artifacts list
818
+ /trace last
819
+ /policy use safe
727
820
  /cron list
728
821
  /daemon status
729
822
  /rpc call status
@@ -1453,6 +1546,8 @@ async function handleWiki(args) {
1453
1546
  ["AI-профили", `${base}/AI-профили`],
1454
1547
  ["Локальный инструментальный агент", `${base}/Локальный-инструментальный-агент`],
1455
1548
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1549
+ ["Локальные файлы", `${base}/Локальные-файлы`],
1550
+ ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1456
1551
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
1457
1552
  ["Контекст и память", `${base}/Контекст-и-память`],
1458
1553
  ["Команды", `${base}/Команды`],
@@ -1617,6 +1712,242 @@ async function handleTools(args) {
1617
1712
  throw new Error("Команды tools: list, toolsets, enable NAME, disable NAME, profile NAME.");
1618
1713
  }
1619
1714
 
1715
+ async function handleFiles(args) {
1716
+ const [action = "status", target, ...rest] = args;
1717
+ const options = parseOptions(rest);
1718
+ const config = await loadConfig();
1719
+
1720
+ if (action === "status") {
1721
+ printKeyValue({
1722
+ mode: config.files?.mode || "locked",
1723
+ approvals: config.files?.approvals || "on-write",
1724
+ workspaceRoot: resolveWorkspaceRoot(config),
1725
+ maxReadBytes: config.files?.maxReadBytes || 200000,
1726
+ readFiles: config.permissions?.readFiles ? "allow" : "deny",
1727
+ writeFiles: config.permissions?.writeFiles ? "allow" : "deny",
1728
+ editFiles: config.permissions?.editFiles ? "allow" : "deny",
1729
+ });
1730
+ return;
1731
+ }
1732
+
1733
+ if (action === "mode") {
1734
+ if (!["locked", "read-only", "workspace-write", "full-access"].includes(target)) {
1735
+ throw new Error("Режимы файлов: locked, read-only, workspace-write, full-access.");
1736
+ }
1737
+ await setFilesMode(target, config);
1738
+ console.log(`Файловый режим: ${target}`);
1739
+ return;
1740
+ }
1741
+
1742
+ if (action === "approvals") {
1743
+ if (!["never", "on-write", "on-danger", "always"].includes(target)) {
1744
+ throw new Error("Политики approvals: never, on-write, on-danger, always.");
1745
+ }
1746
+ await saveConfig({ files: { ...(config.files || {}), approvals: target } });
1747
+ console.log(`Файловые подтверждения: ${target}`);
1748
+ return;
1749
+ }
1750
+
1751
+ if (action === "tree") {
1752
+ const rows = await filesTree(target || ".", options);
1753
+ if (options.json) printJson(rows);
1754
+ else printTable(rows, [["type", "Тип"], ["path", "Путь"], ["size", "Размер"]]);
1755
+ return;
1756
+ }
1757
+
1758
+ if (action === "read") {
1759
+ if (!target) throw new Error("Пример: iola files read README.md");
1760
+ console.log(await filesRead(target, options));
1761
+ return;
1762
+ }
1763
+
1764
+ if (action === "search") {
1765
+ const query = target;
1766
+ if (!query) throw new Error('Пример: iola files search "Петрова" --path .');
1767
+ const rows = await filesSearch(query, options);
1768
+ if (options.json) printJson(rows);
1769
+ else printTable(rows, [["file", "Файл"], ["line", "Строка"], ["text", "Текст"]]);
1770
+ return;
1771
+ }
1772
+
1773
+ if (action === "write") {
1774
+ if (!target) throw new Error('Пример: iola files write report.md --text "..."');
1775
+ const text = options.text ?? rest.join(" ");
1776
+ if (!text) throw new Error('Для записи нужен --text "..." или текст после пути.');
1777
+ await filesWrite(target, text, { append: Boolean(options.append) });
1778
+ console.log(`Файл записан: ${target}`);
1779
+ return;
1780
+ }
1781
+
1782
+ if (action === "patch") {
1783
+ if (!target) throw new Error('Пример: iola files patch README.md --search old --replace new');
1784
+ if (!options.search || options.replace === undefined) throw new Error("Для patch нужны --search и --replace.");
1785
+ const result = await filesPatch(target, options.search, options.replace);
1786
+ printKeyValue(result);
1787
+ return;
1788
+ }
1789
+
1790
+ throw new Error("Команды files: status, mode MODE, approvals POLICY, tree [PATH], read FILE, search TEXT, write FILE --text TEXT, patch FILE --search OLD --replace NEW.");
1791
+ }
1792
+
1793
+ async function handleWorkspace(args) {
1794
+ const [action = "status", nameOrPath] = args;
1795
+ const config = await loadConfig();
1796
+ if (action === "status") {
1797
+ printKeyValue({ root: resolveWorkspaceRoot(config), fileMode: config.files?.mode, approvals: config.files?.approvals });
1798
+ return;
1799
+ }
1800
+ if (action === "init") {
1801
+ await handleContext(["init"]);
1802
+ await mkdir(path.join(process.cwd(), ".iola", "skills"), { recursive: true });
1803
+ console.log(`Workspace готов: ${process.cwd()}`);
1804
+ return;
1805
+ }
1806
+ if (action === "list") {
1807
+ const rows = Object.entries(config.workspaces || {}).map(([name, value]) => ({ name, path: value.path }));
1808
+ printTable(rows, [["name", "Workspace"], ["path", "Путь"]]);
1809
+ return;
1810
+ }
1811
+ if (action === "use") {
1812
+ if (!nameOrPath) throw new Error("Пример: iola workspace use D:\\project");
1813
+ const root = path.resolve(nameOrPath);
1814
+ const name = path.basename(root);
1815
+ await saveConfig({ workspaces: { ...(config.workspaces || {}), [name]: { path: root } }, files: { ...(config.files || {}), workspaceRoot: root } });
1816
+ console.log(`Workspace выбран: ${root}`);
1817
+ return;
1818
+ }
1819
+ throw new Error("Команды workspace: init, status, list, use PATH.");
1820
+ }
1821
+
1822
+ async function handleTasks(args) {
1823
+ const [action = "list", idOrText, ...rest] = args;
1824
+ if (action === "list" || action === "ls") {
1825
+ printTable(listTasks(), [["id", "ID"], ["status", "Статус"], ["title", "Задача"], ["command", "Команда"]]);
1826
+ return;
1827
+ }
1828
+ if (action === "add") {
1829
+ const title = [idOrText, ...rest].filter(Boolean).join(" ");
1830
+ if (!title) throw new Error('Пример: iola tasks add "проверить школы"');
1831
+ const id = addTask(title);
1832
+ console.log(`Задача добавлена: ${id}`);
1833
+ return;
1834
+ }
1835
+ if (action === "done") {
1836
+ updateTaskStatus(Number(idOrText), "done");
1837
+ console.log(`Задача выполнена: ${idOrText}`);
1838
+ return;
1839
+ }
1840
+ if (action === "run") {
1841
+ const task = getTask(Number(idOrText));
1842
+ if (!task.command) throw new Error("У задачи нет команды. Добавьте command через SQLite пока не реализовано редактирование.");
1843
+ await main(splitCommandLine(task.command));
1844
+ updateTaskStatus(task.id, "done");
1845
+ return;
1846
+ }
1847
+ throw new Error("Команды tasks: list, add TEXT, done ID, run ID.");
1848
+ }
1849
+
1850
+ async function handleArtifacts(args) {
1851
+ const [action = "list", id] = args;
1852
+ if (action === "list" || action === "ls") {
1853
+ printTable(listArtifacts(), [["id", "ID"], ["kind", "Тип"], ["title", "Название"], ["file", "Файл"], ["created_at", "Дата"]]);
1854
+ return;
1855
+ }
1856
+ if (action === "show") {
1857
+ const artifact = getArtifact(Number(id));
1858
+ if (artifact.file && existsSync(artifact.file)) console.log(await readFile(artifact.file, "utf8"));
1859
+ else printJson(artifact);
1860
+ return;
1861
+ }
1862
+ if (action === "open") {
1863
+ const artifact = getArtifact(Number(id));
1864
+ if (!artifact.file) throw new Error("У artifact нет файла.");
1865
+ await openUrl(artifact.file);
1866
+ return;
1867
+ }
1868
+ throw new Error("Команды artifacts: list, show ID, open ID.");
1869
+ }
1870
+
1871
+ async function handleSnapshot(args) {
1872
+ const [action = "list", id] = args;
1873
+ if (action === "create") {
1874
+ const result = await createSnapshot();
1875
+ printKeyValue(result);
1876
+ return;
1877
+ }
1878
+ if (action === "list" || action === "ls") {
1879
+ printTable(listSnapshots(), [["id", "ID"], ["workspace", "Workspace"], ["path", "Папка"], ["created_at", "Дата"]]);
1880
+ return;
1881
+ }
1882
+ if (action === "restore") {
1883
+ await restoreSnapshot(Number(id));
1884
+ console.log(`Snapshot восстановлен: ${id}`);
1885
+ return;
1886
+ }
1887
+ throw new Error("Команды snapshot: create, list, restore ID.");
1888
+ }
1889
+
1890
+ async function handleTrace(args) {
1891
+ const [action = "last", id] = args;
1892
+ if (action === "last") {
1893
+ printTable(listTrace(Number(id || 20)), [["id", "ID"], ["run_id", "Run"], ["tool", "Tool"], ["status", "Статус"], ["summary", "Сводка"]]);
1894
+ return;
1895
+ }
1896
+ if (action === "show") {
1897
+ printJson(getTraceRun(id));
1898
+ return;
1899
+ }
1900
+ throw new Error("Команды trace: last [LIMIT], show RUN_ID.");
1901
+ }
1902
+
1903
+ async function handlePolicy(args) {
1904
+ const [action = "list", name] = args;
1905
+ const policies = {
1906
+ safe: { fileMode: "read-only", approvals: "always", toolProfile: "safe" },
1907
+ analyst: { fileMode: "read-only", approvals: "on-danger", toolsets: ["data-read", "reports", "sync", "ai", "local-files-read"] },
1908
+ developer: { fileMode: "workspace-write", approvals: "on-write", toolsets: ["data-read", "reports", "sync", "ai", "local-files-read", "local-files-write"] },
1909
+ full: { fileMode: "full-access", approvals: "on-danger", toolProfile: "full" },
1910
+ };
1911
+ if (action === "list") {
1912
+ printTable(Object.entries(policies).map(([policy, value]) => ({ policy, ...value, toolsets: value.toolsets?.join(", ") || value.toolProfile })), [["policy", "Policy"], ["fileMode", "Files"], ["approvals", "Approvals"], ["toolsets", "Toolsets"]]);
1913
+ return;
1914
+ }
1915
+ if (action === "use") {
1916
+ const policy = policies[name];
1917
+ if (!policy) throw new Error(`Policy неизвестна: ${Object.keys(policies).join(", ")}`);
1918
+ const config = await loadConfig();
1919
+ if (policy.toolProfile) {
1920
+ await handleTools(["profile", policy.toolProfile]);
1921
+ } else {
1922
+ await saveConfig({ toolsets: { ...(config.toolsets || {}), enabled: policy.toolsets } });
1923
+ }
1924
+ const next = await loadConfig();
1925
+ await saveConfig({ files: { ...(next.files || {}), mode: policy.fileMode, approvals: policy.approvals } });
1926
+ await setFilesMode(policy.fileMode, await loadConfig());
1927
+ console.log(`Policy применена: ${name}`);
1928
+ return;
1929
+ }
1930
+ throw new Error("Команды policy: list, use NAME.");
1931
+ }
1932
+
1933
+ async function handleExport(args) {
1934
+ const [name] = args;
1935
+ const options = parseOptions(args.slice(1));
1936
+ const format = options.format || "xlsx";
1937
+ const output = options.output || `${name || "iola-export"}.${format}`;
1938
+ await ensureLocalData();
1939
+ const rows = buildReportRows(name || "education-contacts");
1940
+ if (format === "xlsx") {
1941
+ await writeFile(output, toSpreadsheetXml(rows), "utf8");
1942
+ } else if (format === "docx" || format === "doc") {
1943
+ await writeFile(output, toWordHtml(name || "Отчет", rows), "utf8");
1944
+ } else {
1945
+ await outputData(rows, { output }, format);
1946
+ }
1947
+ saveArtifact("export", name || "export", output, { format, rows: rows.length });
1948
+ console.log(`Экспорт создан: ${output}`);
1949
+ }
1950
+
1620
1951
  async function handleCron(args) {
1621
1952
  const [action = "list", ...rest] = args;
1622
1953
  const options = parseOptions(rest);
@@ -1722,6 +2053,14 @@ async function handlePermissions(args) {
1722
2053
  value: permissions.localTools?.[tool] === false ? "deny" : "allow",
1723
2054
  scope: "local-tool",
1724
2055
  })),
2056
+ ...FILE_TOOLS.map((tool) => ({
2057
+ permission: `localTools.${tool}`,
2058
+ value: permissions.localTools?.[tool] === true ? "allow" : "deny",
2059
+ scope: "file-tool",
2060
+ })),
2061
+ { permission: "readFiles", value: permissions.readFiles === true ? "allow" : "deny", scope: "filesystem" },
2062
+ { permission: "editFiles", value: permissions.editFiles === true ? "allow" : "deny", scope: "filesystem" },
2063
+ { permission: "deleteFiles", value: permissions.deleteFiles === true ? "allow" : "deny", scope: "filesystem" },
1725
2064
  { permission: "writeFiles", value: permissions.writeFiles === false ? "deny" : "allow", scope: "runtime" },
1726
2065
  { permission: "sync", value: permissions.sync === false ? "deny" : "allow", scope: "runtime" },
1727
2066
  { permission: "externalApi", value: permissions.externalApi === false ? "deny" : "allow", scope: "network" },
@@ -1743,12 +2082,12 @@ async function handlePermissions(args) {
1743
2082
  const allow = action === "allow";
1744
2083
  const next = { ...(config.permissions || DEFAULT_AI_CONFIG.permissions) };
1745
2084
  next.localTools = { ...(next.localTools || {}) };
1746
- if (LOCAL_TOOLS.includes(name)) {
2085
+ if (ALL_LOCAL_TOOLS.includes(name)) {
1747
2086
  next.localTools[name] = allow;
1748
2087
  } else if (name in DEFAULT_AI_CONFIG.permissions) {
1749
2088
  next[name] = allow;
1750
2089
  } else {
1751
- throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...LOCAL_TOOLS, "writeFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
2090
+ throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...ALL_LOCAL_TOOLS, "readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
1752
2091
  }
1753
2092
  await saveConfig({ permissions: next });
1754
2093
  console.log(`${name}: ${allow ? "allow" : "deny"}`);
@@ -2085,6 +2424,11 @@ async function handleView(args) {
2085
2424
  async function handleReport(args) {
2086
2425
  const [name] = args;
2087
2426
  await ensureLocalData();
2427
+ const options = parseOptions(args.slice(1));
2428
+ if (options.format === "docx" || options.format === "xlsx") {
2429
+ await handleExport([name || "education-contacts", "--format", options.format, "--output", options.output || `${name || "report"}.${options.format}`]);
2430
+ return;
2431
+ }
2088
2432
  if (name === "schools-summary") {
2089
2433
  printTable(getLocalSummaryRows("schools"), [["metric", "Показатель"], ["value", "Значение"]]);
2090
2434
  return;
@@ -2104,6 +2448,43 @@ async function handleReport(args) {
2104
2448
  throw new Error("Отчеты: schools-summary, education-contacts, missing-phones, licenses.");
2105
2449
  }
2106
2450
 
2451
+ function buildReportRows(name) {
2452
+ const reportName = name || "education-contacts";
2453
+ if (reportName === "schools-summary") return getLocalSummaryRows("schools");
2454
+ if (reportName === "missing-phones") return searchLocalRecords("", { dataset: "all", limit: 500 }).filter((item) => !item.phone || item.phone === "-");
2455
+ if (reportName === "licenses") return searchLocalRecords("", { dataset: "all", limit: 500 }).map((item) => ({ name: item.name, license_number: item.license_number, license_status: item.license_status }));
2456
+ return searchLocalRecords("", { dataset: "all", limit: 500 });
2457
+ }
2458
+
2459
+ function toSpreadsheetXml(rows) {
2460
+ const columns = Object.keys(rows[0] || { empty: "" });
2461
+ const cell = (value) => `<Cell><Data ss:Type="String">${escapeXml(value ?? "")}</Data></Cell>`;
2462
+ return `<?xml version="1.0"?>
2463
+ <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
2464
+ <Worksheet ss:Name="IOLA"><Table>
2465
+ <Row>${columns.map(cell).join("")}</Row>
2466
+ ${rows.map((row) => `<Row>${columns.map((column) => cell(row[column])).join("")}</Row>`).join("\n")}
2467
+ </Table></Worksheet></Workbook>`;
2468
+ }
2469
+
2470
+ function toWordHtml(title, rows) {
2471
+ const columns = Object.keys(rows[0] || { empty: "" });
2472
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body>
2473
+ <h1>${escapeHtml(title)}</h1>
2474
+ <table border="1" cellspacing="0" cellpadding="4">
2475
+ <thead><tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead>
2476
+ <tbody>${rows.map((row) => `<tr>${columns.map((column) => `<td>${escapeHtml(row[column] ?? "")}</td>`).join("")}</tr>`).join("\n")}</tbody>
2477
+ </table></body></html>`;
2478
+ }
2479
+
2480
+ function escapeXml(value) {
2481
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2482
+ }
2483
+
2484
+ function escapeHtml(value) {
2485
+ return escapeXml(value);
2486
+ }
2487
+
2107
2488
  async function handlePrivacy() {
2108
2489
  printKeyValue({
2109
2490
  config: CONFIG_FILE,
@@ -2782,6 +3163,38 @@ function initDatabase() {
2782
3163
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
2783
3164
  );
2784
3165
  CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled ON cron_jobs(enabled, last_run_at);
3166
+ CREATE TABLE IF NOT EXISTS tasks (
3167
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3168
+ title TEXT NOT NULL,
3169
+ command TEXT,
3170
+ status TEXT NOT NULL DEFAULT 'open',
3171
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3172
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
3173
+ );
3174
+ CREATE TABLE IF NOT EXISTS artifacts (
3175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3176
+ kind TEXT NOT NULL,
3177
+ title TEXT NOT NULL,
3178
+ file TEXT,
3179
+ meta_json TEXT NOT NULL DEFAULT '{}',
3180
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
3181
+ );
3182
+ CREATE TABLE IF NOT EXISTS tool_traces (
3183
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3184
+ run_id TEXT NOT NULL,
3185
+ tool TEXT NOT NULL,
3186
+ args_json TEXT NOT NULL,
3187
+ status TEXT NOT NULL,
3188
+ summary TEXT,
3189
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
3190
+ );
3191
+ CREATE INDEX IF NOT EXISTS idx_tool_traces_run_id ON tool_traces(run_id, id);
3192
+ CREATE TABLE IF NOT EXISTS snapshots (
3193
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3194
+ workspace TEXT NOT NULL,
3195
+ path TEXT NOT NULL,
3196
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
3197
+ );
2785
3198
  `);
2786
3199
  rebuildFtsIfEmpty(db);
2787
3200
  db.prepare(`
@@ -2834,6 +3247,8 @@ function getDbStatus() {
2834
3247
  const memory = db.prepare("SELECT COUNT(*) AS count FROM memory").get();
2835
3248
  const memorySuggestions = db.prepare("SELECT COUNT(*) AS count FROM memory_suggestions WHERE status = 'pending'").get();
2836
3249
  const cron = db.prepare("SELECT COUNT(*) AS count FROM cron_jobs").get();
3250
+ const tasks = db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE status != 'done'").get();
3251
+ const artifacts = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get();
2837
3252
  return {
2838
3253
  status: "ok",
2839
3254
  file: DB_FILE,
@@ -2845,6 +3260,8 @@ function getDbStatus() {
2845
3260
  memory: memory?.count ?? 0,
2846
3261
  memory_suggestions: memorySuggestions?.count ?? 0,
2847
3262
  cron_jobs: cron?.count ?? 0,
3263
+ open_tasks: tasks?.count ?? 0,
3264
+ artifacts: artifacts?.count ?? 0,
2848
3265
  };
2849
3266
  } finally {
2850
3267
  db.close();
@@ -3910,8 +4327,17 @@ function resolveAiProfile(config, options = {}) {
3910
4327
  async function localToolAsk(question, providerConfig, options) {
3911
4328
  await ensureLocalData();
3912
4329
  const plan = await buildLocalToolPlan(question, providerConfig, options);
3913
- const validated = validateToolPlan(plan);
3914
- const result = await executeToolPlan(validated);
4330
+ const validated = validateToolPlan(plan, options);
4331
+ if (options.plan) {
4332
+ printToolPlan(validated);
4333
+ const shouldRun = await confirm("Выполнить план? [y/N] ");
4334
+ if (!shouldRun) {
4335
+ saveArtifact("plan", question.slice(0, 80), "", { plan: validated });
4336
+ return "План построен, выполнение отменено.";
4337
+ }
4338
+ }
4339
+ const runId = `run-${Date.now()}-${Math.random().toString(16).slice(2)}`;
4340
+ const result = await executeToolPlan(validated, { ...options, runId });
3915
4341
  const answer = formatToolResult(result, options);
3916
4342
 
3917
4343
  if (!options["no-history"] && isFeatureEnabled("sqlite-history")) {
@@ -3925,7 +4351,8 @@ async function localToolAsk(question, providerConfig, options) {
3925
4351
  });
3926
4352
  }
3927
4353
 
3928
- emitEvent(options, "tool_plan", { plan: validated });
4354
+ emitEvent(options, "tool_plan", { plan: validated, runId });
4355
+ saveArtifact("tool-result", question.slice(0, 80), "", { runId, plan: validated, outputs: result.outputs });
3929
4356
  if (options.output) {
3930
4357
  await assertPermission("writeFiles");
3931
4358
  await writeFile(options.output, answer, "utf8");
@@ -3938,12 +4365,20 @@ async function localToolAsk(question, providerConfig, options) {
3938
4365
  return answer;
3939
4366
  }
3940
4367
 
4368
+ function printToolPlan(plan) {
4369
+ console.log("План выполнения:");
4370
+ plan.steps.forEach((step, index) => {
4371
+ console.log(`${index + 1}. ${step.tool} ${JSON.stringify(step.args || {})}`);
4372
+ });
4373
+ }
4374
+
3941
4375
  async function buildLocalToolPlan(question, providerConfig, options) {
3942
4376
  const mode = options.reasoning || "verify";
3943
4377
  const prompt = [
3944
4378
  "Ты планировщик CLI iola. Верни только JSON.",
3945
- "Доступные tools: search_local, get_card, export_data, run_report, save_view.",
4379
+ `Доступные tools: ${availableToolNames(options).join(", ")}.`,
3946
4380
  "Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
4381
+ options.files ? "Файловые tools: files_tree {path,depth,limit}, files_read {path}, files_search {query,path}, files_write {path,text}, files_patch {path,search,replace}." : "",
3947
4382
  "Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
3948
4383
  `Вопрос: ${question}`,
3949
4384
  ].join("\n");
@@ -3952,11 +4387,11 @@ async function buildLocalToolPlan(question, providerConfig, options) {
3952
4387
  const raw = await callOllama(providerConfig, [{ role: "user", content: prompt }]);
3953
4388
  const parsed = parseJsonObject(raw);
3954
4389
  if (mode === "vote") {
3955
- return chooseBestPlan([parsed, inferToolPlan(question)]);
4390
+ return chooseBestPlan([parsed, inferToolPlan(question, options)], options);
3956
4391
  }
3957
4392
  return parsed;
3958
4393
  } catch {
3959
- return inferToolPlan(question);
4394
+ return inferToolPlan(question, options);
3960
4395
  }
3961
4396
  }
3962
4397
 
@@ -3966,7 +4401,7 @@ function parseJsonObject(text) {
3966
4401
  return JSON.parse(match[0]);
3967
4402
  }
3968
4403
 
3969
- function inferToolPlan(question) {
4404
+ function inferToolPlan(question, options = {}) {
3970
4405
  const normalized = question.toLocaleLowerCase("ru-RU");
3971
4406
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
3972
4407
  const steps = [];
@@ -3979,13 +4414,20 @@ function inferToolPlan(question) {
3979
4414
  if (normalized.includes("csv") || normalized.includes("выгруз")) {
3980
4415
  steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
3981
4416
  }
4417
+ if (options.files || normalized.includes("файл") || normalized.includes("папк") || normalized.includes("readme")) {
4418
+ if (normalized.includes("найди") || normalized.includes("поиск")) {
4419
+ steps.unshift({ tool: "files_search", args: { query: question, path: "." } });
4420
+ } else {
4421
+ steps.unshift({ tool: "files_tree", args: { path: ".", depth: 2, limit: 80 } });
4422
+ }
4423
+ }
3982
4424
  return { steps };
3983
4425
  }
3984
4426
 
3985
- function chooseBestPlan(plans) {
4427
+ function chooseBestPlan(plans, options = {}) {
3986
4428
  return plans.find((plan) => {
3987
4429
  try {
3988
- validateToolPlan(plan);
4430
+ validateToolPlan(plan, options);
3989
4431
  return true;
3990
4432
  } catch {
3991
4433
  return false;
@@ -3993,8 +4435,8 @@ function chooseBestPlan(plans) {
3993
4435
  }) || plans.at(-1);
3994
4436
  }
3995
4437
 
3996
- function validateToolPlan(plan) {
3997
- const allowed = new Set(LOCAL_TOOLS);
4438
+ function validateToolPlan(plan, options = {}) {
4439
+ const allowed = new Set(availableToolNames(options));
3998
4440
  if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
3999
4441
  for (const step of plan.steps) {
4000
4442
  if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
@@ -4002,32 +4444,66 @@ function validateToolPlan(plan) {
4002
4444
  return plan;
4003
4445
  }
4004
4446
 
4005
- async function executeToolPlan(plan) {
4447
+ function availableToolNames(options = {}) {
4448
+ return options.files ? ALL_LOCAL_TOOLS : LOCAL_TOOLS;
4449
+ }
4450
+
4451
+ async function executeToolPlan(plan, options = {}) {
4006
4452
  let current = [];
4007
4453
  const outputs = [];
4008
4454
  for (const step of plan.steps) {
4455
+ let status = "ok";
4456
+ let summary = "";
4009
4457
  await assertPermission(step.tool);
4010
4458
  await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
4011
- if (step.tool === "search_local") {
4012
- current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
4013
- outputs.push({ tool: step.tool, rows: current.length });
4014
- } else if (step.tool === "get_card") {
4015
- const card = findCard(step.args?.query || "");
4016
- current = card ? [card] : [];
4017
- outputs.push({ tool: step.tool, rows: current.length });
4018
- } else if (step.tool === "run_report") {
4019
- current = runQuality(step.args?.name || "all");
4020
- outputs.push({ tool: step.tool, rows: current.length });
4021
- } else if (step.tool === "save_view") {
4022
- saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
4023
- outputs.push({ tool: step.tool, saved: step.args?.name });
4024
- } else if (step.tool === "export_data") {
4025
- await assertPermission("writeFiles");
4026
- await runHooks("BeforeExport", { output: step.args?.output || "iola-export.csv", format: step.args?.format || "csv", rows: current.length });
4027
- const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
4028
- await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
4029
- outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
4459
+ try {
4460
+ if (step.tool === "search_local") {
4461
+ current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
4462
+ outputs.push({ tool: step.tool, rows: current.length });
4463
+ } else if (step.tool === "get_card") {
4464
+ const card = findCard(step.args?.query || "");
4465
+ current = card ? [card] : [];
4466
+ outputs.push({ tool: step.tool, rows: current.length });
4467
+ } else if (step.tool === "run_report") {
4468
+ current = runQuality(step.args?.name || "all");
4469
+ outputs.push({ tool: step.tool, rows: current.length });
4470
+ } else if (step.tool === "save_view") {
4471
+ saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
4472
+ outputs.push({ tool: step.tool, saved: step.args?.name });
4473
+ } else if (step.tool === "export_data") {
4474
+ await assertPermission("writeFiles");
4475
+ await runHooks("BeforeExport", { output: step.args?.output || "iola-export.csv", format: step.args?.format || "csv", rows: current.length });
4476
+ const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
4477
+ await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
4478
+ saveArtifact("export", step.args?.output || "iola-export.csv", step.args?.output || "iola-export.csv", { rows: current.length });
4479
+ outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
4480
+ } else if (step.tool === "files_tree") {
4481
+ current = await filesTree(step.args?.path || ".", step.args || {});
4482
+ outputs.push({ tool: step.tool, rows: current.length });
4483
+ } else if (step.tool === "files_read") {
4484
+ const text = await filesRead(step.args?.path || step.args?.file || ".", step.args || {});
4485
+ current = [{ path: step.args?.path || step.args?.file || ".", text }];
4486
+ outputs.push({ tool: step.tool, bytes: text.length });
4487
+ } else if (step.tool === "files_search") {
4488
+ current = await filesSearch(step.args?.query || "", { path: step.args?.path || ".", limit: step.args?.limit || 50 });
4489
+ outputs.push({ tool: step.tool, rows: current.length });
4490
+ } else if (step.tool === "files_write") {
4491
+ await filesWrite(step.args?.path || step.args?.file, step.args?.text || "", { append: Boolean(step.args?.append) });
4492
+ current = [{ path: step.args?.path || step.args?.file, status: "written" }];
4493
+ outputs.push({ tool: step.tool, output: step.args?.path || step.args?.file, rows: 1 });
4494
+ } else if (step.tool === "files_patch") {
4495
+ const result = await filesPatch(step.args?.path || step.args?.file, step.args?.search || "", step.args?.replace || "");
4496
+ current = [result];
4497
+ outputs.push({ tool: step.tool, output: result.path, replacements: result.replacements });
4498
+ }
4499
+ summary = `rows=${current.length}`;
4500
+ } catch (error) {
4501
+ status = "error";
4502
+ summary = error instanceof Error ? error.message : String(error);
4503
+ recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
4504
+ throw error;
4030
4505
  }
4506
+ recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
4031
4507
  await runHooks("AfterTool", { tool: step.tool, rows: current.length });
4032
4508
  }
4033
4509
  return { rows: current, outputs };
@@ -4071,7 +4547,7 @@ async function runHooks(event, payload = {}) {
4071
4547
  async function assertPermission(name) {
4072
4548
  const config = await loadConfig();
4073
4549
  const permissions = applyToolsetPermissions(config.permissions || DEFAULT_AI_CONFIG.permissions, config.toolsets?.enabled || []);
4074
- if (LOCAL_TOOLS.includes(name)) {
4550
+ if (ALL_LOCAL_TOOLS.includes(name)) {
4075
4551
  if (permissions.localTools?.[name] === false) {
4076
4552
  throw new Error(`Tool запрещен политикой permissions: ${name}`);
4077
4553
  }
@@ -4584,12 +5060,12 @@ function parseOptions(args) {
4584
5060
 
4585
5061
  for (let index = 0; index < args.length; index += 1) {
4586
5062
  const arg = args[index];
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") {
5063
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
4588
5064
  result[arg.slice(2)] = true;
4589
5065
  } else if (arg === "--check" || arg === "--upgrade-node") {
4590
5066
  result.check = true;
4591
5067
  result[arg.slice(2)] = true;
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") {
5068
+ } 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 === "--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") {
4593
5069
  result[arg.slice(2)] = args[index + 1];
4594
5070
  index += 1;
4595
5071
  } else {
@@ -4972,6 +5448,356 @@ function readRequestBody(req) {
4972
5448
  });
4973
5449
  }
4974
5450
 
5451
+ async function setFilesMode(mode, config = null) {
5452
+ const current = config || await loadConfig();
5453
+ const localTools = { ...(current.permissions?.localTools || {}) };
5454
+ for (const tool of FILE_TOOLS) localTools[tool] = false;
5455
+ const permissions = {
5456
+ ...(current.permissions || DEFAULT_AI_CONFIG.permissions),
5457
+ localTools,
5458
+ readFiles: false,
5459
+ editFiles: false,
5460
+ deleteFiles: false,
5461
+ };
5462
+ const enabled = new Set(current.toolsets?.enabled || []);
5463
+ enabled.delete("local-files-read");
5464
+ enabled.delete("local-files-write");
5465
+
5466
+ if (mode === "read-only") {
5467
+ permissions.readFiles = true;
5468
+ for (const tool of ["files_tree", "files_read", "files_search"]) permissions.localTools[tool] = true;
5469
+ enabled.add("local-files-read");
5470
+ } else if (mode === "workspace-write") {
5471
+ permissions.readFiles = true;
5472
+ permissions.writeFiles = true;
5473
+ permissions.editFiles = true;
5474
+ for (const tool of FILE_TOOLS) permissions.localTools[tool] = true;
5475
+ enabled.add("local-files-read");
5476
+ enabled.add("local-files-write");
5477
+ } else if (mode === "full-access") {
5478
+ permissions.readFiles = true;
5479
+ permissions.writeFiles = true;
5480
+ permissions.editFiles = true;
5481
+ permissions.deleteFiles = false;
5482
+ for (const tool of FILE_TOOLS) permissions.localTools[tool] = true;
5483
+ enabled.add("local-files-read");
5484
+ enabled.add("local-files-write");
5485
+ }
5486
+
5487
+ await saveConfig({
5488
+ permissions,
5489
+ toolsets: { ...(current.toolsets || {}), enabled: [...enabled] },
5490
+ files: { ...(current.files || {}), mode },
5491
+ });
5492
+ }
5493
+
5494
+ function resolveWorkspaceRoot(config) {
5495
+ return path.resolve(process.cwd(), config.files?.workspaceRoot || ".");
5496
+ }
5497
+
5498
+ async function resolveFileTarget(target, operation) {
5499
+ if (!target) throw new Error("Путь к файлу обязателен.");
5500
+ const config = await loadConfig();
5501
+ const mode = config.files?.mode || "locked";
5502
+ if (mode === "locked") throw new Error("Файловые операции заблокированы. Включите: iola files mode read-only");
5503
+ const workspaceRoot = resolveWorkspaceRoot(config);
5504
+ const resolved = path.resolve(workspaceRoot, target);
5505
+ const relative = path.relative(workspaceRoot, resolved);
5506
+ const insideWorkspace = relative && !relative.startsWith("..") && !path.isAbsolute(relative);
5507
+
5508
+ if ((mode === "read-only" || mode === "workspace-write") && !insideWorkspace && resolved !== workspaceRoot) {
5509
+ throw new Error(`Путь вне workspace запрещен режимом ${mode}: ${resolved}`);
5510
+ }
5511
+
5512
+ const blocked = config.files?.blockedGlobs || [];
5513
+ const normalized = resolved.toLocaleLowerCase("ru-RU");
5514
+ if (blocked.some((pattern) => filePatternMatches(normalized, pattern))) {
5515
+ throw new Error(`Путь заблокирован политикой безопасности: ${target}`);
5516
+ }
5517
+
5518
+ if (operation === "read") await assertPermission("readFiles");
5519
+ if (operation === "write") await assertPermission("writeFiles");
5520
+ if (operation === "edit") await assertPermission("editFiles");
5521
+ if (operation === "delete") await assertPermission("deleteFiles");
5522
+
5523
+ return { config, resolved, workspaceRoot, relative: resolved === workspaceRoot ? "." : relative, insideWorkspace };
5524
+ }
5525
+
5526
+ function filePatternMatches(normalizedPath, pattern) {
5527
+ const normalizedPattern = String(pattern).toLocaleLowerCase("ru-RU").replace(/\*/g, "");
5528
+ if (!normalizedPattern) return false;
5529
+ return normalizedPath.split(/[\\/]/).includes(normalizedPattern) || normalizedPath.includes(normalizedPattern);
5530
+ }
5531
+
5532
+ function isBlockedPathForConfig(fullPath, config) {
5533
+ const normalized = fullPath.toLocaleLowerCase("ru-RU");
5534
+ return (config.files?.blockedGlobs || []).some((pattern) => filePatternMatches(normalized, pattern));
5535
+ }
5536
+
5537
+ async function filesTree(target = ".", options = {}) {
5538
+ await assertPermission("files_tree");
5539
+ const { resolved, workspaceRoot } = await resolveFileTarget(target, "read");
5540
+ const depth = Number(options.depth || 2);
5541
+ const limit = Number(options.limit || 100);
5542
+ const rows = [];
5543
+ await walkFiles(resolved, workspaceRoot, rows, depth, limit, (await loadConfig()));
5544
+ return rows;
5545
+ }
5546
+
5547
+ async function walkFiles(directory, workspaceRoot, rows, depth, limit, config) {
5548
+ if (rows.length >= limit || depth < 0) return;
5549
+ let entries = [];
5550
+ try {
5551
+ entries = readdirSync(directory, { withFileTypes: true });
5552
+ } catch {
5553
+ return;
5554
+ }
5555
+ for (const entry of entries) {
5556
+ if (rows.length >= limit) break;
5557
+ const full = path.join(directory, entry.name);
5558
+ if (isBlockedPathForConfig(full, config)) continue;
5559
+ const relative = path.relative(workspaceRoot, full) || ".";
5560
+ let size = "-";
5561
+ try {
5562
+ size = entry.isFile() ? (await stat(full)).size : "-";
5563
+ } catch {
5564
+ size = "-";
5565
+ }
5566
+ rows.push({ type: entry.isDirectory() ? "dir" : "file", path: relative, size });
5567
+ if (entry.isDirectory()) await walkFiles(full, workspaceRoot, rows, depth - 1, limit, config);
5568
+ }
5569
+ }
5570
+
5571
+ async function filesRead(target, options = {}) {
5572
+ await assertPermission("files_read");
5573
+ const { config, resolved } = await resolveFileTarget(target, "read");
5574
+ const info = await stat(resolved);
5575
+ if (!info.isFile()) throw new Error(`Это не файл: ${target}`);
5576
+ const maxBytes = Number(options.maxBytes || config.files?.maxReadBytes || 200000);
5577
+ if (info.size > maxBytes) throw new Error(`Файл слишком большой: ${info.size} байт. Лимит: ${maxBytes}`);
5578
+ return readFile(resolved, "utf8");
5579
+ }
5580
+
5581
+ async function filesSearch(query, options = {}) {
5582
+ await assertPermission("files_search");
5583
+ if (!query) throw new Error("Строка поиска обязательна.");
5584
+ const rows = await filesTree(options.path || ".", { depth: Number(options.depth || 4), limit: Number(options.limit || 200) });
5585
+ const results = [];
5586
+ for (const row of rows.filter((item) => item.type === "file")) {
5587
+ if (results.length >= Number(options.limit || 50)) break;
5588
+ try {
5589
+ const text = await filesRead(row.path, { maxBytes: 500000 });
5590
+ const lines = text.split(/\r?\n/);
5591
+ lines.forEach((line, index) => {
5592
+ if (results.length < Number(options.limit || 50) && line.toLocaleLowerCase("ru-RU").includes(String(query).toLocaleLowerCase("ru-RU"))) {
5593
+ results.push({ file: row.path, line: index + 1, text: line.trim().slice(0, 240) });
5594
+ }
5595
+ });
5596
+ } catch {
5597
+ // Binary, blocked or oversized files are skipped.
5598
+ }
5599
+ }
5600
+ return results;
5601
+ }
5602
+
5603
+ async function filesWrite(target, text, options = {}) {
5604
+ await assertPermission("files_write");
5605
+ const { resolved, relative } = await resolveFileTarget(target, "write");
5606
+ await maybeConfirmFileOperation("write", relative, text);
5607
+ await mkdir(path.dirname(resolved), { recursive: true });
5608
+ if (options.append) {
5609
+ await appendFile(resolved, text, "utf8");
5610
+ } else {
5611
+ await writeFile(resolved, text, "utf8");
5612
+ }
5613
+ }
5614
+
5615
+ async function filesPatch(target, search, replace) {
5616
+ await assertPermission("files_patch");
5617
+ const { resolved, relative } = await resolveFileTarget(target, "edit");
5618
+ const current = await readFile(resolved, "utf8");
5619
+ if (!current.includes(search)) throw new Error("Искомый фрагмент не найден.");
5620
+ const next = current.split(search).join(replace);
5621
+ const replacements = current.split(search).length - 1;
5622
+ await maybeConfirmFileOperation("patch", relative, unifiedPreview(current, next));
5623
+ await writeFile(resolved, next, "utf8");
5624
+ return { path: relative, replacements };
5625
+ }
5626
+
5627
+ async function maybeConfirmFileOperation(operation, target, preview) {
5628
+ const config = await loadConfig();
5629
+ const approvals = config.files?.approvals || "on-write";
5630
+ const needsApproval = approvals === "always" || approvals === "on-write" || (approvals === "on-danger" && operation !== "write");
5631
+ if (!needsApproval) return;
5632
+ console.log(`Файловая операция: ${operation} ${target}`);
5633
+ if (preview) console.log(String(preview).slice(0, 2000));
5634
+ const ok = await confirm("Продолжить? [y/N] ");
5635
+ if (!ok) throw new Error("Файловая операция отменена.");
5636
+ }
5637
+
5638
+ function listTasks() {
5639
+ initDatabase();
5640
+ const db = openDatabase();
5641
+ try {
5642
+ return db.prepare("SELECT id, title, COALESCE(command, '-') AS command, status FROM tasks ORDER BY status, id DESC LIMIT 100").all();
5643
+ } finally {
5644
+ db.close();
5645
+ }
5646
+ }
5647
+
5648
+ function addTask(title, command = "") {
5649
+ initDatabase();
5650
+ const db = openDatabase();
5651
+ try {
5652
+ const result = db.prepare("INSERT INTO tasks(title, command) VALUES (?, ?)").run(title, command);
5653
+ return Number(result.lastInsertRowid);
5654
+ } finally {
5655
+ db.close();
5656
+ }
5657
+ }
5658
+
5659
+ function getTask(id) {
5660
+ initDatabase();
5661
+ const db = openDatabase();
5662
+ try {
5663
+ const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
5664
+ if (!row) throw new Error(`Задача не найдена: ${id}`);
5665
+ return row;
5666
+ } finally {
5667
+ db.close();
5668
+ }
5669
+ }
5670
+
5671
+ function updateTaskStatus(id, status) {
5672
+ initDatabase();
5673
+ const db = openDatabase();
5674
+ try {
5675
+ db.prepare("UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, id);
5676
+ } finally {
5677
+ db.close();
5678
+ }
5679
+ }
5680
+
5681
+ function saveArtifact(kind, title, file = "", meta = {}) {
5682
+ initDatabase();
5683
+ const db = openDatabase();
5684
+ try {
5685
+ const result = db.prepare("INSERT INTO artifacts(kind, title, file, meta_json) VALUES (?, ?, ?, ?)").run(kind, title || kind, file || "", JSON.stringify(meta));
5686
+ return Number(result.lastInsertRowid);
5687
+ } finally {
5688
+ db.close();
5689
+ }
5690
+ }
5691
+
5692
+ function listArtifacts() {
5693
+ initDatabase();
5694
+ const db = openDatabase();
5695
+ try {
5696
+ return db.prepare("SELECT id, kind, title, file, created_at FROM artifacts ORDER BY id DESC LIMIT 100").all();
5697
+ } finally {
5698
+ db.close();
5699
+ }
5700
+ }
5701
+
5702
+ function getArtifact(id) {
5703
+ initDatabase();
5704
+ const db = openDatabase();
5705
+ try {
5706
+ const row = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id);
5707
+ if (!row) throw new Error(`Artifact не найден: ${id}`);
5708
+ return row;
5709
+ } finally {
5710
+ db.close();
5711
+ }
5712
+ }
5713
+
5714
+ function recordToolTrace(runId, tool, args, status, summary) {
5715
+ initDatabase();
5716
+ const db = openDatabase();
5717
+ try {
5718
+ db.prepare("INSERT INTO tool_traces(run_id, tool, args_json, status, summary) VALUES (?, ?, ?, ?, ?)").run(runId, tool, JSON.stringify(args), status, summary || "");
5719
+ } finally {
5720
+ db.close();
5721
+ }
5722
+ }
5723
+
5724
+ function listTrace(limit = 20) {
5725
+ initDatabase();
5726
+ const db = openDatabase();
5727
+ try {
5728
+ return db.prepare("SELECT id, run_id, tool, status, summary, created_at FROM tool_traces ORDER BY id DESC LIMIT ?").all(limit);
5729
+ } finally {
5730
+ db.close();
5731
+ }
5732
+ }
5733
+
5734
+ function getTraceRun(runId) {
5735
+ initDatabase();
5736
+ const db = openDatabase();
5737
+ try {
5738
+ return db.prepare("SELECT * FROM tool_traces WHERE run_id = ? ORDER BY id ASC").all(runId);
5739
+ } finally {
5740
+ db.close();
5741
+ }
5742
+ }
5743
+
5744
+ async function createSnapshot() {
5745
+ const config = await loadConfig();
5746
+ const workspace = resolveWorkspaceRoot(config);
5747
+ const snapshotsDir = path.join(CONFIG_DIR, "snapshots");
5748
+ await mkdir(snapshotsDir, { recursive: true });
5749
+ const target = path.join(snapshotsDir, `snapshot-${Date.now()}`);
5750
+ await cp(workspace, target, {
5751
+ recursive: true,
5752
+ filter: (source) => !isBlockedPathForConfig(source, config) && !source.includes(`${path.sep}node_modules${path.sep}`),
5753
+ });
5754
+ initDatabase();
5755
+ const db = openDatabase();
5756
+ try {
5757
+ const result = db.prepare("INSERT INTO snapshots(workspace, path) VALUES (?, ?)").run(workspace, target);
5758
+ return { id: Number(result.lastInsertRowid), workspace, path: target };
5759
+ } finally {
5760
+ db.close();
5761
+ }
5762
+ }
5763
+
5764
+ function listSnapshots() {
5765
+ initDatabase();
5766
+ const db = openDatabase();
5767
+ try {
5768
+ return db.prepare("SELECT id, workspace, path, created_at FROM snapshots ORDER BY id DESC LIMIT 50").all();
5769
+ } finally {
5770
+ db.close();
5771
+ }
5772
+ }
5773
+
5774
+ async function restoreSnapshot(id) {
5775
+ initDatabase();
5776
+ const db = openDatabase();
5777
+ let row;
5778
+ try {
5779
+ row = db.prepare("SELECT * FROM snapshots WHERE id = ?").get(id);
5780
+ } finally {
5781
+ db.close();
5782
+ }
5783
+ if (!row) throw new Error(`Snapshot не найден: ${id}`);
5784
+ await cp(row.path, row.workspace, { recursive: true, force: true });
5785
+ }
5786
+
5787
+ function unifiedPreview(before, after) {
5788
+ const beforeLines = before.split(/\r?\n/);
5789
+ const afterLines = after.split(/\r?\n/);
5790
+ const output = ["--- before", "+++ after"];
5791
+ const max = Math.max(beforeLines.length, afterLines.length);
5792
+ for (let index = 0; index < Math.min(max, 80); index += 1) {
5793
+ if (beforeLines[index] !== afterLines[index]) {
5794
+ if (beforeLines[index] !== undefined) output.push(`- ${beforeLines[index]}`);
5795
+ if (afterLines[index] !== undefined) output.push(`+ ${afterLines[index]}`);
5796
+ }
5797
+ }
5798
+ return output.join("\n");
5799
+ }
5800
+
4975
5801
  async function executeRpc(method, options = {}) {
4976
5802
  if (method === "status") {
4977
5803
  return { db: getDbStatus(), sync: getSyncStatus(), activeProfile: getActiveProfileName(await loadConfig()) };
@@ -4996,6 +5822,15 @@ async function executeRpc(method, options = {}) {
4996
5822
  await assertPermission("sync");
4997
5823
  return syncDataset(options.dataset || "schools");
4998
5824
  }
5825
+ if (method === "files.tree") {
5826
+ return filesTree(options.path || ".", options);
5827
+ }
5828
+ if (method === "files.read") {
5829
+ return { path: options.path, text: await filesRead(options.path, options) };
5830
+ }
5831
+ if (method === "files.search") {
5832
+ return filesSearch(options.query || options.search || "", options);
5833
+ }
4999
5834
  throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
5000
5835
  }
5001
5836
 
@@ -5155,6 +5990,10 @@ function mergeConfig(base, override) {
5155
5990
  ...(override.permissions?.localTools || {}),
5156
5991
  },
5157
5992
  },
5993
+ files: {
5994
+ ...base.files,
5995
+ ...(override.files || {}),
5996
+ },
5158
5997
  memory: {
5159
5998
  ...base.memory,
5160
5999
  ...(override.memory || {}),
@@ -5190,7 +6029,7 @@ function validateConfig(config) {
5190
6029
  if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
5191
6030
  }
5192
6031
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
5193
- if (!LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
6032
+ if (!ALL_LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
5194
6033
  }
5195
6034
  for (const toolset of config.toolsets?.enabled || []) {
5196
6035
  if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
@@ -5205,8 +6044,9 @@ function configSchema() {
5205
6044
  properties: {
5206
6045
  api: { required: ["baseUrl", "mcpBaseUrl"] },
5207
6046
  ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
5208
- permissions: { localTools: LOCAL_TOOLS, runtime: ["writeFiles", "sync", "externalApi", "externalAi", "codex"] },
6047
+ permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
5209
6048
  toolsets: { available: Object.keys(TOOLSETS) },
6049
+ files: { modes: ["locked", "read-only", "workspace-write", "full-access"], approvals: ["never", "on-write", "on-danger", "always"] },
5210
6050
  skills: { enabled: "array of skill names" },
5211
6051
  daemon: { host: "127.0.0.1", port: DAEMON_PORT },
5212
6052
  },