@iola_adm/iola-cli 0.1.28 → 0.1.30

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
@@ -18,12 +18,15 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
18
18
  const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
19
19
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
20
20
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
21
- const DB_SCHEMA_VERSION = 7;
21
+ const DB_SCHEMA_VERSION = 8;
22
+ const PROJECT_IOLA_DIR = path.join(process.cwd(), ".iola");
23
+ const PROJECT_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "config.json");
24
+ const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
22
25
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
23
26
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
24
27
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
25
28
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
26
- const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
29
+ const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
27
30
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
28
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
32
  const BUILTIN_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
@@ -82,6 +85,23 @@ const FEATURES = {
82
85
  "mcp-management": { stage: "stable", defaultEnabled: true, description: "Команды управления MCP-интеграциями." },
83
86
  "web-search": { stage: "experimental", defaultEnabled: false, description: "Резерв под web-search режимы AI." },
84
87
  };
88
+ const SKILL_BUNDLES = {
89
+ analyst: {
90
+ description: "Аналитик открытых данных: поиск, карточки, отчеты и память.",
91
+ skills: ["open-data", "reports", "local-model"],
92
+ requirements: ["Локальная SQLite-БД", "публичный API"],
93
+ },
94
+ documents: {
95
+ description: "Работа с локальными документами, индексом, архивами и выгрузками.",
96
+ skills: ["open-data", "reports"],
97
+ requirements: ["files mode read-only/workspace-write", "7-Zip для архивов"],
98
+ },
99
+ "local-agent": {
100
+ description: "Локальная модель Ollama с проверочным reasoning и локальными tools.",
101
+ skills: ["local-model", "open-data"],
102
+ requirements: ["Ollama", "локальная модель"],
103
+ },
104
+ };
85
105
  const DEFAULT_AI_CONFIG = {
86
106
  api: {
87
107
  baseUrl: "https://apiiola.yasg.ru/api/v1",
@@ -239,11 +259,13 @@ const COMMANDS = new Map([
239
259
  ["resume", resumeSession],
240
260
  ["fork", forkSession],
241
261
  ["features", handleFeatures],
262
+ ["settings", handleSettings],
242
263
  ["wiki", handleWiki],
243
264
  ["context", handleContext],
244
265
  ["skills", handleSkills],
245
266
  ["tools", handleTools],
246
267
  ["files", handleFiles],
268
+ ["archive", handleArchive],
247
269
  ["changes", handleChanges],
248
270
  ["import", handleImport],
249
271
  ["index", handleIndex],
@@ -253,7 +275,11 @@ const COMMANDS = new Map([
253
275
  ["tasks", handleTasks],
254
276
  ["artifacts", handleArtifacts],
255
277
  ["snapshot", handleSnapshot],
278
+ ["sandbox", handleSandbox],
256
279
  ["trace", handleTrace],
280
+ ["trajectory", handleTrajectory],
281
+ ["usage", handleUsage],
282
+ ["budget", handleBudget],
257
283
  ["policy", handlePolicy],
258
284
  ["export", handleExport],
259
285
  ["cron", handleCron],
@@ -263,6 +289,8 @@ const COMMANDS = new Map([
263
289
  ["memory", handleMemory],
264
290
  ["hooks", handleHooks],
265
291
  ["agents", handleAgents],
292
+ ["subagents", handleSubagents],
293
+ ["review", handleReview],
266
294
  ["mcp", handleMcp],
267
295
  ["cache", handleCache],
268
296
  ["sync", handleSync],
@@ -352,14 +380,17 @@ Usage:
352
380
  iola history [--limit 20]
353
381
  iola history clear
354
382
  iola sessions [--limit 20]
383
+ iola sessions replay SESSION_ID
355
384
  iola resume SESSION_ID [TEXT]
356
385
  iola fork SESSION_ID [TEXT]
357
386
  iola features list|enable|disable
387
+ iola settings list|get|validate|doctor|init
358
388
  iola wiki [open|links]
359
389
  iola context list|show|init
360
- iola skills list|show|paths|enable|disable
390
+ iola skills list|show|paths|enable|disable|bundles|bundle|doctor
361
391
  iola tools list|toolsets|enable|disable|profile
362
392
  iola files status|mode|approvals|tree|read|search|write|patch
393
+ iola archive doctor|list|test|extract|create|index
363
394
  iola changes list|show|apply|discard
364
395
  iola import file|folder
365
396
  iola index folder|status|search
@@ -369,17 +400,23 @@ Usage:
369
400
  iola tasks list|add|done|run
370
401
  iola artifacts list|show|open
371
402
  iola snapshot create|list|restore
403
+ iola sandbox fork|run|diff|apply
372
404
  iola trace last|show
405
+ iola trajectory export|last
406
+ iola usage summary|models|sessions
407
+ iola budget status|set
373
408
  iola policy use safe|analyst|developer|full
374
409
  iola export REPORT --format docx|xlsx --output FILE
375
410
  iola cron list|add|delete|run|tick
376
411
  iola daemon start|status
377
412
  iola rpc call METHOD [ARGS] [--json]
378
413
  iola permissions list|allow|deny
379
- iola memory show|add|set|clear|export
380
- iola hooks list|add|delete|run
414
+ iola memory show|add|set|clear|export|curate|duplicates|prune
415
+ iola hooks list|events|add|delete|run|trust|audit
381
416
  iola agents list|run
382
- iola mcp list|status|install|remove|serve
417
+ iola subagents list|run|parallel|add
418
+ iola review config|data|docs|report
419
+ iola mcp list|status|install|remove|serve [--stdio]
383
420
  iola cache status|warm|clear
384
421
  iola sync [--dataset schools|kindergartens]
385
422
  iola sync status
@@ -618,6 +655,11 @@ async function handleAgentLine(line, state) {
618
655
  return false;
619
656
  }
620
657
 
658
+ if (command === "archive") {
659
+ await handleArchive(args);
660
+ return false;
661
+ }
662
+
621
663
  if (command === "changes") {
622
664
  await handleChanges(args);
623
665
  return false;
@@ -791,6 +833,7 @@ async function handleAgentLine(line, state) {
791
833
  context: ["context", args],
792
834
  skills: ["skills", args],
793
835
  files: ["files", args],
836
+ archive: ["archive", args],
794
837
  changes: ["changes", args],
795
838
  index: ["index", args],
796
839
  reports: ["reports", args],
@@ -850,6 +893,7 @@ function printAgentHelp() {
850
893
  /permissions
851
894
  /tools
852
895
  /files status
896
+ /archive doctor
853
897
  /changes list
854
898
  /index status
855
899
  /reports list
@@ -966,6 +1010,14 @@ function showBanner() {
966
1010
  console.log("открытые данные • MCP • локальный AI");
967
1011
  }
968
1012
 
1013
+ function getPackageVersion() {
1014
+ try {
1015
+ return JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8")).version;
1016
+ } catch {
1017
+ return "0.0.0";
1018
+ }
1019
+ }
1020
+
969
1021
  async function showVersion(args = []) {
970
1022
  const options = parseOptions(args);
971
1023
  const packageJson = await import("../package.json", { with: { type: "json" } });
@@ -1503,6 +1555,17 @@ async function handleSessions(args) {
1503
1555
  return;
1504
1556
  }
1505
1557
 
1558
+ if (action === "replay") {
1559
+ const sessionId = Number(args[1]);
1560
+ if (!sessionId) throw new Error("Пример: iola sessions replay 1");
1561
+ const rows = getSessionMessages(sessionId);
1562
+ for (const row of rows) {
1563
+ console.log(`\n[${row.role}] ${row.created_at}`);
1564
+ console.log(row.content);
1565
+ }
1566
+ return;
1567
+ }
1568
+
1506
1569
  const rows = listSessions(Number(options.limit || 20));
1507
1570
 
1508
1571
  if (options.json) {
@@ -1578,6 +1641,61 @@ async function handleFeatures(args) {
1578
1641
  throw new Error("Команды features: list, enable NAME, disable NAME.");
1579
1642
  }
1580
1643
 
1644
+ async function handleSettings(args) {
1645
+ const [action = "list", key] = args;
1646
+ const layers = await loadConfigLayers();
1647
+ const effective = await loadConfig();
1648
+
1649
+ if (action === "list" || action === "ls" || action === "doctor") {
1650
+ const rows = layers.map((layer) => ({
1651
+ scope: layer.scope,
1652
+ file: layer.file,
1653
+ exists: layer.exists ? "yes" : "no",
1654
+ valid: layer.errors.length ? "no" : "yes",
1655
+ errors: layer.errors.join("; ") || "-",
1656
+ }));
1657
+ printTable(rows, [["scope", "Слой"], ["exists", "Есть"], ["valid", "Валиден"], ["file", "Файл"], ["errors", "Ошибки"]]);
1658
+ return;
1659
+ }
1660
+
1661
+ if (action === "get") {
1662
+ if (!key) {
1663
+ printJson(effective);
1664
+ return;
1665
+ }
1666
+ const value = getConfigValue(effective, key);
1667
+ if (typeof value === "object") printJson(value);
1668
+ else console.log(value ?? "-");
1669
+ return;
1670
+ }
1671
+
1672
+ if (action === "validate") {
1673
+ const errors = validateConfig(effective);
1674
+ if (errors.length) {
1675
+ printTable(errors.map((error) => ({ error })), [["error", "Ошибка"]]);
1676
+ process.exitCode = 1;
1677
+ return;
1678
+ }
1679
+ console.log("Конфигурация валидна.");
1680
+ return;
1681
+ }
1682
+
1683
+ if (action === "init") {
1684
+ await mkdir(PROJECT_IOLA_DIR, { recursive: true });
1685
+ if (!existsSync(PROJECT_CONFIG_FILE)) {
1686
+ await writeFile(PROJECT_CONFIG_FILE, `${JSON.stringify({ files: { workspaceRoot: "." } }, null, 2)}\n`, "utf8");
1687
+ }
1688
+ if (!existsSync(LOCAL_CONFIG_FILE)) {
1689
+ await writeFile(LOCAL_CONFIG_FILE, `${JSON.stringify({ local: true }, null, 2)}\n`, "utf8");
1690
+ }
1691
+ console.log(`Создан project config: ${PROJECT_CONFIG_FILE}`);
1692
+ console.log(`Создан local config: ${LOCAL_CONFIG_FILE}`);
1693
+ return;
1694
+ }
1695
+
1696
+ throw new Error("Команды settings: list, get [KEY], validate, doctor, init.");
1697
+ }
1698
+
1581
1699
  async function handleWiki(args) {
1582
1700
  const [action = "links"] = args;
1583
1701
  const base = "https://github.com/adm-iola/iola-cli/wiki";
@@ -1590,7 +1708,9 @@ async function handleWiki(args) {
1590
1708
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1591
1709
  ["Локальные файлы", `${base}/Локальные-файлы`],
1592
1710
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1711
+ ["Платформа агента", `${base}/Платформа-агента`],
1593
1712
  ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1713
+ ["Архивы и мастер настройки", `${base}/Архивы-и-мастер-настройки`],
1594
1714
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
1595
1715
  ["Контекст и память", `${base}/Контекст-и-память`],
1596
1716
  ["Команды", `${base}/Команды`],
@@ -1691,6 +1811,41 @@ async function handleSkills(args) {
1691
1811
  return;
1692
1812
  }
1693
1813
 
1814
+ if (action === "bundles") {
1815
+ const enabled = new Set(config.skills?.enabled || []);
1816
+ const rows = Object.entries(SKILL_BUNDLES).map(([bundle, meta]) => ({
1817
+ bundle,
1818
+ enabled: meta.skills.every((skill) => enabled.has(skill)) ? "yes" : "partial/no",
1819
+ skills: meta.skills.join(", "),
1820
+ description: meta.description,
1821
+ }));
1822
+ printTable(rows, [["bundle", "Bundle"], ["enabled", "Вкл"], ["skills", "Skills"], ["description", "Описание"]]);
1823
+ return;
1824
+ }
1825
+
1826
+ if (action === "bundle") {
1827
+ const [operation, bundleName] = args.slice(1);
1828
+ if (operation !== "enable" || !SKILL_BUNDLES[bundleName]) {
1829
+ throw new Error(`Пример: iola skills bundle enable analyst. Доступно: ${Object.keys(SKILL_BUNDLES).join(", ")}`);
1830
+ }
1831
+ const enabled = new Set(config.skills?.enabled || []);
1832
+ for (const skill of SKILL_BUNDLES[bundleName].skills) enabled.add(skill);
1833
+ await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabled] } });
1834
+ console.log(`Skill bundle включен: ${bundleName}`);
1835
+ return;
1836
+ }
1837
+
1838
+ if (action === "doctor") {
1839
+ const skills = listSkills(config);
1840
+ const enabled = new Set(config.skills?.enabled || []);
1841
+ const rows = [
1842
+ ...skills.map((skill) => ({ item: skill.name, type: "skill", status: enabled.has(skill.name) ? "enabled" : "available", detail: skill.file })),
1843
+ ...Object.entries(SKILL_BUNDLES).map(([bundle, meta]) => ({ item: bundle, type: "bundle", status: meta.skills.every((skill) => enabled.has(skill)) ? "enabled" : "not-complete", detail: meta.requirements.join(", ") })),
1844
+ ];
1845
+ printTable(rows, [["type", "Тип"], ["item", "Имя"], ["status", "Статус"], ["detail", "Детали"]]);
1846
+ return;
1847
+ }
1848
+
1694
1849
  if (action === "show") {
1695
1850
  const skill = findSkill(name, config);
1696
1851
  if (!skill) throw new Error(`Skill не найден: ${name}`);
@@ -1708,7 +1863,7 @@ async function handleSkills(args) {
1708
1863
  return;
1709
1864
  }
1710
1865
 
1711
- throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME.");
1866
+ throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME, bundles, bundle enable NAME, doctor.");
1712
1867
  }
1713
1868
 
1714
1869
  async function handleTools(args) {
@@ -1845,6 +2000,88 @@ async function handleFiles(args) {
1845
2000
  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.");
1846
2001
  }
1847
2002
 
2003
+ async function handleArchive(args) {
2004
+ const [action = "doctor", target, ...rest] = args;
2005
+ const options = parseOptions(rest);
2006
+ if (action === "doctor") {
2007
+ const sevenZip = await ensureArchiveTool({ install: true });
2008
+ printKeyValue({ sevenZip, status: "ok", formats: "zip, 7z, rar, tar, gz, tgz, bz2, xz и др." });
2009
+ return;
2010
+ }
2011
+ if (action === "list") {
2012
+ if (!target) throw new Error("Пример: iola archive list docs.zip");
2013
+ const rows = await archiveList(target);
2014
+ printTable(rows, [["date", "Дата"], ["size", "Размер"], ["name", "Файл"]]);
2015
+ return;
2016
+ }
2017
+ if (action === "test") {
2018
+ if (!target) throw new Error("Пример: iola archive test docs.zip");
2019
+ await archiveRun(["t", target]);
2020
+ console.log("Архив проверен.");
2021
+ return;
2022
+ }
2023
+ if (action === "extract") {
2024
+ if (!target) throw new Error("Пример: iola archive extract docs.zip --output ./out");
2025
+ const outputDir = options.output || path.join(process.cwd(), path.basename(target, path.extname(target)));
2026
+ await archiveRun(["x", target, `-o${outputDir}`, "-y"]);
2027
+ console.log(`Архив распакован: ${outputDir}`);
2028
+ return;
2029
+ }
2030
+ if (action === "create") {
2031
+ const outputFile = target;
2032
+ const inputPath = rest[0] || options.path || ".";
2033
+ if (!outputFile) throw new Error("Пример: iola archive create docs.zip ./docs");
2034
+ await archiveRun(["a", outputFile, inputPath]);
2035
+ console.log(`Архив создан: ${outputFile}`);
2036
+ return;
2037
+ }
2038
+ if (action === "index") {
2039
+ if (!target) throw new Error("Пример: iola archive index docs.zip");
2040
+ const tempDir = path.join(os.tmpdir(), `iola-archive-${Date.now()}`);
2041
+ const previous = await loadConfig();
2042
+ await mkdir(tempDir, { recursive: true });
2043
+ try {
2044
+ await archiveRun(["x", target, `-o${tempDir}`, "-y"]);
2045
+ await saveConfig({ files: { ...(previous.files || {}), workspaceRoot: tempDir, mode: "read-only" } });
2046
+ await setFilesMode("read-only", await loadConfig());
2047
+ const count = await indexFolder(".", { depth: options.depth || 8, limit: options.limit || 2000 });
2048
+ console.log(`Проиндексировано файлов из архива: ${count}`);
2049
+ } finally {
2050
+ await saveConfig({ files: previous.files, permissions: previous.permissions, toolsets: previous.toolsets }).catch(() => {});
2051
+ await rm(tempDir, { recursive: true, force: true });
2052
+ }
2053
+ return;
2054
+ }
2055
+ throw new Error("Команды archive: doctor, list FILE, test FILE, extract FILE --output DIR, create OUT INPUT, index FILE.");
2056
+ }
2057
+
2058
+ async function archiveRun(args) {
2059
+ const command = await ensureArchiveTool({ install: true });
2060
+ return runCommand(command, args, { inherit: true });
2061
+ }
2062
+
2063
+ async function archiveList(target) {
2064
+ const command = await ensureArchiveTool({ install: true });
2065
+ const { stdout } = await runCommand(command, ["l", "-slt", target]);
2066
+ const rows = [];
2067
+ let current = {};
2068
+ for (const line of stdout.split(/\r?\n/)) {
2069
+ if (!line.trim()) {
2070
+ if (current.Path && current.Path !== target) rows.push({
2071
+ date: current.Modified || current.Created || "-",
2072
+ size: current.Size || "-",
2073
+ name: current.Path,
2074
+ });
2075
+ current = {};
2076
+ continue;
2077
+ }
2078
+ const [key, ...parts] = line.split(" = ");
2079
+ if (key && parts.length) current[key.trim()] = parts.join(" = ").trim();
2080
+ }
2081
+ if (current.Path && current.Path !== target) rows.push({ date: current.Modified || current.Created || "-", size: current.Size || "-", name: current.Path });
2082
+ return rows;
2083
+ }
2084
+
1848
2085
  async function handleChanges(args) {
1849
2086
  const [action = "list", id] = args;
1850
2087
  if (action === "list" || action === "ls") {
@@ -1905,13 +2142,18 @@ async function handleIndex(args) {
1905
2142
  console.log(`Проиндексировано документов: ${count}`);
1906
2143
  return;
1907
2144
  }
2145
+ if (action === "archive") {
2146
+ if (!target) throw new Error("Пример: iola index archive docs.zip");
2147
+ await handleArchive(["index", target, ...rest]);
2148
+ return;
2149
+ }
1908
2150
  if (action === "search") {
1909
2151
  const query = [target, ...rest].filter(Boolean).join(" ");
1910
2152
  if (!query) throw new Error('Пример: iola index search "школа 29"');
1911
2153
  printTable(searchDocs(query, Number(options.limit || 20)), [["file", "Файл"], ["title", "Название"], ["snippet", "Фрагмент"]]);
1912
2154
  return;
1913
2155
  }
1914
- throw new Error("Команды index: status, folder PATH, search TEXT.");
2156
+ throw new Error("Команды index: status, folder PATH, archive FILE, search TEXT.");
1915
2157
  }
1916
2158
 
1917
2159
  async function handleReports(args) {
@@ -2064,6 +2306,38 @@ async function handleSnapshot(args) {
2064
2306
  throw new Error("Команды snapshot: create, list, restore ID.");
2065
2307
  }
2066
2308
 
2309
+ async function handleSandbox(args) {
2310
+ const [action = "fork", ...rest] = args;
2311
+ if (action === "fork") {
2312
+ const result = await createSandboxCopy(rest[0]);
2313
+ printKeyValue(result);
2314
+ return;
2315
+ }
2316
+ if (action === "run") {
2317
+ const command = rest.join(" ").trim();
2318
+ if (!command) throw new Error('Пример: iola sandbox run "npm test"');
2319
+ const sandbox = await createSandboxCopy();
2320
+ const parts = splitCommandLine(command);
2321
+ console.log(`Sandbox: ${sandbox.path}`);
2322
+ await runCommand(parts[0], parts.slice(1), { inherit: true, cwd: sandbox.path });
2323
+ return;
2324
+ }
2325
+ if (action === "diff") {
2326
+ const sandboxPath = rest[0];
2327
+ if (!sandboxPath) throw new Error("Пример: iola sandbox diff PATH");
2328
+ await runCommand("git", ["diff", "--no-index", process.cwd(), sandboxPath], { inherit: true }).catch(() => {});
2329
+ return;
2330
+ }
2331
+ if (action === "apply") {
2332
+ const sandboxPath = rest[0];
2333
+ if (!sandboxPath) throw new Error("Пример: iola sandbox apply PATH");
2334
+ await cp(sandboxPath, process.cwd(), { recursive: true, force: true });
2335
+ console.log(`Sandbox применен: ${sandboxPath}`);
2336
+ return;
2337
+ }
2338
+ throw new Error("Команды sandbox: fork [NAME], run COMMAND, diff PATH, apply PATH.");
2339
+ }
2340
+
2067
2341
  async function handleTrace(args) {
2068
2342
  const [action = "last", id] = args;
2069
2343
  if (action === "last") {
@@ -2077,6 +2351,61 @@ async function handleTrace(args) {
2077
2351
  throw new Error("Команды trace: last [LIMIT], show RUN_ID.");
2078
2352
  }
2079
2353
 
2354
+ async function handleTrajectory(args) {
2355
+ const [action = "export", ...rest] = args;
2356
+ const options = parseOptions(rest);
2357
+ if (action === "last") {
2358
+ const rows = buildTrajectoryRows(Number(options.limit || rest[0] || 20));
2359
+ if (options.json) printJson(rows);
2360
+ else printTable(rows, [["type", "Тип"], ["id", "ID"], ["created_at", "Дата"], ["summary", "Сводка"]]);
2361
+ return;
2362
+ }
2363
+ if (action === "export") {
2364
+ const format = options.format || "jsonl";
2365
+ const output = options.output || path.join(process.cwd(), `iola-trajectory-${Date.now()}.${format}`);
2366
+ const rows = buildTrajectoryRows(Number(options.limit || 500));
2367
+ const text = format === "json" ? `${JSON.stringify(rows, null, 2)}\n` : rows.map((row) => JSON.stringify(row)).join("\n") + "\n";
2368
+ await writeFile(output, text, "utf8");
2369
+ saveArtifact("trajectory", path.basename(output), output, { format, rows: rows.length });
2370
+ console.log(`Trajectory экспортирована: ${output}`);
2371
+ return;
2372
+ }
2373
+ throw new Error("Команды trajectory: last [--limit N], export [--format jsonl|json] [--output FILE].");
2374
+ }
2375
+
2376
+ async function handleUsage(args) {
2377
+ const [action = "summary"] = args;
2378
+ if (action === "summary") {
2379
+ printKeyValue(getUsageSummary());
2380
+ return;
2381
+ }
2382
+ if (action === "models") {
2383
+ printTable(getUsageByModel(), [["provider", "Провайдер"], ["model", "Модель"], ["requests", "Запросы"], ["tokens", "Токены"], ["cost", "USD"]]);
2384
+ return;
2385
+ }
2386
+ if (action === "sessions") {
2387
+ printTable(getUsageBySession(), [["session_id", "Сессия"], ["requests", "Запросы"], ["tokens", "Токены"], ["cost", "USD"]]);
2388
+ return;
2389
+ }
2390
+ throw new Error("Команды usage: summary, models, sessions.");
2391
+ }
2392
+
2393
+ async function handleBudget(args) {
2394
+ const [action = "status", scope = "daily", amount] = args;
2395
+ if (action === "status") {
2396
+ printTable(listBudgets(), [["scope", "Область"], ["amount_usd", "Лимит USD"], ["spent_usd", "Потрачено"], ["updated_at", "Обновлено"]]);
2397
+ return;
2398
+ }
2399
+ if (action === "set") {
2400
+ const value = Number(amount || args[2]);
2401
+ if (!value || value < 0) throw new Error("Пример: iola budget set daily 5");
2402
+ setBudget(scope, value);
2403
+ console.log(`Budget сохранен: ${scope}=${value} USD`);
2404
+ return;
2405
+ }
2406
+ throw new Error("Команды budget: status, set daily AMOUNT.");
2407
+ }
2408
+
2080
2409
  async function handlePolicy(args) {
2081
2410
  const [action = "list", name] = args;
2082
2411
  const policies = {
@@ -2348,7 +2677,26 @@ async function handleMemory(args) {
2348
2677
  return;
2349
2678
  }
2350
2679
 
2351
- throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE].");
2680
+ if (action === "duplicates" || action === "curate") {
2681
+ const rows = findMemoryDuplicates();
2682
+ if (options.json) printJson(rows);
2683
+ else printTable(rows, [["keeper_id", "Оставить"], ["duplicate_id", "Дубликат"], ["content", "Текст"]]);
2684
+ return;
2685
+ }
2686
+
2687
+ if (action === "prune") {
2688
+ const rows = findMemoryDuplicates();
2689
+ if (!options.yes) {
2690
+ printTable(rows, [["keeper_id", "Оставить"], ["duplicate_id", "Удалить"], ["content", "Текст"]]);
2691
+ console.log("Для удаления дубликатов запустите: iola memory prune --yes");
2692
+ return;
2693
+ }
2694
+ for (const row of rows) deleteMemory(row.duplicate_id);
2695
+ console.log(`Удалено дубликатов памяти: ${rows.length}`);
2696
+ return;
2697
+ }
2698
+
2699
+ throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE], curate, duplicates, prune --yes.");
2352
2700
  }
2353
2701
 
2354
2702
  async function handleHooks(args) {
@@ -2371,6 +2719,22 @@ async function handleHooks(args) {
2371
2719
  return;
2372
2720
  }
2373
2721
 
2722
+ if (action === "trust") {
2723
+ await saveConfig({ hooksTrusted: true });
2724
+ console.log("Hooks помечены как доверенные для текущего пользователя.");
2725
+ return;
2726
+ }
2727
+
2728
+ if (action === "audit") {
2729
+ const rows = Object.entries(config.hooks || {}).map(([hookEvent, commands]) => ({
2730
+ event: hookEvent,
2731
+ commands: commands.length,
2732
+ trusted: config.hooksTrusted ? "yes" : "no",
2733
+ }));
2734
+ printTable(rows, [["event", "Событие"], ["commands", "Команд"], ["trusted", "Доверено"]]);
2735
+ return;
2736
+ }
2737
+
2374
2738
  if (action === "add") {
2375
2739
  if (!HOOK_EVENTS.includes(event) || commandParts.length === 0) {
2376
2740
  throw new Error(`Пример: iola hooks add AfterSync "iola quality" Доступно: ${HOOK_EVENTS.join(", ")}`);
@@ -2402,7 +2766,7 @@ async function handleHooks(args) {
2402
2766
  return;
2403
2767
  }
2404
2768
 
2405
- throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT.");
2769
+ throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT, trust, audit.");
2406
2770
  }
2407
2771
 
2408
2772
  async function handleAgents(args) {
@@ -2451,8 +2815,132 @@ async function handleAgents(args) {
2451
2815
  throw new Error("Команды agents: list, run NAME TEXT.");
2452
2816
  }
2453
2817
 
2818
+ async function handleSubagents(args) {
2819
+ const [action = "list", name, ...rest] = args;
2820
+ const config = await loadConfig();
2821
+ const custom = config.subagents || {};
2822
+ const agents = { ...AGENTS, ...custom };
2823
+
2824
+ if (action === "list" || action === "ls") {
2825
+ const rows = Object.entries(agents).map(([agent, meta]) => ({
2826
+ agent,
2827
+ profile: meta.profile || "active",
2828
+ tools: meta.tools ? "yes" : "no",
2829
+ source: AGENTS[agent] ? "builtin" : "user",
2830
+ description: meta.description || "-",
2831
+ }));
2832
+ printTable(rows, [["agent", "Subagent"], ["profile", "Профиль"], ["tools", "Tools"], ["source", "Источник"], ["description", "Описание"]]);
2833
+ return;
2834
+ }
2835
+
2836
+ if (action === "add") {
2837
+ const options = parseOptions(rest);
2838
+ if (!name) throw new Error("Пример: iola subagents add culture --profile local --prompt \"...\"");
2839
+ const prompt = options.prompt || options.command || options._.join(" ");
2840
+ const next = {
2841
+ ...custom,
2842
+ [name]: {
2843
+ profile: options.profile || null,
2844
+ tools: Boolean(options.tools),
2845
+ prefix: prompt ? `${prompt} ` : "",
2846
+ description: options.description || prompt || "Пользовательский subagent",
2847
+ },
2848
+ };
2849
+ await saveConfig({ subagents: next });
2850
+ console.log(`Subagent добавлен: ${name}`);
2851
+ return;
2852
+ }
2853
+
2854
+ if (action === "run") {
2855
+ if (!agents[name]) throw new Error(`Subagent неизвестен: ${name}. Доступно: ${Object.keys(agents).join(", ")}`);
2856
+ await runSubagent(name, agents[name], rest);
2857
+ return;
2858
+ }
2859
+
2860
+ if (action === "parallel") {
2861
+ const names = String(name || "").split(",").map((item) => item.trim()).filter(Boolean);
2862
+ const question = rest.join(" ").trim();
2863
+ if (!names.length || !question) throw new Error('Пример: iola subagents parallel data-analyst,reviewer "проверь школы"');
2864
+ for (const agentName of names) {
2865
+ if (!agents[agentName]) throw new Error(`Subagent неизвестен: ${agentName}`);
2866
+ console.log(`\n## ${agentName}`);
2867
+ await runSubagent(agentName, agents[agentName], [question, "--no-history"]);
2868
+ }
2869
+ return;
2870
+ }
2871
+
2872
+ throw new Error("Команды subagents: list, add NAME --profile PROFILE --prompt TEXT, run NAME TEXT, parallel a,b TEXT.");
2873
+ }
2874
+
2875
+ async function runSubagent(name, agent, rest) {
2876
+ const options = parseOptions(rest);
2877
+ const question = options._.join(" ").trim();
2878
+ if (!question) throw new Error(`Пример: iola subagents run ${name} "найди школы"`);
2879
+ const askArgs = [agent.prefix ? `${agent.prefix}${question}` : question, "--agent", name];
2880
+ if (agent.profile || options.profile) askArgs.push("--profile", options.profile || agent.profile);
2881
+ if (agent.tools || options.tools) askArgs.push("--tools");
2882
+ if (agent.reasoning || options.reasoning) askArgs.push("--reasoning", options.reasoning || agent.reasoning);
2883
+ if (options.files) askArgs.push("--files");
2884
+ if (options.events) askArgs.push("--events");
2885
+ if (options["no-history"]) askArgs.push("--no-history");
2886
+ await aiAsk(askArgs);
2887
+ }
2888
+
2889
+ async function handleReview(args) {
2890
+ const [action = "config", target, ...rest] = args;
2891
+ const options = parseOptions([target, ...rest].filter(Boolean));
2892
+ const actualTarget = options._[0];
2893
+ if (action === "config") {
2894
+ const errors = validateConfig(await loadConfig());
2895
+ const rows = errors.length ? errors.map((error) => ({ level: "error", message: error })) : [{ level: "ok", message: "Конфигурация валидна" }];
2896
+ printTable(rows, [["level", "Уровень"], ["message", "Сообщение"]]);
2897
+ return;
2898
+ }
2899
+ if (action === "data") {
2900
+ await ensureLocalData();
2901
+ const rows = runQuality(actualTarget || "all");
2902
+ if (options.json) printJson(rows);
2903
+ else printTable(rows, [["check", "Проверка"], ["count", "Кол-во"], ["sample", "Пример"]]);
2904
+ return;
2905
+ }
2906
+ if (action === "docs") {
2907
+ const rows = actualTarget ? await reviewDocumentFolder(actualTarget, options) : searchDocs(options.query || "", Number(options.limit || 20));
2908
+ if (options.json) printJson(rows);
2909
+ else printTable(rows, [["file", "Файл"], ["issue", "Замечание"], ["detail", "Детали"]]);
2910
+ return;
2911
+ }
2912
+ if (action === "report") {
2913
+ if (!actualTarget) throw new Error("Пример: iola review report отчет.docx");
2914
+ const text = await extractReadableText(path.resolve(actualTarget));
2915
+ const rows = [
2916
+ { file: actualTarget, issue: text.trim() ? "ok" : "empty", detail: text.trim() ? "Текст извлечен" : "Не удалось извлечь текст" },
2917
+ { file: actualTarget, issue: /источник|данн/i.test(text) ? "ok" : "missing-source", detail: "Проверьте указание источника данных" },
2918
+ ];
2919
+ printTable(rows, [["file", "Файл"], ["issue", "Замечание"], ["detail", "Детали"]]);
2920
+ return;
2921
+ }
2922
+ throw new Error("Команды review: config, data [scope], docs [PATH], report FILE.");
2923
+ }
2924
+
2925
+ async function reviewDocumentFolder(target, options = {}) {
2926
+ const previous = await loadConfig();
2927
+ const rows = [];
2928
+ try {
2929
+ await saveConfig({ files: { ...(previous.files || {}), workspaceRoot: path.resolve(target), mode: "read-only" } });
2930
+ await setFilesMode("read-only", await loadConfig());
2931
+ const files = await filesTree(".", { depth: Number(options.depth || 5), limit: Number(options.limit || 200) });
2932
+ for (const file of files.filter((item) => item.type === "file" && INDEXABLE_EXTENSIONS.test(item.path))) {
2933
+ rows.push({ file: file.path, issue: "indexable", detail: "Документ можно читать и индексировать" });
2934
+ }
2935
+ } finally {
2936
+ await saveConfig({ files: previous.files, permissions: previous.permissions, toolsets: previous.toolsets }).catch(() => {});
2937
+ }
2938
+ return rows;
2939
+ }
2940
+
2454
2941
  async function handleMcp(args) {
2455
- const [action = "status", target = "codex"] = args;
2942
+ const [action = "status", target = "codex", ...rest] = args;
2943
+ const options = parseOptions([target, ...rest]);
2456
2944
 
2457
2945
  if (action === "status") {
2458
2946
  const [health, version] = await Promise.all([
@@ -2488,6 +2976,10 @@ async function handleMcp(args) {
2488
2976
 
2489
2977
  if (action === "serve") {
2490
2978
  const config = await loadConfig();
2979
+ if (options.stdio || target === "--stdio" || target === "stdio") {
2980
+ await startMcpStdio();
2981
+ return;
2982
+ }
2491
2983
  await startMcpServer(config.daemon?.host || "127.0.0.1", Number(config.daemon?.port || DAEMON_PORT) + 1);
2492
2984
  return;
2493
2985
  }
@@ -3411,6 +3903,24 @@ function initDatabase() {
3411
3903
  command TEXT,
3412
3904
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
3413
3905
  );
3906
+ CREATE TABLE IF NOT EXISTS usage_events (
3907
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3908
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3909
+ provider TEXT,
3910
+ model TEXT,
3911
+ profile TEXT,
3912
+ input_chars INTEGER NOT NULL DEFAULT 0,
3913
+ output_chars INTEGER NOT NULL DEFAULT 0,
3914
+ estimated_tokens INTEGER NOT NULL DEFAULT 0,
3915
+ estimated_cost_usd REAL NOT NULL DEFAULT 0,
3916
+ session_id INTEGER
3917
+ );
3918
+ CREATE INDEX IF NOT EXISTS idx_usage_events_created_at ON usage_events(created_at DESC);
3919
+ CREATE TABLE IF NOT EXISTS budgets (
3920
+ scope TEXT PRIMARY KEY,
3921
+ amount_usd REAL NOT NULL,
3922
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
3923
+ );
3414
3924
  `);
3415
3925
  rebuildFtsIfEmpty(db);
3416
3926
  db.prepare(`
@@ -3467,6 +3977,7 @@ function getDbStatus() {
3467
3977
  const artifacts = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get();
3468
3978
  const docs = db.prepare("SELECT COUNT(*) AS count FROM doc_index").get();
3469
3979
  const custom = db.prepare("SELECT COUNT(*) AS count FROM custom_records").get();
3980
+ const usage = db.prepare("SELECT COUNT(*) AS count FROM usage_events").get();
3470
3981
  return {
3471
3982
  status: "ok",
3472
3983
  file: DB_FILE,
@@ -3482,6 +3993,7 @@ function getDbStatus() {
3482
3993
  artifacts: artifacts?.count ?? 0,
3483
3994
  indexed_docs: docs?.count ?? 0,
3484
3995
  custom_records: custom?.count ?? 0,
3996
+ usage_events: usage?.count ?? 0,
3485
3997
  };
3486
3998
  } finally {
3487
3999
  db.close();
@@ -3652,6 +4164,16 @@ function printSessionMessages(sessionId) {
3652
4164
  ]);
3653
4165
  }
3654
4166
 
4167
+ function getSessionMessages(sessionId) {
4168
+ initDatabase();
4169
+ const db = openDatabase();
4170
+ try {
4171
+ return db.prepare("SELECT id, role, content, created_at FROM session_messages WHERE session_id = ? ORDER BY id ASC").all(sessionId);
4172
+ } finally {
4173
+ db.close();
4174
+ }
4175
+ }
4176
+
3655
4177
  function forkSessionInDb(sessionId) {
3656
4178
  initDatabase();
3657
4179
  const db = openDatabase();
@@ -4142,6 +4664,21 @@ function deleteMemory(id) {
4142
4664
  }
4143
4665
  }
4144
4666
 
4667
+ function findMemoryDuplicates() {
4668
+ const rows = listMemory(1000).reverse();
4669
+ const seen = new Map();
4670
+ const duplicates = [];
4671
+ for (const row of rows) {
4672
+ const normalized = row.content.trim().toLocaleLowerCase("ru-RU").replace(/\s+/g, " ");
4673
+ if (seen.has(normalized)) {
4674
+ duplicates.push({ keeper_id: seen.get(normalized).id, duplicate_id: row.id, content: row.content });
4675
+ } else {
4676
+ seen.set(normalized, row);
4677
+ }
4678
+ }
4679
+ return duplicates;
4680
+ }
4681
+
4145
4682
  function clearMemory() {
4146
4683
  initDatabase();
4147
4684
  const db = openDatabase();
@@ -4472,6 +5009,7 @@ async function aiAsk(args, context = {}) {
4472
5009
  const providerConfig = resolveAiProfile(config, options);
4473
5010
  if (providerConfig.provider === "codex") await assertPermission("codex");
4474
5011
  if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
5012
+ if (options["stream-json"]) options.events = true;
4475
5013
  if (options.tools && providerConfig.provider === "ollama") {
4476
5014
  return localToolAsk(question, providerConfig, options);
4477
5015
  }
@@ -4501,6 +5039,13 @@ async function aiAsk(args, context = {}) {
4501
5039
  recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
4502
5040
  appendSessionExchange(sessionId, question, answer, dataContext, "");
4503
5041
  }
5042
+ recordUsage({
5043
+ providerConfig,
5044
+ question,
5045
+ answer,
5046
+ sessionId,
5047
+ profile: providerConfig.name,
5048
+ });
4504
5049
  await maybeSuggestMemory(question, answer, providerConfig);
4505
5050
 
4506
5051
  emitEvent(options, "answer", { length: answer.length, sessionId });
@@ -4545,6 +5090,7 @@ function resolveAiProfile(config, options = {}) {
4545
5090
  }
4546
5091
 
4547
5092
  async function localToolAsk(question, providerConfig, options) {
5093
+ if (options["stream-json"]) options.events = true;
4548
5094
  await ensureLocalData();
4549
5095
  const plan = await buildLocalToolPlan(question, providerConfig, options);
4550
5096
  const validated = validateToolPlan(plan, options);
@@ -4570,6 +5116,7 @@ async function localToolAsk(question, providerConfig, options) {
4570
5116
  sessionId: null,
4571
5117
  });
4572
5118
  }
5119
+ recordUsage({ providerConfig, question, answer, sessionId: null, profile: providerConfig.name });
4573
5120
 
4574
5121
  emitEvent(options, "tool_plan", { plan: validated, runId });
4575
5122
  saveArtifact("tool-result", question.slice(0, 80), "", { runId, plan: validated, outputs: result.outputs });
@@ -4675,6 +5222,7 @@ async function executeToolPlan(plan, options = {}) {
4675
5222
  let status = "ok";
4676
5223
  let summary = "";
4677
5224
  await assertPermission(step.tool);
5225
+ await runHooks("PreToolUse", { tool: step.tool, args: step.args || {} });
4678
5226
  await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
4679
5227
  try {
4680
5228
  if (step.tool === "search_local") {
@@ -4721,10 +5269,12 @@ async function executeToolPlan(plan, options = {}) {
4721
5269
  status = "error";
4722
5270
  summary = error instanceof Error ? error.message : String(error);
4723
5271
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
5272
+ await runHooks("OnError", { tool: step.tool, args: step.args || {}, error: summary });
4724
5273
  throw error;
4725
5274
  }
4726
5275
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
4727
5276
  await runHooks("AfterTool", { tool: step.tool, rows: current.length });
5277
+ await runHooks("PostToolUse", { tool: step.tool, rows: current.length });
4728
5278
  }
4729
5279
  return { rows: current, outputs };
4730
5280
  }
@@ -4752,7 +5302,12 @@ async function runHooks(event, payload = {}) {
4752
5302
  const config = await loadConfig();
4753
5303
  const commands = config.hooks?.[event] || [];
4754
5304
  for (const command of commands) {
4755
- const parts = splitCommandLine(command);
5305
+ const [maybeFilter, ...rest] = String(command).split(":");
5306
+ const commandText = payload.tool && rest.length > 0 && ALL_LOCAL_TOOLS.includes(maybeFilter.trim())
5307
+ ? (maybeFilter.trim() === payload.tool ? rest.join(":").trim() : "")
5308
+ : command;
5309
+ if (!commandText) continue;
5310
+ const parts = splitCommandLine(commandText);
4756
5311
  if (parts.length === 0) continue;
4757
5312
  await runCommand(parts[0], parts.slice(1), {
4758
5313
  inherit: true,
@@ -5281,23 +5836,79 @@ async function onboard(args = []) {
5281
5836
  initDatabase();
5282
5837
  await handleConfig(["validate"]);
5283
5838
  await doctor(["--summary"]);
5284
- if (options.yes || await confirm("Инициализировать workspace? [Y/n] ")) await handleWorkspace(["init"]);
5285
- if (options.yes || await confirm("Применить policy analyst? [Y/n] ")) await handlePolicy(["use", "analyst"]);
5286
- if (options.yes || await confirm("Настроить AI сейчас? [y/N] ")) await aiSetup([]);
5839
+ await ensureArchiveTool({ install: true });
5840
+
5841
+ const components = options.yes ? ["workspace", "policy", "ollama", "openai", "openrouter", "codex", "codex-mcp", "index"] : await chooseOnboardComponents();
5842
+ if (components.includes("workspace")) await handleWorkspace(["init"]);
5843
+ if (components.includes("policy")) await handlePolicy(["use", "analyst"]);
5844
+ if (components.includes("ollama")) {
5845
+ await installOllamaIfMissing();
5846
+ await setupOllama(["--yes"]);
5847
+ }
5848
+ if (components.includes("openai")) {
5849
+ await aiSetup(["openai"]);
5850
+ if (process.stdin.isTTY) await setAiKey("openai");
5851
+ }
5852
+ if (components.includes("openrouter")) {
5853
+ await aiSetup(["openrouter"]);
5854
+ if (process.stdin.isTTY) await setAiKey("openrouter");
5855
+ }
5856
+ if (components.includes("codex")) {
5857
+ await installCodexIfMissing();
5858
+ await aiSetup(["codex"]);
5859
+ }
5860
+ if (components.includes("codex-mcp")) await setupClient(["codex"]);
5861
+ if (components.includes("index")) {
5862
+ await setFilesMode("read-only", await loadConfig());
5863
+ console.log("Индекс документов можно запустить командой: iola index folder ./docs");
5864
+ }
5287
5865
  console.log("Onboard завершен.");
5288
5866
  }
5289
5867
 
5868
+ async function chooseOnboardComponents() {
5869
+ if (!process.stdin.isTTY) return ["workspace", "policy"];
5870
+ console.log("");
5871
+ console.log("Выберите компоненты через запятую:");
5872
+ console.log("1. workspace и контекст");
5873
+ console.log("2. policy analyst");
5874
+ console.log("3. Ollama + локальная модель");
5875
+ console.log("4. OpenAI API");
5876
+ console.log("5. OpenRouter API");
5877
+ console.log("6. Codex CLI");
5878
+ console.log("7. MCP для Codex");
5879
+ console.log("8. Индекс локальных документов");
5880
+ console.log("");
5881
+ const rl = readline.createInterface({ input, output });
5882
+ try {
5883
+ const answer = (await rl.question("Компоненты [1,2,8]: ")).trim() || "1,2,8";
5884
+ const selected = new Set(answer.split(/[,\s]+/).filter(Boolean));
5885
+ const map = {
5886
+ 1: "workspace",
5887
+ 2: "policy",
5888
+ 3: "ollama",
5889
+ 4: "openai",
5890
+ 5: "openrouter",
5891
+ 6: "codex",
5892
+ 7: "codex-mcp",
5893
+ 8: "index",
5894
+ };
5895
+ return [...selected].map((item) => map[item] || item).filter(Boolean);
5896
+ } finally {
5897
+ rl.close();
5898
+ }
5899
+ }
5900
+
5290
5901
  function parseOptions(args) {
5291
5902
  const result = { _: [] };
5292
5903
 
5293
5904
  for (let index = 0; index < args.length; index += 1) {
5294
5905
  const arg = args[index];
5295
- 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 === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
5906
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
5296
5907
  result[arg.slice(2)] = true;
5297
5908
  } else if (arg === "--check" || arg === "--upgrade-node") {
5298
5909
  result.check = true;
5299
5910
  result[arg.slice(2)] = true;
5300
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--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") {
5911
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--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") {
5301
5912
  result[arg.slice(2)] = args[index + 1];
5302
5913
  index += 1;
5303
5914
  } else {
@@ -5614,6 +6225,66 @@ async function getCommandVersion(command, args) {
5614
6225
  }
5615
6226
  }
5616
6227
 
6228
+ async function findCommand(candidates, versionArgs = ["--version"]) {
6229
+ for (const command of candidates) {
6230
+ const version = await getCommandVersion(command, versionArgs);
6231
+ if (version !== "не найден") return { command, version };
6232
+ }
6233
+ return null;
6234
+ }
6235
+
6236
+ async function ensureArchiveTool(options = {}) {
6237
+ const found = await findCommand(["7z", "7zz", "7za"], ["--help"]);
6238
+ if (found) return found.command;
6239
+ if (options.install === false) throw new Error("7-Zip не найден.");
6240
+ await installSevenZip();
6241
+ const installed = await findCommand(["7z", "7zz", "7za"], ["--help"]);
6242
+ if (!installed) throw new Error("7-Zip не найден после установки. Перезапустите терминал и проверьте: 7z");
6243
+ return installed.command;
6244
+ }
6245
+
6246
+ async function installSevenZip() {
6247
+ console.log("7-Zip не найден. Устанавливаю архиватор для работы со всеми типами архивов.");
6248
+ if (process.platform === "win32") {
6249
+ await runCommand("winget", ["install", "7zip.7zip", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
6250
+ return;
6251
+ }
6252
+ if (process.platform === "darwin") {
6253
+ try {
6254
+ await runCommand("brew", ["install", "sevenzip"], { inherit: true });
6255
+ } catch {
6256
+ await runCommand("brew", ["install", "p7zip"], { inherit: true });
6257
+ }
6258
+ return;
6259
+ }
6260
+ try {
6261
+ await runCommand("sh", ["-c", "sudo apt-get update && sudo apt-get install -y p7zip-full p7zip-rar"], { inherit: true });
6262
+ } catch {
6263
+ await runCommand("sh", ["-c", "sudo apt-get update && sudo apt-get install -y 7zip"], { inherit: true });
6264
+ }
6265
+ }
6266
+
6267
+ async function installOllamaIfMissing() {
6268
+ if (await getOllamaVersion()) return;
6269
+ console.log("Ollama не найден. Устанавливаю Ollama.");
6270
+ if (process.platform === "win32") {
6271
+ await runCommand("winget", ["install", "Ollama.Ollama", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
6272
+ return;
6273
+ }
6274
+ if (process.platform === "darwin") {
6275
+ await runCommand("brew", ["install", "--cask", "ollama"], { inherit: true });
6276
+ return;
6277
+ }
6278
+ await runCommand("sh", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { inherit: true });
6279
+ }
6280
+
6281
+ async function installCodexIfMissing() {
6282
+ const version = await getCommandVersion("codex", ["--version"]);
6283
+ if (version !== "не найден") return;
6284
+ console.log("Codex CLI не найден. Устанавливаю через npm.");
6285
+ await runCommand("npm", ["install", "-g", "@openai/codex"], { inherit: true });
6286
+ }
6287
+
5617
6288
  async function probeEndpoint(url) {
5618
6289
  try {
5619
6290
  const response = await fetch(url, { headers: { accept: "application/json" } });
@@ -5627,6 +6298,13 @@ async function startDaemon(host, port) {
5627
6298
  const server = createServer(async (req, res) => {
5628
6299
  try {
5629
6300
  const url = new URL(req.url || "/", `http://${host}:${port}`);
6301
+
6302
+ if (req.method === "GET" && url.pathname === "/") {
6303
+ res.setHeader("content-type", "text/html; charset=utf-8");
6304
+ res.end(renderDaemonDashboard(host, port));
6305
+ return;
6306
+ }
6307
+
5630
6308
  res.setHeader("content-type", "application/json; charset=utf-8");
5631
6309
 
5632
6310
  if (req.method === "GET" && url.pathname === "/health") {
@@ -5639,6 +6317,26 @@ async function startDaemon(host, port) {
5639
6317
  return;
5640
6318
  }
5641
6319
 
6320
+ if (req.method === "GET" && url.pathname === "/api/status") {
6321
+ res.end(JSON.stringify({ db: getDbStatus(), sync: getSyncStatus(), usage: getUsageSummary() }));
6322
+ return;
6323
+ }
6324
+
6325
+ if (req.method === "GET" && url.pathname === "/api/tasks") {
6326
+ res.end(JSON.stringify(listTasks()));
6327
+ return;
6328
+ }
6329
+
6330
+ if (req.method === "GET" && url.pathname === "/api/artifacts") {
6331
+ res.end(JSON.stringify(listArtifacts()));
6332
+ return;
6333
+ }
6334
+
6335
+ if (req.method === "GET" && url.pathname === "/api/trace") {
6336
+ res.end(JSON.stringify(listTrace(50)));
6337
+ return;
6338
+ }
6339
+
5642
6340
  if (req.method === "POST" && url.pathname === "/rpc") {
5643
6341
  const body = await readRequestBody(req);
5644
6342
  const payload = body ? JSON.parse(body) : {};
@@ -5666,22 +6364,19 @@ async function startDaemon(host, port) {
5666
6364
 
5667
6365
  async function startMcpServer(host, port) {
5668
6366
  const server = createServer(async (req, res) => {
6367
+ let payload = {};
5669
6368
  try {
5670
6369
  res.setHeader("content-type", "application/json; charset=utf-8");
5671
6370
  if (req.method !== "POST") {
5672
- res.end(JSON.stringify({ name: "iola-local-mcp", tools: ["status", "search", "card", "quality", "files.search", "index.search"] }));
6371
+ res.end(JSON.stringify({ name: "iola-local-mcp", protocol: "2024-11-05", tools: mcpTools().map((tool) => tool.name) }));
5673
6372
  return;
5674
6373
  }
5675
- const payload = JSON.parse(await readRequestBody(req) || "{}");
5676
- const method = payload.method === "tools/call" ? payload.params?.name : payload.method;
5677
- const args = payload.params?.arguments || payload.params || {};
5678
- let result;
5679
- if (method === "index.search") result = searchDocs(args.query || "", Number(args.limit || 20));
5680
- else result = await executeRpc(method, { ...args, _: [] });
6374
+ payload = JSON.parse(await readRequestBody(req) || "{}");
6375
+ const result = await handleMcpMessage(payload);
5681
6376
  res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result }));
5682
6377
  } catch (error) {
5683
6378
  res.statusCode = 500;
5684
- res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { message: error instanceof Error ? error.message : String(error) } }));
6379
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }));
5685
6380
  }
5686
6381
  });
5687
6382
  await new Promise((resolve, reject) => {
@@ -5692,6 +6387,142 @@ async function startMcpServer(host, port) {
5692
6387
  await new Promise(() => {});
5693
6388
  }
5694
6389
 
6390
+ function renderDaemonDashboard(host, port) {
6391
+ const status = getDbStatus();
6392
+ const sync = getSyncStatus();
6393
+ const usage = getUsageSummary();
6394
+ return `<!doctype html>
6395
+ <html lang="ru">
6396
+ <meta charset="utf-8">
6397
+ <title>iola daemon</title>
6398
+ <style>
6399
+ body{font-family:Segoe UI,Arial,sans-serif;margin:32px;background:#f8fafc;color:#0f172a}
6400
+ h1{margin:0 0 8px;font-size:28px}
6401
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:20px}
6402
+ .card{background:white;border:1px solid #dbe3ef;border-radius:8px;padding:16px}
6403
+ .k{color:#64748b;font-size:12px;text-transform:uppercase}.v{font-size:24px;font-weight:700;margin-top:4px}
6404
+ a{color:#0b62d6}
6405
+ code{background:#eef2f7;padding:2px 5px;border-radius:4px}
6406
+ </style>
6407
+ <h1>iola daemon</h1>
6408
+ <div>Локальная панель CLI-проекта Йошкар-Олы: <code>http://${host}:${port}</code></div>
6409
+ <div class="grid">
6410
+ <div class="card"><div class="k">DB</div><div class="v">${status.status}</div><p>schema ${status.schema}, records ${status.local_records}</p></div>
6411
+ <div class="card"><div class="k">Sync</div><div class="v">${sync.last_status || "none"}</div><p>${sync.last_dataset || "-"} ${sync.last_records || 0}</p></div>
6412
+ <div class="card"><div class="k">Usage</div><div class="v">${usage.requests}</div><p>${usage.estimated_tokens} tokens</p></div>
6413
+ <div class="card"><div class="k">API</div><p><a href="/api/status">/api/status</a></p><p><a href="/api/tasks">/api/tasks</a></p><p><a href="/api/artifacts">/api/artifacts</a></p><p><a href="/api/trace">/api/trace</a></p></div>
6414
+ </div>
6415
+ </html>`;
6416
+ }
6417
+
6418
+ async function startMcpStdio() {
6419
+ const rl = readline.createInterface({ input, terminal: false });
6420
+ for await (const line of rl) {
6421
+ if (!line.trim()) continue;
6422
+ let payload = {};
6423
+ try {
6424
+ payload = JSON.parse(line);
6425
+ const result = await handleMcpMessage(payload);
6426
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result })}\n`);
6427
+ } catch (error) {
6428
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } })}\n`);
6429
+ }
6430
+ }
6431
+ }
6432
+
6433
+ async function handleMcpMessage(payload) {
6434
+ const method = payload.method;
6435
+ if (method === "initialize") {
6436
+ return {
6437
+ protocolVersion: "2024-11-05",
6438
+ serverInfo: { name: "iola-local-mcp", version: getPackageVersion() },
6439
+ capabilities: { tools: {}, resources: {}, prompts: {} },
6440
+ };
6441
+ }
6442
+ if (method === "tools/list") return { tools: mcpTools() };
6443
+ if (method === "tools/call") {
6444
+ const result = await callMcpTool(payload.params?.name, payload.params?.arguments || {});
6445
+ return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] };
6446
+ }
6447
+ if (method === "resources/list") return { resources: mcpResources() };
6448
+ if (method === "resources/read") {
6449
+ const text = await readMcpResource(payload.params?.uri);
6450
+ return { contents: [{ uri: payload.params?.uri, mimeType: "application/json", text }] };
6451
+ }
6452
+ if (method === "prompts/list") return { prompts: mcpPrompts() };
6453
+ if (method === "prompts/get") return getMcpPrompt(payload.params?.name, payload.params?.arguments || {});
6454
+ if (method === "notifications/initialized") return {};
6455
+ throw new Error(`MCP method неизвестен: ${method}`);
6456
+ }
6457
+
6458
+ function mcpTools() {
6459
+ const schema = (properties = {}) => ({ type: "object", properties, additionalProperties: false });
6460
+ return [
6461
+ { name: "status", description: "Статус локальной БД, sync и активного AI-профиля.", inputSchema: schema() },
6462
+ { name: "search", description: "Поиск по локальным открытым данным Йошкар-Олы.", inputSchema: schema({ query: { type: "string" }, dataset: { type: "string" }, limit: { type: "number" } }) },
6463
+ { name: "card", description: "Карточка объекта по названию или ИНН.", inputSchema: schema({ query: { type: "string" } }) },
6464
+ { name: "quality", description: "Проверки качества данных.", inputSchema: schema({ scope: { type: "string" } }) },
6465
+ { name: "sync", description: "Обновление локальной копии слоя.", inputSchema: schema({ dataset: { type: "string" } }) },
6466
+ { name: "files.tree", description: "Дерево файлов разрешенного workspace.", inputSchema: schema({ path: { type: "string" }, depth: { type: "number" }, limit: { type: "number" } }) },
6467
+ { name: "files.read", description: "Чтение файла разрешенного workspace.", inputSchema: schema({ path: { type: "string" }, maxBytes: { type: "number" } }) },
6468
+ { name: "files.search", description: "Поиск текста в файлах workspace.", inputSchema: schema({ query: { type: "string" }, path: { type: "string" }, limit: { type: "number" } }) },
6469
+ { name: "index.search", description: "Поиск по индексу локальных документов.", inputSchema: schema({ query: { type: "string" }, limit: { type: "number" } }) },
6470
+ { name: "report", description: "Запуск встроенного отчета.", inputSchema: schema({ name: { type: "string" }, format: { type: "string" }, output: { type: "string" } }) },
6471
+ ];
6472
+ }
6473
+
6474
+ function mcpResources() {
6475
+ return [
6476
+ { uri: "iola://status", name: "Статус CLI", mimeType: "application/json" },
6477
+ { uri: "iola://sync", name: "Статус синхронизации", mimeType: "application/json" },
6478
+ { uri: "iola://settings", name: "Эффективные настройки", mimeType: "application/json" },
6479
+ { uri: "iola://skills", name: "Skills", mimeType: "application/json" },
6480
+ { uri: "iola://memory", name: "Память агента", mimeType: "application/json" },
6481
+ { uri: "iola://artifacts", name: "Artifacts", mimeType: "application/json" },
6482
+ ];
6483
+ }
6484
+
6485
+ function mcpPrompts() {
6486
+ return [
6487
+ { name: "data-question", description: "Ответить строго по открытым данным Йошкар-Олы.", arguments: [{ name: "question", required: true }] },
6488
+ { name: "document-review", description: "Проверить документ на полноту и источники.", arguments: [{ name: "file", required: true }] },
6489
+ { name: "report-build", description: "Собрать отчет по выбранному слою.", arguments: [{ name: "dataset", required: true }] },
6490
+ ];
6491
+ }
6492
+
6493
+ async function callMcpTool(name, args = {}) {
6494
+ if (name === "index.search") return searchDocs(args.query || "", Number(args.limit || 20));
6495
+ if (name === "report") {
6496
+ const output = args.output || `${args.name || "education-contacts"}.${args.format || "xlsx"}`;
6497
+ await handleExport([args.name || "education-contacts", "--format", args.format || "xlsx", "--output", output]);
6498
+ return { output };
6499
+ }
6500
+ return executeRpc(name, { ...args, _: [] });
6501
+ }
6502
+
6503
+ async function readMcpResource(uri) {
6504
+ if (uri === "iola://status") return JSON.stringify({ db: getDbStatus(), sync: getSyncStatus() }, null, 2);
6505
+ if (uri === "iola://sync") return JSON.stringify(getSyncStatus(), null, 2);
6506
+ if (uri === "iola://settings") return JSON.stringify(await loadConfig(), null, 2);
6507
+ if (uri === "iola://skills") return JSON.stringify(listSkills(await loadConfig()), null, 2);
6508
+ if (uri === "iola://memory") return JSON.stringify(listMemory(100), null, 2);
6509
+ if (uri === "iola://artifacts") return JSON.stringify(listArtifacts(), null, 2);
6510
+ throw new Error(`MCP resource неизвестен: ${uri}`);
6511
+ }
6512
+
6513
+ function getMcpPrompt(name, args = {}) {
6514
+ if (name === "data-question") {
6515
+ return { messages: [{ role: "user", content: { type: "text", text: `Ответь по открытым данным городского округа "Город Йошкар-Ола", не выдумывая сведения: ${args.question || ""}` } }] };
6516
+ }
6517
+ if (name === "document-review") {
6518
+ return { messages: [{ role: "user", content: { type: "text", text: `Проверь документ ${args.file || ""}: полнота, источники, противоречия, ошибки оформления.` } }] };
6519
+ }
6520
+ if (name === "report-build") {
6521
+ return { messages: [{ role: "user", content: { type: "text", text: `Собери практичный отчет по слою ${args.dataset || "schools"} с выводами и источником данных.` } }] };
6522
+ }
6523
+ throw new Error(`MCP prompt неизвестен: ${name}`);
6524
+ }
6525
+
5695
6526
  function readRequestBody(req) {
5696
6527
  return new Promise((resolve, reject) => {
5697
6528
  let body = "";
@@ -6125,6 +6956,101 @@ function recordToolTrace(runId, tool, args, status, summary) {
6125
6956
  }
6126
6957
  }
6127
6958
 
6959
+ function recordUsage({ providerConfig, question, answer, sessionId, profile }) {
6960
+ try {
6961
+ initDatabase();
6962
+ const inputChars = String(question || "").length;
6963
+ const outputChars = String(answer || "").length;
6964
+ const estimatedTokens = Math.ceil((inputChars + outputChars) / 4);
6965
+ const estimatedCostUsd = estimateCost(providerConfig, estimatedTokens);
6966
+ const db = openDatabase();
6967
+ try {
6968
+ db.prepare(`
6969
+ INSERT INTO usage_events(provider, model, profile, input_chars, output_chars, estimated_tokens, estimated_cost_usd, session_id)
6970
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6971
+ `).run(
6972
+ providerConfig.provider || "",
6973
+ providerConfig.model || "",
6974
+ profile || providerConfig.name || "",
6975
+ inputChars,
6976
+ outputChars,
6977
+ estimatedTokens,
6978
+ estimatedCostUsd,
6979
+ sessionId || null,
6980
+ );
6981
+ } finally {
6982
+ db.close();
6983
+ }
6984
+ } catch {
6985
+ // Usage accounting must not break the main answer path.
6986
+ }
6987
+ }
6988
+
6989
+ function estimateCost(providerConfig, tokens) {
6990
+ if (!providerConfig || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
6991
+ const perMillion = providerConfig.provider === "openrouter" ? 0.25 : 0.4;
6992
+ return Math.round((tokens / 1_000_000) * perMillion * 1_000_000) / 1_000_000;
6993
+ }
6994
+
6995
+ function getUsageSummary() {
6996
+ initDatabase();
6997
+ const db = openDatabase();
6998
+ try {
6999
+ const row = db.prepare("SELECT COUNT(*) AS requests, COALESCE(SUM(estimated_tokens),0) AS tokens, COALESCE(SUM(estimated_cost_usd),0) AS cost FROM usage_events").get();
7000
+ return { requests: row.requests || 0, estimated_tokens: row.tokens || 0, estimated_cost_usd: Number(row.cost || 0).toFixed(6) };
7001
+ } finally {
7002
+ db.close();
7003
+ }
7004
+ }
7005
+
7006
+ function getUsageByModel() {
7007
+ initDatabase();
7008
+ const db = openDatabase();
7009
+ try {
7010
+ return db.prepare(`
7011
+ SELECT provider, model, COUNT(*) AS requests, COALESCE(SUM(estimated_tokens),0) AS tokens, printf('%.6f', COALESCE(SUM(estimated_cost_usd),0)) AS cost
7012
+ FROM usage_events GROUP BY provider, model ORDER BY requests DESC
7013
+ `).all();
7014
+ } finally {
7015
+ db.close();
7016
+ }
7017
+ }
7018
+
7019
+ function getUsageBySession() {
7020
+ initDatabase();
7021
+ const db = openDatabase();
7022
+ try {
7023
+ return db.prepare(`
7024
+ SELECT COALESCE(session_id, 0) AS session_id, COUNT(*) AS requests, COALESCE(SUM(estimated_tokens),0) AS tokens, printf('%.6f', COALESCE(SUM(estimated_cost_usd),0)) AS cost
7025
+ FROM usage_events GROUP BY session_id ORDER BY requests DESC LIMIT 100
7026
+ `).all();
7027
+ } finally {
7028
+ db.close();
7029
+ }
7030
+ }
7031
+
7032
+ function listBudgets() {
7033
+ initDatabase();
7034
+ const db = openDatabase();
7035
+ try {
7036
+ const spent = Number(db.prepare("SELECT COALESCE(SUM(estimated_cost_usd),0) AS spent FROM usage_events WHERE created_at >= datetime('now','-1 day')").get()?.spent || 0);
7037
+ return db.prepare("SELECT scope, amount_usd, updated_at FROM budgets ORDER BY scope").all()
7038
+ .map((row) => ({ ...row, spent_usd: row.scope === "daily" ? spent.toFixed(6) : "-" }));
7039
+ } finally {
7040
+ db.close();
7041
+ }
7042
+ }
7043
+
7044
+ function setBudget(scope, amountUsd) {
7045
+ initDatabase();
7046
+ const db = openDatabase();
7047
+ try {
7048
+ db.prepare("INSERT INTO budgets(scope, amount_usd) VALUES (?, ?) ON CONFLICT(scope) DO UPDATE SET amount_usd = excluded.amount_usd, updated_at = datetime('now')").run(scope, amountUsd);
7049
+ } finally {
7050
+ db.close();
7051
+ }
7052
+ }
7053
+
6128
7054
  function listTrace(limit = 20) {
6129
7055
  initDatabase();
6130
7056
  const db = openDatabase();
@@ -6135,6 +7061,20 @@ function listTrace(limit = 20) {
6135
7061
  }
6136
7062
  }
6137
7063
 
7064
+ function buildTrajectoryRows(limit = 500) {
7065
+ initDatabase();
7066
+ const db = openDatabase();
7067
+ try {
7068
+ const history = db.prepare("SELECT id, created_at, 'ask' AS type, question AS summary, provider, model FROM ask_history ORDER BY id DESC LIMIT ?").all(limit);
7069
+ const traces = db.prepare("SELECT id, created_at, 'tool' AS type, tool || ': ' || COALESCE(summary,'') AS summary, status, run_id FROM tool_traces ORDER BY id DESC LIMIT ?").all(limit);
7070
+ return [...history, ...traces]
7071
+ .sort((left, right) => String(right.created_at).localeCompare(String(left.created_at)))
7072
+ .slice(0, limit);
7073
+ } finally {
7074
+ db.close();
7075
+ }
7076
+ }
7077
+
6138
7078
  function getTraceRun(runId) {
6139
7079
  initDatabase();
6140
7080
  const db = openDatabase();
@@ -6165,6 +7105,22 @@ async function createSnapshot() {
6165
7105
  }
6166
7106
  }
6167
7107
 
7108
+ async function createSandboxCopy(name = "") {
7109
+ const config = await loadConfig();
7110
+ const workspace = resolveWorkspaceRoot(config);
7111
+ const sandboxesDir = path.join(CONFIG_DIR, "sandboxes");
7112
+ await mkdir(sandboxesDir, { recursive: true });
7113
+ const safeName = name ? String(name).replace(/[^a-zA-Z0-9_-]+/g, "-") : `sandbox-${Date.now()}`;
7114
+ const target = path.join(sandboxesDir, safeName);
7115
+ await rm(target, { recursive: true, force: true });
7116
+ await cp(workspace, target, {
7117
+ recursive: true,
7118
+ filter: (source) => !isBlockedPathForConfig(source, config) && !source.includes(`${path.sep}node_modules${path.sep}`) && !source.includes(`${path.sep}.git${path.sep}`),
7119
+ });
7120
+ const id = saveArtifact("sandbox", safeName, target, { workspace });
7121
+ return { id, workspace, path: target };
7122
+ }
7123
+
6168
7124
  function listSnapshots() {
6169
7125
  initDatabase();
6170
7126
  const db = openDatabase();
@@ -6440,7 +7396,7 @@ async function executeRpc(method, options = {}) {
6440
7396
  if (method === "index.search") {
6441
7397
  return searchDocs(options.query || options.search || "", Number(options.limit || 20));
6442
7398
  }
6443
- throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
7399
+ throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync, files.tree, files.read, files.search, index.search.`);
6444
7400
  }
6445
7401
 
6446
7402
  async function getLatestNpmVersion(packageName) {
@@ -6567,11 +7523,39 @@ async function writeConfig(value) {
6567
7523
  }
6568
7524
 
6569
7525
  async function loadConfig() {
7526
+ let config = DEFAULT_AI_CONFIG;
7527
+ for (const layer of [CONFIG_FILE, PROJECT_CONFIG_FILE, LOCAL_CONFIG_FILE]) {
7528
+ const value = await readConfigLayer(layer);
7529
+ if (value) config = mergeConfig(config, value);
7530
+ }
7531
+ return config;
7532
+ }
7533
+
7534
+ async function loadConfigLayers() {
7535
+ const files = [
7536
+ { scope: "defaults", file: "builtin", value: DEFAULT_AI_CONFIG, exists: true },
7537
+ { scope: "user", file: CONFIG_FILE },
7538
+ { scope: "project", file: PROJECT_CONFIG_FILE },
7539
+ { scope: "local", file: LOCAL_CONFIG_FILE },
7540
+ ];
7541
+ const rows = [];
7542
+ for (const layer of files) {
7543
+ if (layer.scope === "defaults") {
7544
+ rows.push({ ...layer, errors: validateConfig(layer.value) });
7545
+ continue;
7546
+ }
7547
+ const value = await readConfigLayer(layer.file);
7548
+ rows.push({ ...layer, exists: Boolean(value), value, errors: value ? validateConfig(mergeConfig(DEFAULT_AI_CONFIG, value)) : [] });
7549
+ }
7550
+ rows.push({ scope: "runtime", file: "process.env", exists: true, value: { IOLA_API_BASE_URL: process.env.IOLA_API_BASE_URL || "", IOLA_MCP_BASE_URL: process.env.IOLA_MCP_BASE_URL || "" }, errors: [] });
7551
+ return rows;
7552
+ }
7553
+
7554
+ async function readConfigLayer(file) {
6570
7555
  try {
6571
- const text = await readFile(CONFIG_FILE, "utf8");
6572
- return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
7556
+ return JSON.parse(await readFile(file, "utf8"));
6573
7557
  } catch {
6574
- return DEFAULT_AI_CONFIG;
7558
+ return null;
6575
7559
  }
6576
7560
  }
6577
7561
 
@@ -6623,6 +7607,20 @@ function mergeConfig(base, override) {
6623
7607
  ...base.cron,
6624
7608
  ...(override.cron || {}),
6625
7609
  },
7610
+ hooks: {
7611
+ ...base.hooks,
7612
+ ...(override.hooks || {}),
7613
+ },
7614
+ subagents: {
7615
+ ...(base.subagents || {}),
7616
+ ...(override.subagents || {}),
7617
+ },
7618
+ workspaces: {
7619
+ ...(base.workspaces || {}),
7620
+ ...(override.workspaces || {}),
7621
+ },
7622
+ hooksTrusted: override.hooksTrusted ?? base.hooksTrusted,
7623
+ local: override.local ?? base.local,
6626
7624
  };
6627
7625
  }
6628
7626
 
@@ -6783,6 +7781,7 @@ function runCommand(command, args, options = {}) {
6783
7781
  const child = execFile(command, args, {
6784
7782
  windowsHide: true,
6785
7783
  maxBuffer: 1024 * 1024 * 5,
7784
+ cwd: options.cwd,
6786
7785
  env: {
6787
7786
  ...process.env,
6788
7787
  ...(options.env || {}),