@iola_adm/iola-cli 0.1.29 → 0.1.31

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,17 @@ 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");
25
+ const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
26
+ const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
22
27
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
23
28
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
24
29
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
25
30
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
26
- const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
31
+ const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
27
32
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
28
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
34
  const BUILTIN_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
@@ -82,6 +87,23 @@ const FEATURES = {
82
87
  "mcp-management": { stage: "stable", defaultEnabled: true, description: "Команды управления MCP-интеграциями." },
83
88
  "web-search": { stage: "experimental", defaultEnabled: false, description: "Резерв под web-search режимы AI." },
84
89
  };
90
+ const SKILL_BUNDLES = {
91
+ analyst: {
92
+ description: "Аналитик открытых данных: поиск, карточки, отчеты и память.",
93
+ skills: ["open-data", "reports", "local-model"],
94
+ requirements: ["Локальная SQLite-БД", "публичный API"],
95
+ },
96
+ documents: {
97
+ description: "Работа с локальными документами, индексом, архивами и выгрузками.",
98
+ skills: ["open-data", "reports"],
99
+ requirements: ["files mode read-only/workspace-write", "7-Zip для архивов"],
100
+ },
101
+ "local-agent": {
102
+ description: "Локальная модель Ollama с проверочным reasoning и локальными tools.",
103
+ skills: ["local-model", "open-data"],
104
+ requirements: ["Ollama", "локальная модель"],
105
+ },
106
+ };
85
107
  const DEFAULT_AI_CONFIG = {
86
108
  api: {
87
109
  baseUrl: "https://apiiola.yasg.ru/api/v1",
@@ -239,6 +261,7 @@ const COMMANDS = new Map([
239
261
  ["resume", resumeSession],
240
262
  ["fork", forkSession],
241
263
  ["features", handleFeatures],
264
+ ["settings", handleSettings],
242
265
  ["wiki", handleWiki],
243
266
  ["context", handleContext],
244
267
  ["skills", handleSkills],
@@ -250,11 +273,16 @@ const COMMANDS = new Map([
250
273
  ["index", handleIndex],
251
274
  ["reports", handleReports],
252
275
  ["plugins", handlePlugins],
276
+ ["browser", handleBrowser],
253
277
  ["workspace", handleWorkspace],
254
278
  ["tasks", handleTasks],
255
279
  ["artifacts", handleArtifacts],
256
280
  ["snapshot", handleSnapshot],
281
+ ["sandbox", handleSandbox],
257
282
  ["trace", handleTrace],
283
+ ["trajectory", handleTrajectory],
284
+ ["usage", handleUsage],
285
+ ["budget", handleBudget],
258
286
  ["policy", handlePolicy],
259
287
  ["export", handleExport],
260
288
  ["cron", handleCron],
@@ -264,6 +292,8 @@ const COMMANDS = new Map([
264
292
  ["memory", handleMemory],
265
293
  ["hooks", handleHooks],
266
294
  ["agents", handleAgents],
295
+ ["subagents", handleSubagents],
296
+ ["review", handleReview],
267
297
  ["mcp", handleMcp],
268
298
  ["cache", handleCache],
269
299
  ["sync", handleSync],
@@ -353,12 +383,14 @@ Usage:
353
383
  iola history [--limit 20]
354
384
  iola history clear
355
385
  iola sessions [--limit 20]
386
+ iola sessions replay SESSION_ID
356
387
  iola resume SESSION_ID [TEXT]
357
388
  iola fork SESSION_ID [TEXT]
358
389
  iola features list|enable|disable
390
+ iola settings list|get|validate|doctor|init
359
391
  iola wiki [open|links]
360
392
  iola context list|show|init
361
- iola skills list|show|paths|enable|disable
393
+ iola skills list|show|paths|enable|disable|bundles|bundle|doctor
362
394
  iola tools list|toolsets|enable|disable|profile
363
395
  iola files status|mode|approvals|tree|read|search|write|patch
364
396
  iola archive doctor|list|test|extract|create|index
@@ -367,21 +399,28 @@ Usage:
367
399
  iola index folder|status|search
368
400
  iola reports list|run
369
401
  iola plugins list|install|run|remove
402
+ iola browser status|install|open|text|html|screenshot|pdf|click|type|eval
370
403
  iola workspace init|status|use|list
371
404
  iola tasks list|add|done|run
372
405
  iola artifacts list|show|open
373
406
  iola snapshot create|list|restore
407
+ iola sandbox fork|run|diff|apply
374
408
  iola trace last|show
409
+ iola trajectory export|last
410
+ iola usage summary|models|sessions
411
+ iola budget status|set
375
412
  iola policy use safe|analyst|developer|full
376
413
  iola export REPORT --format docx|xlsx --output FILE
377
414
  iola cron list|add|delete|run|tick
378
415
  iola daemon start|status
379
416
  iola rpc call METHOD [ARGS] [--json]
380
417
  iola permissions list|allow|deny
381
- iola memory show|add|set|clear|export
382
- iola hooks list|add|delete|run
418
+ iola memory show|add|set|clear|export|curate|duplicates|prune
419
+ iola hooks list|events|add|delete|run|trust|audit
383
420
  iola agents list|run
384
- iola mcp list|status|install|remove|serve
421
+ iola subagents list|run|parallel|add
422
+ iola review config|data|docs|report
423
+ iola mcp list|status|install|remove|serve [--stdio]
385
424
  iola cache status|warm|clear
386
425
  iola sync [--dataset schools|kindergartens]
387
426
  iola sync status
@@ -975,6 +1014,14 @@ function showBanner() {
975
1014
  console.log("открытые данные • MCP • локальный AI");
976
1015
  }
977
1016
 
1017
+ function getPackageVersion() {
1018
+ try {
1019
+ return JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8")).version;
1020
+ } catch {
1021
+ return "0.0.0";
1022
+ }
1023
+ }
1024
+
978
1025
  async function showVersion(args = []) {
979
1026
  const options = parseOptions(args);
980
1027
  const packageJson = await import("../package.json", { with: { type: "json" } });
@@ -1512,6 +1559,17 @@ async function handleSessions(args) {
1512
1559
  return;
1513
1560
  }
1514
1561
 
1562
+ if (action === "replay") {
1563
+ const sessionId = Number(args[1]);
1564
+ if (!sessionId) throw new Error("Пример: iola sessions replay 1");
1565
+ const rows = getSessionMessages(sessionId);
1566
+ for (const row of rows) {
1567
+ console.log(`\n[${row.role}] ${row.created_at}`);
1568
+ console.log(row.content);
1569
+ }
1570
+ return;
1571
+ }
1572
+
1515
1573
  const rows = listSessions(Number(options.limit || 20));
1516
1574
 
1517
1575
  if (options.json) {
@@ -1587,6 +1645,61 @@ async function handleFeatures(args) {
1587
1645
  throw new Error("Команды features: list, enable NAME, disable NAME.");
1588
1646
  }
1589
1647
 
1648
+ async function handleSettings(args) {
1649
+ const [action = "list", key] = args;
1650
+ const layers = await loadConfigLayers();
1651
+ const effective = await loadConfig();
1652
+
1653
+ if (action === "list" || action === "ls" || action === "doctor") {
1654
+ const rows = layers.map((layer) => ({
1655
+ scope: layer.scope,
1656
+ file: layer.file,
1657
+ exists: layer.exists ? "yes" : "no",
1658
+ valid: layer.errors.length ? "no" : "yes",
1659
+ errors: layer.errors.join("; ") || "-",
1660
+ }));
1661
+ printTable(rows, [["scope", "Слой"], ["exists", "Есть"], ["valid", "Валиден"], ["file", "Файл"], ["errors", "Ошибки"]]);
1662
+ return;
1663
+ }
1664
+
1665
+ if (action === "get") {
1666
+ if (!key) {
1667
+ printJson(effective);
1668
+ return;
1669
+ }
1670
+ const value = getConfigValue(effective, key);
1671
+ if (typeof value === "object") printJson(value);
1672
+ else console.log(value ?? "-");
1673
+ return;
1674
+ }
1675
+
1676
+ if (action === "validate") {
1677
+ const errors = validateConfig(effective);
1678
+ if (errors.length) {
1679
+ printTable(errors.map((error) => ({ error })), [["error", "Ошибка"]]);
1680
+ process.exitCode = 1;
1681
+ return;
1682
+ }
1683
+ console.log("Конфигурация валидна.");
1684
+ return;
1685
+ }
1686
+
1687
+ if (action === "init") {
1688
+ await mkdir(PROJECT_IOLA_DIR, { recursive: true });
1689
+ if (!existsSync(PROJECT_CONFIG_FILE)) {
1690
+ await writeFile(PROJECT_CONFIG_FILE, `${JSON.stringify({ files: { workspaceRoot: "." } }, null, 2)}\n`, "utf8");
1691
+ }
1692
+ if (!existsSync(LOCAL_CONFIG_FILE)) {
1693
+ await writeFile(LOCAL_CONFIG_FILE, `${JSON.stringify({ local: true }, null, 2)}\n`, "utf8");
1694
+ }
1695
+ console.log(`Создан project config: ${PROJECT_CONFIG_FILE}`);
1696
+ console.log(`Создан local config: ${LOCAL_CONFIG_FILE}`);
1697
+ return;
1698
+ }
1699
+
1700
+ throw new Error("Команды settings: list, get [KEY], validate, doctor, init.");
1701
+ }
1702
+
1590
1703
  async function handleWiki(args) {
1591
1704
  const [action = "links"] = args;
1592
1705
  const base = "https://github.com/adm-iola/iola-cli/wiki";
@@ -1599,6 +1712,8 @@ async function handleWiki(args) {
1599
1712
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1600
1713
  ["Локальные файлы", `${base}/Локальные-файлы`],
1601
1714
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1715
+ ["Платформа агента", `${base}/Платформа-агента`],
1716
+ ["Браузерный агент", `${base}/Браузерный-агент`],
1602
1717
  ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1603
1718
  ["Архивы и мастер настройки", `${base}/Архивы-и-мастер-настройки`],
1604
1719
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
@@ -1701,6 +1816,41 @@ async function handleSkills(args) {
1701
1816
  return;
1702
1817
  }
1703
1818
 
1819
+ if (action === "bundles") {
1820
+ const enabled = new Set(config.skills?.enabled || []);
1821
+ const rows = Object.entries(SKILL_BUNDLES).map(([bundle, meta]) => ({
1822
+ bundle,
1823
+ enabled: meta.skills.every((skill) => enabled.has(skill)) ? "yes" : "partial/no",
1824
+ skills: meta.skills.join(", "),
1825
+ description: meta.description,
1826
+ }));
1827
+ printTable(rows, [["bundle", "Bundle"], ["enabled", "Вкл"], ["skills", "Skills"], ["description", "Описание"]]);
1828
+ return;
1829
+ }
1830
+
1831
+ if (action === "bundle") {
1832
+ const [operation, bundleName] = args.slice(1);
1833
+ if (operation !== "enable" || !SKILL_BUNDLES[bundleName]) {
1834
+ throw new Error(`Пример: iola skills bundle enable analyst. Доступно: ${Object.keys(SKILL_BUNDLES).join(", ")}`);
1835
+ }
1836
+ const enabled = new Set(config.skills?.enabled || []);
1837
+ for (const skill of SKILL_BUNDLES[bundleName].skills) enabled.add(skill);
1838
+ await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabled] } });
1839
+ console.log(`Skill bundle включен: ${bundleName}`);
1840
+ return;
1841
+ }
1842
+
1843
+ if (action === "doctor") {
1844
+ const skills = listSkills(config);
1845
+ const enabled = new Set(config.skills?.enabled || []);
1846
+ const rows = [
1847
+ ...skills.map((skill) => ({ item: skill.name, type: "skill", status: enabled.has(skill.name) ? "enabled" : "available", detail: skill.file })),
1848
+ ...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(", ") })),
1849
+ ];
1850
+ printTable(rows, [["type", "Тип"], ["item", "Имя"], ["status", "Статус"], ["detail", "Детали"]]);
1851
+ return;
1852
+ }
1853
+
1704
1854
  if (action === "show") {
1705
1855
  const skill = findSkill(name, config);
1706
1856
  if (!skill) throw new Error(`Skill не найден: ${name}`);
@@ -1718,7 +1868,7 @@ async function handleSkills(args) {
1718
1868
  return;
1719
1869
  }
1720
1870
 
1721
- throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME.");
1871
+ throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME, bundles, bundle enable NAME, doctor.");
1722
1872
  }
1723
1873
 
1724
1874
  async function handleTools(args) {
@@ -2064,6 +2214,94 @@ async function handlePlugins(args) {
2064
2214
  throw new Error("Команды plugins: list, install NAME --command CMD, run NAME, remove NAME.");
2065
2215
  }
2066
2216
 
2217
+ async function handleBrowser(args) {
2218
+ const [action = "status", target, ...rest] = args;
2219
+ const options = parseOptions(rest);
2220
+
2221
+ if (action === "status") {
2222
+ printKeyValue(await getBrowserStatus());
2223
+ return;
2224
+ }
2225
+
2226
+ if (action === "install") {
2227
+ await installBrowserRuntime();
2228
+ printKeyValue(await getBrowserStatus());
2229
+ return;
2230
+ }
2231
+
2232
+ if (action === "open") {
2233
+ const url = target || options.url;
2234
+ if (!url) throw new Error("Пример: iola browser open https://example.com");
2235
+ if (options.system) {
2236
+ await openUrl(url);
2237
+ return;
2238
+ }
2239
+ await runBrowserAutomation("open", { url, headed: options.headless ? false : true, waitMs: Number(options.wait || 600000) });
2240
+ return;
2241
+ }
2242
+
2243
+ if (action === "text" || action === "html") {
2244
+ const url = target || options.url;
2245
+ if (!url) throw new Error(`Пример: iola browser ${action} https://example.com`);
2246
+ const result = await runBrowserAutomation(action, browserParams(url, options));
2247
+ if (options.output) {
2248
+ await writeFile(options.output, result, "utf8");
2249
+ console.log(`Файл сохранен: ${options.output}`);
2250
+ } else {
2251
+ console.log(result);
2252
+ }
2253
+ return;
2254
+ }
2255
+
2256
+ if (action === "screenshot" || action === "pdf") {
2257
+ const url = target || options.url;
2258
+ if (!url) throw new Error(`Пример: iola browser ${action} https://example.com --output page.${action === "pdf" ? "pdf" : "png"}`);
2259
+ const output = options.output || path.join(process.cwd(), action === "pdf" ? "browser-page.pdf" : "browser-page.png");
2260
+ await runBrowserAutomation(action, { ...browserParams(url, options), output: path.resolve(output) });
2261
+ saveArtifact(action === "pdf" ? "browser-pdf" : "browser-screenshot", url, path.resolve(output), { url });
2262
+ console.log(`Файл сохранен: ${output}`);
2263
+ return;
2264
+ }
2265
+
2266
+ if (action === "click") {
2267
+ const url = target || options.url;
2268
+ if (!url || !options.selector) throw new Error('Пример: iola browser click https://example.com --selector "button" --output after.png');
2269
+ const result = await runBrowserAutomation("click", { ...browserParams(url, options), selector: options.selector, output: options.output ? path.resolve(options.output) : "" });
2270
+ if (result) console.log(result);
2271
+ return;
2272
+ }
2273
+
2274
+ if (action === "type") {
2275
+ const url = target || options.url;
2276
+ if (!url || !options.selector || options.text === undefined) throw new Error('Пример: iola browser type https://example.com --selector "#q" --text "школа 29"');
2277
+ const result = await runBrowserAutomation("type", { ...browserParams(url, options), selector: options.selector, text: options.text, press: options.press || "", output: options.output ? path.resolve(options.output) : "" });
2278
+ if (result) console.log(result);
2279
+ return;
2280
+ }
2281
+
2282
+ if (action === "eval") {
2283
+ const url = target || options.url;
2284
+ const script = options.script || rest.join(" ");
2285
+ if (!url || !script) throw new Error('Пример: iola browser eval https://example.com --script "document.title"');
2286
+ const result = await runBrowserAutomation("eval", { ...browserParams(url, options), script });
2287
+ console.log(result);
2288
+ return;
2289
+ }
2290
+
2291
+ throw new Error("Команды browser: status, install, open URL, text URL, html URL, screenshot URL --output FILE, pdf URL --output FILE, click URL --selector SEL, type URL --selector SEL --text TEXT, eval URL --script JS.");
2292
+ }
2293
+
2294
+ function browserParams(url, options = {}) {
2295
+ return {
2296
+ url,
2297
+ headed: Boolean(options.headed),
2298
+ timeout: Number(options.timeout || 30000),
2299
+ waitMs: Number(options.wait || 0),
2300
+ selector: options.selector || "",
2301
+ viewport: options.viewport || "1366x768",
2302
+ };
2303
+ }
2304
+
2067
2305
  async function handleWorkspace(args) {
2068
2306
  const [action = "status", nameOrPath] = args;
2069
2307
  const config = await loadConfig();
@@ -2161,6 +2399,38 @@ async function handleSnapshot(args) {
2161
2399
  throw new Error("Команды snapshot: create, list, restore ID.");
2162
2400
  }
2163
2401
 
2402
+ async function handleSandbox(args) {
2403
+ const [action = "fork", ...rest] = args;
2404
+ if (action === "fork") {
2405
+ const result = await createSandboxCopy(rest[0]);
2406
+ printKeyValue(result);
2407
+ return;
2408
+ }
2409
+ if (action === "run") {
2410
+ const command = rest.join(" ").trim();
2411
+ if (!command) throw new Error('Пример: iola sandbox run "npm test"');
2412
+ const sandbox = await createSandboxCopy();
2413
+ const parts = splitCommandLine(command);
2414
+ console.log(`Sandbox: ${sandbox.path}`);
2415
+ await runCommand(parts[0], parts.slice(1), { inherit: true, cwd: sandbox.path });
2416
+ return;
2417
+ }
2418
+ if (action === "diff") {
2419
+ const sandboxPath = rest[0];
2420
+ if (!sandboxPath) throw new Error("Пример: iola sandbox diff PATH");
2421
+ await runCommand("git", ["diff", "--no-index", process.cwd(), sandboxPath], { inherit: true }).catch(() => {});
2422
+ return;
2423
+ }
2424
+ if (action === "apply") {
2425
+ const sandboxPath = rest[0];
2426
+ if (!sandboxPath) throw new Error("Пример: iola sandbox apply PATH");
2427
+ await cp(sandboxPath, process.cwd(), { recursive: true, force: true });
2428
+ console.log(`Sandbox применен: ${sandboxPath}`);
2429
+ return;
2430
+ }
2431
+ throw new Error("Команды sandbox: fork [NAME], run COMMAND, diff PATH, apply PATH.");
2432
+ }
2433
+
2164
2434
  async function handleTrace(args) {
2165
2435
  const [action = "last", id] = args;
2166
2436
  if (action === "last") {
@@ -2174,6 +2444,61 @@ async function handleTrace(args) {
2174
2444
  throw new Error("Команды trace: last [LIMIT], show RUN_ID.");
2175
2445
  }
2176
2446
 
2447
+ async function handleTrajectory(args) {
2448
+ const [action = "export", ...rest] = args;
2449
+ const options = parseOptions(rest);
2450
+ if (action === "last") {
2451
+ const rows = buildTrajectoryRows(Number(options.limit || rest[0] || 20));
2452
+ if (options.json) printJson(rows);
2453
+ else printTable(rows, [["type", "Тип"], ["id", "ID"], ["created_at", "Дата"], ["summary", "Сводка"]]);
2454
+ return;
2455
+ }
2456
+ if (action === "export") {
2457
+ const format = options.format || "jsonl";
2458
+ const output = options.output || path.join(process.cwd(), `iola-trajectory-${Date.now()}.${format}`);
2459
+ const rows = buildTrajectoryRows(Number(options.limit || 500));
2460
+ const text = format === "json" ? `${JSON.stringify(rows, null, 2)}\n` : rows.map((row) => JSON.stringify(row)).join("\n") + "\n";
2461
+ await writeFile(output, text, "utf8");
2462
+ saveArtifact("trajectory", path.basename(output), output, { format, rows: rows.length });
2463
+ console.log(`Trajectory экспортирована: ${output}`);
2464
+ return;
2465
+ }
2466
+ throw new Error("Команды trajectory: last [--limit N], export [--format jsonl|json] [--output FILE].");
2467
+ }
2468
+
2469
+ async function handleUsage(args) {
2470
+ const [action = "summary"] = args;
2471
+ if (action === "summary") {
2472
+ printKeyValue(getUsageSummary());
2473
+ return;
2474
+ }
2475
+ if (action === "models") {
2476
+ printTable(getUsageByModel(), [["provider", "Провайдер"], ["model", "Модель"], ["requests", "Запросы"], ["tokens", "Токены"], ["cost", "USD"]]);
2477
+ return;
2478
+ }
2479
+ if (action === "sessions") {
2480
+ printTable(getUsageBySession(), [["session_id", "Сессия"], ["requests", "Запросы"], ["tokens", "Токены"], ["cost", "USD"]]);
2481
+ return;
2482
+ }
2483
+ throw new Error("Команды usage: summary, models, sessions.");
2484
+ }
2485
+
2486
+ async function handleBudget(args) {
2487
+ const [action = "status", scope = "daily", amount] = args;
2488
+ if (action === "status") {
2489
+ printTable(listBudgets(), [["scope", "Область"], ["amount_usd", "Лимит USD"], ["spent_usd", "Потрачено"], ["updated_at", "Обновлено"]]);
2490
+ return;
2491
+ }
2492
+ if (action === "set") {
2493
+ const value = Number(amount || args[2]);
2494
+ if (!value || value < 0) throw new Error("Пример: iola budget set daily 5");
2495
+ setBudget(scope, value);
2496
+ console.log(`Budget сохранен: ${scope}=${value} USD`);
2497
+ return;
2498
+ }
2499
+ throw new Error("Команды budget: status, set daily AMOUNT.");
2500
+ }
2501
+
2177
2502
  async function handlePolicy(args) {
2178
2503
  const [action = "list", name] = args;
2179
2504
  const policies = {
@@ -2445,7 +2770,26 @@ async function handleMemory(args) {
2445
2770
  return;
2446
2771
  }
2447
2772
 
2448
- throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE].");
2773
+ if (action === "duplicates" || action === "curate") {
2774
+ const rows = findMemoryDuplicates();
2775
+ if (options.json) printJson(rows);
2776
+ else printTable(rows, [["keeper_id", "Оставить"], ["duplicate_id", "Дубликат"], ["content", "Текст"]]);
2777
+ return;
2778
+ }
2779
+
2780
+ if (action === "prune") {
2781
+ const rows = findMemoryDuplicates();
2782
+ if (!options.yes) {
2783
+ printTable(rows, [["keeper_id", "Оставить"], ["duplicate_id", "Удалить"], ["content", "Текст"]]);
2784
+ console.log("Для удаления дубликатов запустите: iola memory prune --yes");
2785
+ return;
2786
+ }
2787
+ for (const row of rows) deleteMemory(row.duplicate_id);
2788
+ console.log(`Удалено дубликатов памяти: ${rows.length}`);
2789
+ return;
2790
+ }
2791
+
2792
+ throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE], curate, duplicates, prune --yes.");
2449
2793
  }
2450
2794
 
2451
2795
  async function handleHooks(args) {
@@ -2468,6 +2812,22 @@ async function handleHooks(args) {
2468
2812
  return;
2469
2813
  }
2470
2814
 
2815
+ if (action === "trust") {
2816
+ await saveConfig({ hooksTrusted: true });
2817
+ console.log("Hooks помечены как доверенные для текущего пользователя.");
2818
+ return;
2819
+ }
2820
+
2821
+ if (action === "audit") {
2822
+ const rows = Object.entries(config.hooks || {}).map(([hookEvent, commands]) => ({
2823
+ event: hookEvent,
2824
+ commands: commands.length,
2825
+ trusted: config.hooksTrusted ? "yes" : "no",
2826
+ }));
2827
+ printTable(rows, [["event", "Событие"], ["commands", "Команд"], ["trusted", "Доверено"]]);
2828
+ return;
2829
+ }
2830
+
2471
2831
  if (action === "add") {
2472
2832
  if (!HOOK_EVENTS.includes(event) || commandParts.length === 0) {
2473
2833
  throw new Error(`Пример: iola hooks add AfterSync "iola quality" Доступно: ${HOOK_EVENTS.join(", ")}`);
@@ -2499,7 +2859,7 @@ async function handleHooks(args) {
2499
2859
  return;
2500
2860
  }
2501
2861
 
2502
- throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT.");
2862
+ throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT, trust, audit.");
2503
2863
  }
2504
2864
 
2505
2865
  async function handleAgents(args) {
@@ -2548,8 +2908,132 @@ async function handleAgents(args) {
2548
2908
  throw new Error("Команды agents: list, run NAME TEXT.");
2549
2909
  }
2550
2910
 
2911
+ async function handleSubagents(args) {
2912
+ const [action = "list", name, ...rest] = args;
2913
+ const config = await loadConfig();
2914
+ const custom = config.subagents || {};
2915
+ const agents = { ...AGENTS, ...custom };
2916
+
2917
+ if (action === "list" || action === "ls") {
2918
+ const rows = Object.entries(agents).map(([agent, meta]) => ({
2919
+ agent,
2920
+ profile: meta.profile || "active",
2921
+ tools: meta.tools ? "yes" : "no",
2922
+ source: AGENTS[agent] ? "builtin" : "user",
2923
+ description: meta.description || "-",
2924
+ }));
2925
+ printTable(rows, [["agent", "Subagent"], ["profile", "Профиль"], ["tools", "Tools"], ["source", "Источник"], ["description", "Описание"]]);
2926
+ return;
2927
+ }
2928
+
2929
+ if (action === "add") {
2930
+ const options = parseOptions(rest);
2931
+ if (!name) throw new Error("Пример: iola subagents add culture --profile local --prompt \"...\"");
2932
+ const prompt = options.prompt || options.command || options._.join(" ");
2933
+ const next = {
2934
+ ...custom,
2935
+ [name]: {
2936
+ profile: options.profile || null,
2937
+ tools: Boolean(options.tools),
2938
+ prefix: prompt ? `${prompt} ` : "",
2939
+ description: options.description || prompt || "Пользовательский subagent",
2940
+ },
2941
+ };
2942
+ await saveConfig({ subagents: next });
2943
+ console.log(`Subagent добавлен: ${name}`);
2944
+ return;
2945
+ }
2946
+
2947
+ if (action === "run") {
2948
+ if (!agents[name]) throw new Error(`Subagent неизвестен: ${name}. Доступно: ${Object.keys(agents).join(", ")}`);
2949
+ await runSubagent(name, agents[name], rest);
2950
+ return;
2951
+ }
2952
+
2953
+ if (action === "parallel") {
2954
+ const names = String(name || "").split(",").map((item) => item.trim()).filter(Boolean);
2955
+ const question = rest.join(" ").trim();
2956
+ if (!names.length || !question) throw new Error('Пример: iola subagents parallel data-analyst,reviewer "проверь школы"');
2957
+ for (const agentName of names) {
2958
+ if (!agents[agentName]) throw new Error(`Subagent неизвестен: ${agentName}`);
2959
+ console.log(`\n## ${agentName}`);
2960
+ await runSubagent(agentName, agents[agentName], [question, "--no-history"]);
2961
+ }
2962
+ return;
2963
+ }
2964
+
2965
+ throw new Error("Команды subagents: list, add NAME --profile PROFILE --prompt TEXT, run NAME TEXT, parallel a,b TEXT.");
2966
+ }
2967
+
2968
+ async function runSubagent(name, agent, rest) {
2969
+ const options = parseOptions(rest);
2970
+ const question = options._.join(" ").trim();
2971
+ if (!question) throw new Error(`Пример: iola subagents run ${name} "найди школы"`);
2972
+ const askArgs = [agent.prefix ? `${agent.prefix}${question}` : question, "--agent", name];
2973
+ if (agent.profile || options.profile) askArgs.push("--profile", options.profile || agent.profile);
2974
+ if (agent.tools || options.tools) askArgs.push("--tools");
2975
+ if (agent.reasoning || options.reasoning) askArgs.push("--reasoning", options.reasoning || agent.reasoning);
2976
+ if (options.files) askArgs.push("--files");
2977
+ if (options.events) askArgs.push("--events");
2978
+ if (options["no-history"]) askArgs.push("--no-history");
2979
+ await aiAsk(askArgs);
2980
+ }
2981
+
2982
+ async function handleReview(args) {
2983
+ const [action = "config", target, ...rest] = args;
2984
+ const options = parseOptions([target, ...rest].filter(Boolean));
2985
+ const actualTarget = options._[0];
2986
+ if (action === "config") {
2987
+ const errors = validateConfig(await loadConfig());
2988
+ const rows = errors.length ? errors.map((error) => ({ level: "error", message: error })) : [{ level: "ok", message: "Конфигурация валидна" }];
2989
+ printTable(rows, [["level", "Уровень"], ["message", "Сообщение"]]);
2990
+ return;
2991
+ }
2992
+ if (action === "data") {
2993
+ await ensureLocalData();
2994
+ const rows = runQuality(actualTarget || "all");
2995
+ if (options.json) printJson(rows);
2996
+ else printTable(rows, [["check", "Проверка"], ["count", "Кол-во"], ["sample", "Пример"]]);
2997
+ return;
2998
+ }
2999
+ if (action === "docs") {
3000
+ const rows = actualTarget ? await reviewDocumentFolder(actualTarget, options) : searchDocs(options.query || "", Number(options.limit || 20));
3001
+ if (options.json) printJson(rows);
3002
+ else printTable(rows, [["file", "Файл"], ["issue", "Замечание"], ["detail", "Детали"]]);
3003
+ return;
3004
+ }
3005
+ if (action === "report") {
3006
+ if (!actualTarget) throw new Error("Пример: iola review report отчет.docx");
3007
+ const text = await extractReadableText(path.resolve(actualTarget));
3008
+ const rows = [
3009
+ { file: actualTarget, issue: text.trim() ? "ok" : "empty", detail: text.trim() ? "Текст извлечен" : "Не удалось извлечь текст" },
3010
+ { file: actualTarget, issue: /источник|данн/i.test(text) ? "ok" : "missing-source", detail: "Проверьте указание источника данных" },
3011
+ ];
3012
+ printTable(rows, [["file", "Файл"], ["issue", "Замечание"], ["detail", "Детали"]]);
3013
+ return;
3014
+ }
3015
+ throw new Error("Команды review: config, data [scope], docs [PATH], report FILE.");
3016
+ }
3017
+
3018
+ async function reviewDocumentFolder(target, options = {}) {
3019
+ const previous = await loadConfig();
3020
+ const rows = [];
3021
+ try {
3022
+ await saveConfig({ files: { ...(previous.files || {}), workspaceRoot: path.resolve(target), mode: "read-only" } });
3023
+ await setFilesMode("read-only", await loadConfig());
3024
+ const files = await filesTree(".", { depth: Number(options.depth || 5), limit: Number(options.limit || 200) });
3025
+ for (const file of files.filter((item) => item.type === "file" && INDEXABLE_EXTENSIONS.test(item.path))) {
3026
+ rows.push({ file: file.path, issue: "indexable", detail: "Документ можно читать и индексировать" });
3027
+ }
3028
+ } finally {
3029
+ await saveConfig({ files: previous.files, permissions: previous.permissions, toolsets: previous.toolsets }).catch(() => {});
3030
+ }
3031
+ return rows;
3032
+ }
3033
+
2551
3034
  async function handleMcp(args) {
2552
- const [action = "status", target = "codex"] = args;
3035
+ const [action = "status", target = "codex", ...rest] = args;
3036
+ const options = parseOptions([target, ...rest]);
2553
3037
 
2554
3038
  if (action === "status") {
2555
3039
  const [health, version] = await Promise.all([
@@ -2585,6 +3069,10 @@ async function handleMcp(args) {
2585
3069
 
2586
3070
  if (action === "serve") {
2587
3071
  const config = await loadConfig();
3072
+ if (options.stdio || target === "--stdio" || target === "stdio") {
3073
+ await startMcpStdio();
3074
+ return;
3075
+ }
2588
3076
  await startMcpServer(config.daemon?.host || "127.0.0.1", Number(config.daemon?.port || DAEMON_PORT) + 1);
2589
3077
  return;
2590
3078
  }
@@ -3508,6 +3996,24 @@ function initDatabase() {
3508
3996
  command TEXT,
3509
3997
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
3510
3998
  );
3999
+ CREATE TABLE IF NOT EXISTS usage_events (
4000
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4001
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4002
+ provider TEXT,
4003
+ model TEXT,
4004
+ profile TEXT,
4005
+ input_chars INTEGER NOT NULL DEFAULT 0,
4006
+ output_chars INTEGER NOT NULL DEFAULT 0,
4007
+ estimated_tokens INTEGER NOT NULL DEFAULT 0,
4008
+ estimated_cost_usd REAL NOT NULL DEFAULT 0,
4009
+ session_id INTEGER
4010
+ );
4011
+ CREATE INDEX IF NOT EXISTS idx_usage_events_created_at ON usage_events(created_at DESC);
4012
+ CREATE TABLE IF NOT EXISTS budgets (
4013
+ scope TEXT PRIMARY KEY,
4014
+ amount_usd REAL NOT NULL,
4015
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4016
+ );
3511
4017
  `);
3512
4018
  rebuildFtsIfEmpty(db);
3513
4019
  db.prepare(`
@@ -3564,6 +4070,7 @@ function getDbStatus() {
3564
4070
  const artifacts = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get();
3565
4071
  const docs = db.prepare("SELECT COUNT(*) AS count FROM doc_index").get();
3566
4072
  const custom = db.prepare("SELECT COUNT(*) AS count FROM custom_records").get();
4073
+ const usage = db.prepare("SELECT COUNT(*) AS count FROM usage_events").get();
3567
4074
  return {
3568
4075
  status: "ok",
3569
4076
  file: DB_FILE,
@@ -3579,6 +4086,7 @@ function getDbStatus() {
3579
4086
  artifacts: artifacts?.count ?? 0,
3580
4087
  indexed_docs: docs?.count ?? 0,
3581
4088
  custom_records: custom?.count ?? 0,
4089
+ usage_events: usage?.count ?? 0,
3582
4090
  };
3583
4091
  } finally {
3584
4092
  db.close();
@@ -3749,6 +4257,16 @@ function printSessionMessages(sessionId) {
3749
4257
  ]);
3750
4258
  }
3751
4259
 
4260
+ function getSessionMessages(sessionId) {
4261
+ initDatabase();
4262
+ const db = openDatabase();
4263
+ try {
4264
+ return db.prepare("SELECT id, role, content, created_at FROM session_messages WHERE session_id = ? ORDER BY id ASC").all(sessionId);
4265
+ } finally {
4266
+ db.close();
4267
+ }
4268
+ }
4269
+
3752
4270
  function forkSessionInDb(sessionId) {
3753
4271
  initDatabase();
3754
4272
  const db = openDatabase();
@@ -4239,6 +4757,21 @@ function deleteMemory(id) {
4239
4757
  }
4240
4758
  }
4241
4759
 
4760
+ function findMemoryDuplicates() {
4761
+ const rows = listMemory(1000).reverse();
4762
+ const seen = new Map();
4763
+ const duplicates = [];
4764
+ for (const row of rows) {
4765
+ const normalized = row.content.trim().toLocaleLowerCase("ru-RU").replace(/\s+/g, " ");
4766
+ if (seen.has(normalized)) {
4767
+ duplicates.push({ keeper_id: seen.get(normalized).id, duplicate_id: row.id, content: row.content });
4768
+ } else {
4769
+ seen.set(normalized, row);
4770
+ }
4771
+ }
4772
+ return duplicates;
4773
+ }
4774
+
4242
4775
  function clearMemory() {
4243
4776
  initDatabase();
4244
4777
  const db = openDatabase();
@@ -4569,6 +5102,7 @@ async function aiAsk(args, context = {}) {
4569
5102
  const providerConfig = resolveAiProfile(config, options);
4570
5103
  if (providerConfig.provider === "codex") await assertPermission("codex");
4571
5104
  if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
5105
+ if (options["stream-json"]) options.events = true;
4572
5106
  if (options.tools && providerConfig.provider === "ollama") {
4573
5107
  return localToolAsk(question, providerConfig, options);
4574
5108
  }
@@ -4598,6 +5132,13 @@ async function aiAsk(args, context = {}) {
4598
5132
  recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
4599
5133
  appendSessionExchange(sessionId, question, answer, dataContext, "");
4600
5134
  }
5135
+ recordUsage({
5136
+ providerConfig,
5137
+ question,
5138
+ answer,
5139
+ sessionId,
5140
+ profile: providerConfig.name,
5141
+ });
4601
5142
  await maybeSuggestMemory(question, answer, providerConfig);
4602
5143
 
4603
5144
  emitEvent(options, "answer", { length: answer.length, sessionId });
@@ -4642,6 +5183,7 @@ function resolveAiProfile(config, options = {}) {
4642
5183
  }
4643
5184
 
4644
5185
  async function localToolAsk(question, providerConfig, options) {
5186
+ if (options["stream-json"]) options.events = true;
4645
5187
  await ensureLocalData();
4646
5188
  const plan = await buildLocalToolPlan(question, providerConfig, options);
4647
5189
  const validated = validateToolPlan(plan, options);
@@ -4667,6 +5209,7 @@ async function localToolAsk(question, providerConfig, options) {
4667
5209
  sessionId: null,
4668
5210
  });
4669
5211
  }
5212
+ recordUsage({ providerConfig, question, answer, sessionId: null, profile: providerConfig.name });
4670
5213
 
4671
5214
  emitEvent(options, "tool_plan", { plan: validated, runId });
4672
5215
  saveArtifact("tool-result", question.slice(0, 80), "", { runId, plan: validated, outputs: result.outputs });
@@ -4772,6 +5315,7 @@ async function executeToolPlan(plan, options = {}) {
4772
5315
  let status = "ok";
4773
5316
  let summary = "";
4774
5317
  await assertPermission(step.tool);
5318
+ await runHooks("PreToolUse", { tool: step.tool, args: step.args || {} });
4775
5319
  await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
4776
5320
  try {
4777
5321
  if (step.tool === "search_local") {
@@ -4818,10 +5362,12 @@ async function executeToolPlan(plan, options = {}) {
4818
5362
  status = "error";
4819
5363
  summary = error instanceof Error ? error.message : String(error);
4820
5364
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
5365
+ await runHooks("OnError", { tool: step.tool, args: step.args || {}, error: summary });
4821
5366
  throw error;
4822
5367
  }
4823
5368
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
4824
5369
  await runHooks("AfterTool", { tool: step.tool, rows: current.length });
5370
+ await runHooks("PostToolUse", { tool: step.tool, rows: current.length });
4825
5371
  }
4826
5372
  return { rows: current, outputs };
4827
5373
  }
@@ -4849,7 +5395,12 @@ async function runHooks(event, payload = {}) {
4849
5395
  const config = await loadConfig();
4850
5396
  const commands = config.hooks?.[event] || [];
4851
5397
  for (const command of commands) {
4852
- const parts = splitCommandLine(command);
5398
+ const [maybeFilter, ...rest] = String(command).split(":");
5399
+ const commandText = payload.tool && rest.length > 0 && ALL_LOCAL_TOOLS.includes(maybeFilter.trim())
5400
+ ? (maybeFilter.trim() === payload.tool ? rest.join(":").trim() : "")
5401
+ : command;
5402
+ if (!commandText) continue;
5403
+ const parts = splitCommandLine(commandText);
4853
5404
  if (parts.length === 0) continue;
4854
5405
  await runCommand(parts[0], parts.slice(1), {
4855
5406
  inherit: true,
@@ -5445,12 +5996,12 @@ function parseOptions(args) {
5445
5996
 
5446
5997
  for (let index = 0; index < args.length; index += 1) {
5447
5998
  const arg = args[index];
5448
- 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") {
5999
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--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") {
5449
6000
  result[arg.slice(2)] = true;
5450
6001
  } else if (arg === "--check" || arg === "--upgrade-node") {
5451
6002
  result.check = true;
5452
6003
  result[arg.slice(2)] = true;
5453
- } 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") {
6004
+ } 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 === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--debug-file") {
5454
6005
  result[arg.slice(2)] = args[index + 1];
5455
6006
  index += 1;
5456
6007
  } else {
@@ -5827,6 +6378,105 @@ async function installCodexIfMissing() {
5827
6378
  await runCommand("npm", ["install", "-g", "@openai/codex"], { inherit: true });
5828
6379
  }
5829
6380
 
6381
+ async function getBrowserStatus() {
6382
+ const installed = existsSync(BROWSER_RUNTIME_PACKAGE);
6383
+ let playwright = "не установлен";
6384
+ if (installed) {
6385
+ try {
6386
+ playwright = JSON.parse(await readFile(BROWSER_RUNTIME_PACKAGE, "utf8")).version || "installed";
6387
+ } catch {
6388
+ playwright = "installed";
6389
+ }
6390
+ }
6391
+ return {
6392
+ runtime: BROWSER_RUNTIME_DIR,
6393
+ playwright,
6394
+ installed: installed ? "yes" : "no",
6395
+ install_command: "iola browser install",
6396
+ chromium: installed ? "managed by Playwright" : "not installed",
6397
+ };
6398
+ }
6399
+
6400
+ async function installBrowserRuntime() {
6401
+ await mkdir(BROWSER_RUNTIME_DIR, { recursive: true });
6402
+ const packageFile = path.join(BROWSER_RUNTIME_DIR, "package.json");
6403
+ if (!existsSync(packageFile)) {
6404
+ await writeFile(packageFile, `${JSON.stringify({ private: true, type: "module", dependencies: {} }, null, 2)}\n`, "utf8");
6405
+ }
6406
+ console.log(`Устанавливаю Playwright runtime: ${BROWSER_RUNTIME_DIR}`);
6407
+ await runPackageManager("npm", ["install", "playwright@latest"], { inherit: true, cwd: BROWSER_RUNTIME_DIR });
6408
+ await runPackageManager("npx", ["playwright", "install", "chromium"], { inherit: true, cwd: BROWSER_RUNTIME_DIR });
6409
+ }
6410
+
6411
+ function runPackageManager(command, args, options = {}) {
6412
+ if (process.platform === "win32") {
6413
+ return runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/c", [command, ...args].join(" ")], options);
6414
+ }
6415
+ return runCommand(command, args, options);
6416
+ }
6417
+
6418
+ async function ensureBrowserRuntime() {
6419
+ if (existsSync(BROWSER_RUNTIME_PACKAGE)) return;
6420
+ throw new Error("Browser runtime не установлен. Запустите: iola browser install");
6421
+ }
6422
+
6423
+ async function runBrowserAutomation(action, params) {
6424
+ await ensureBrowserRuntime();
6425
+ const scriptFile = path.join(BROWSER_RUNTIME_DIR, `iola-browser-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs`);
6426
+ await writeFile(scriptFile, browserAutomationScript(action, params), "utf8");
6427
+ try {
6428
+ const { stdout } = await runCommand(process.execPath, [scriptFile], { cwd: BROWSER_RUNTIME_DIR });
6429
+ return stdout.trim();
6430
+ } finally {
6431
+ await rm(scriptFile, { force: true }).catch(() => {});
6432
+ }
6433
+ }
6434
+
6435
+ function browserAutomationScript(action, params) {
6436
+ return `
6437
+ import { chromium } from "playwright";
6438
+ const action = ${JSON.stringify(action)};
6439
+ const params = ${JSON.stringify(params)};
6440
+ const [width, height] = String(params.viewport || "1366x768").split("x").map(Number);
6441
+ const browser = await chromium.launch({ headless: !params.headed });
6442
+ const page = await browser.newPage({ viewport: { width: width || 1366, height: height || 768 } });
6443
+ page.setDefaultTimeout(params.timeout || 30000);
6444
+ try {
6445
+ await page.goto(params.url, { waitUntil: "domcontentloaded", timeout: params.timeout || 30000 });
6446
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6447
+ if (action === "open") {
6448
+ if (params.waitMs > 0) await page.waitForTimeout(params.waitMs);
6449
+ else if (!page.context().browser()?.isConnected()) {}
6450
+ } else if (action === "text") {
6451
+ console.log((await page.locator("body").innerText()).trim());
6452
+ } else if (action === "html") {
6453
+ console.log(await page.content());
6454
+ } else if (action === "screenshot") {
6455
+ await page.screenshot({ path: params.output, fullPage: true });
6456
+ } else if (action === "pdf") {
6457
+ await page.pdf({ path: params.output, format: "A4", printBackground: true });
6458
+ } else if (action === "click") {
6459
+ await page.locator(params.selector).first().click();
6460
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6461
+ if (params.output) await page.screenshot({ path: params.output, fullPage: true });
6462
+ console.log((await page.locator("body").innerText()).trim().slice(0, 4000));
6463
+ } else if (action === "type") {
6464
+ const locator = page.locator(params.selector).first();
6465
+ await locator.fill(params.text || "");
6466
+ if (params.press) await locator.press(params.press);
6467
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6468
+ if (params.output) await page.screenshot({ path: params.output, fullPage: true });
6469
+ console.log((await page.locator("body").innerText()).trim().slice(0, 4000));
6470
+ } else if (action === "eval") {
6471
+ const value = await page.evaluate(new Function("return (" + params.script + ")"));
6472
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
6473
+ }
6474
+ } finally {
6475
+ await browser.close();
6476
+ }
6477
+ `;
6478
+ }
6479
+
5830
6480
  async function probeEndpoint(url) {
5831
6481
  try {
5832
6482
  const response = await fetch(url, { headers: { accept: "application/json" } });
@@ -5840,6 +6490,13 @@ async function startDaemon(host, port) {
5840
6490
  const server = createServer(async (req, res) => {
5841
6491
  try {
5842
6492
  const url = new URL(req.url || "/", `http://${host}:${port}`);
6493
+
6494
+ if (req.method === "GET" && url.pathname === "/") {
6495
+ res.setHeader("content-type", "text/html; charset=utf-8");
6496
+ res.end(renderDaemonDashboard(host, port));
6497
+ return;
6498
+ }
6499
+
5843
6500
  res.setHeader("content-type", "application/json; charset=utf-8");
5844
6501
 
5845
6502
  if (req.method === "GET" && url.pathname === "/health") {
@@ -5852,6 +6509,26 @@ async function startDaemon(host, port) {
5852
6509
  return;
5853
6510
  }
5854
6511
 
6512
+ if (req.method === "GET" && url.pathname === "/api/status") {
6513
+ res.end(JSON.stringify({ db: getDbStatus(), sync: getSyncStatus(), usage: getUsageSummary() }));
6514
+ return;
6515
+ }
6516
+
6517
+ if (req.method === "GET" && url.pathname === "/api/tasks") {
6518
+ res.end(JSON.stringify(listTasks()));
6519
+ return;
6520
+ }
6521
+
6522
+ if (req.method === "GET" && url.pathname === "/api/artifacts") {
6523
+ res.end(JSON.stringify(listArtifacts()));
6524
+ return;
6525
+ }
6526
+
6527
+ if (req.method === "GET" && url.pathname === "/api/trace") {
6528
+ res.end(JSON.stringify(listTrace(50)));
6529
+ return;
6530
+ }
6531
+
5855
6532
  if (req.method === "POST" && url.pathname === "/rpc") {
5856
6533
  const body = await readRequestBody(req);
5857
6534
  const payload = body ? JSON.parse(body) : {};
@@ -5879,22 +6556,19 @@ async function startDaemon(host, port) {
5879
6556
 
5880
6557
  async function startMcpServer(host, port) {
5881
6558
  const server = createServer(async (req, res) => {
6559
+ let payload = {};
5882
6560
  try {
5883
6561
  res.setHeader("content-type", "application/json; charset=utf-8");
5884
6562
  if (req.method !== "POST") {
5885
- res.end(JSON.stringify({ name: "iola-local-mcp", tools: ["status", "search", "card", "quality", "files.search", "index.search"] }));
6563
+ res.end(JSON.stringify({ name: "iola-local-mcp", protocol: "2024-11-05", tools: mcpTools().map((tool) => tool.name) }));
5886
6564
  return;
5887
6565
  }
5888
- const payload = JSON.parse(await readRequestBody(req) || "{}");
5889
- const method = payload.method === "tools/call" ? payload.params?.name : payload.method;
5890
- const args = payload.params?.arguments || payload.params || {};
5891
- let result;
5892
- if (method === "index.search") result = searchDocs(args.query || "", Number(args.limit || 20));
5893
- else result = await executeRpc(method, { ...args, _: [] });
6566
+ payload = JSON.parse(await readRequestBody(req) || "{}");
6567
+ const result = await handleMcpMessage(payload);
5894
6568
  res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result }));
5895
6569
  } catch (error) {
5896
6570
  res.statusCode = 500;
5897
- res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { message: error instanceof Error ? error.message : String(error) } }));
6571
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }));
5898
6572
  }
5899
6573
  });
5900
6574
  await new Promise((resolve, reject) => {
@@ -5905,6 +6579,152 @@ async function startMcpServer(host, port) {
5905
6579
  await new Promise(() => {});
5906
6580
  }
5907
6581
 
6582
+ function renderDaemonDashboard(host, port) {
6583
+ const status = getDbStatus();
6584
+ const sync = getSyncStatus();
6585
+ const usage = getUsageSummary();
6586
+ return `<!doctype html>
6587
+ <html lang="ru">
6588
+ <meta charset="utf-8">
6589
+ <title>iola daemon</title>
6590
+ <style>
6591
+ body{font-family:Segoe UI,Arial,sans-serif;margin:32px;background:#f8fafc;color:#0f172a}
6592
+ h1{margin:0 0 8px;font-size:28px}
6593
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:20px}
6594
+ .card{background:white;border:1px solid #dbe3ef;border-radius:8px;padding:16px}
6595
+ .k{color:#64748b;font-size:12px;text-transform:uppercase}.v{font-size:24px;font-weight:700;margin-top:4px}
6596
+ a{color:#0b62d6}
6597
+ code{background:#eef2f7;padding:2px 5px;border-radius:4px}
6598
+ </style>
6599
+ <h1>iola daemon</h1>
6600
+ <div>Локальная панель CLI-проекта Йошкар-Олы: <code>http://${host}:${port}</code></div>
6601
+ <div class="grid">
6602
+ <div class="card"><div class="k">DB</div><div class="v">${status.status}</div><p>schema ${status.schema}, records ${status.local_records}</p></div>
6603
+ <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>
6604
+ <div class="card"><div class="k">Usage</div><div class="v">${usage.requests}</div><p>${usage.estimated_tokens} tokens</p></div>
6605
+ <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>
6606
+ </div>
6607
+ </html>`;
6608
+ }
6609
+
6610
+ async function startMcpStdio() {
6611
+ const rl = readline.createInterface({ input, terminal: false });
6612
+ for await (const line of rl) {
6613
+ if (!line.trim()) continue;
6614
+ let payload = {};
6615
+ try {
6616
+ payload = JSON.parse(line);
6617
+ const result = await handleMcpMessage(payload);
6618
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result })}\n`);
6619
+ } catch (error) {
6620
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } })}\n`);
6621
+ }
6622
+ }
6623
+ }
6624
+
6625
+ async function handleMcpMessage(payload) {
6626
+ const method = payload.method;
6627
+ if (method === "initialize") {
6628
+ return {
6629
+ protocolVersion: "2024-11-05",
6630
+ serverInfo: { name: "iola-local-mcp", version: getPackageVersion() },
6631
+ capabilities: { tools: {}, resources: {}, prompts: {} },
6632
+ };
6633
+ }
6634
+ if (method === "tools/list") return { tools: mcpTools() };
6635
+ if (method === "tools/call") {
6636
+ const result = await callMcpTool(payload.params?.name, payload.params?.arguments || {});
6637
+ return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] };
6638
+ }
6639
+ if (method === "resources/list") return { resources: mcpResources() };
6640
+ if (method === "resources/read") {
6641
+ const text = await readMcpResource(payload.params?.uri);
6642
+ return { contents: [{ uri: payload.params?.uri, mimeType: "application/json", text }] };
6643
+ }
6644
+ if (method === "prompts/list") return { prompts: mcpPrompts() };
6645
+ if (method === "prompts/get") return getMcpPrompt(payload.params?.name, payload.params?.arguments || {});
6646
+ if (method === "notifications/initialized") return {};
6647
+ throw new Error(`MCP method неизвестен: ${method}`);
6648
+ }
6649
+
6650
+ function mcpTools() {
6651
+ const schema = (properties = {}) => ({ type: "object", properties, additionalProperties: false });
6652
+ return [
6653
+ { name: "status", description: "Статус локальной БД, sync и активного AI-профиля.", inputSchema: schema() },
6654
+ { name: "search", description: "Поиск по локальным открытым данным Йошкар-Олы.", inputSchema: schema({ query: { type: "string" }, dataset: { type: "string" }, limit: { type: "number" } }) },
6655
+ { name: "card", description: "Карточка объекта по названию или ИНН.", inputSchema: schema({ query: { type: "string" } }) },
6656
+ { name: "quality", description: "Проверки качества данных.", inputSchema: schema({ scope: { type: "string" } }) },
6657
+ { name: "sync", description: "Обновление локальной копии слоя.", inputSchema: schema({ dataset: { type: "string" } }) },
6658
+ { name: "files.tree", description: "Дерево файлов разрешенного workspace.", inputSchema: schema({ path: { type: "string" }, depth: { type: "number" }, limit: { type: "number" } }) },
6659
+ { name: "files.read", description: "Чтение файла разрешенного workspace.", inputSchema: schema({ path: { type: "string" }, maxBytes: { type: "number" } }) },
6660
+ { name: "files.search", description: "Поиск текста в файлах workspace.", inputSchema: schema({ query: { type: "string" }, path: { type: "string" }, limit: { type: "number" } }) },
6661
+ { name: "index.search", description: "Поиск по индексу локальных документов.", inputSchema: schema({ query: { type: "string" }, limit: { type: "number" } }) },
6662
+ { name: "report", description: "Запуск встроенного отчета.", inputSchema: schema({ name: { type: "string" }, format: { type: "string" }, output: { type: "string" } }) },
6663
+ { name: "browser.text", description: "Открыть страницу в headless Chromium и вернуть видимый текст.", inputSchema: schema({ url: { type: "string" }, waitMs: { type: "number" } }) },
6664
+ { name: "browser.screenshot", description: "Сделать скриншот страницы через Chromium.", inputSchema: schema({ url: { type: "string" }, output: { type: "string" }, waitMs: { type: "number" } }) },
6665
+ ];
6666
+ }
6667
+
6668
+ function mcpResources() {
6669
+ return [
6670
+ { uri: "iola://status", name: "Статус CLI", mimeType: "application/json" },
6671
+ { uri: "iola://sync", name: "Статус синхронизации", mimeType: "application/json" },
6672
+ { uri: "iola://settings", name: "Эффективные настройки", mimeType: "application/json" },
6673
+ { uri: "iola://skills", name: "Skills", mimeType: "application/json" },
6674
+ { uri: "iola://memory", name: "Память агента", mimeType: "application/json" },
6675
+ { uri: "iola://artifacts", name: "Artifacts", mimeType: "application/json" },
6676
+ ];
6677
+ }
6678
+
6679
+ function mcpPrompts() {
6680
+ return [
6681
+ { name: "data-question", description: "Ответить строго по открытым данным Йошкар-Олы.", arguments: [{ name: "question", required: true }] },
6682
+ { name: "document-review", description: "Проверить документ на полноту и источники.", arguments: [{ name: "file", required: true }] },
6683
+ { name: "report-build", description: "Собрать отчет по выбранному слою.", arguments: [{ name: "dataset", required: true }] },
6684
+ ];
6685
+ }
6686
+
6687
+ async function callMcpTool(name, args = {}) {
6688
+ if (name === "index.search") return searchDocs(args.query || "", Number(args.limit || 20));
6689
+ if (name === "report") {
6690
+ const output = args.output || `${args.name || "education-contacts"}.${args.format || "xlsx"}`;
6691
+ await handleExport([args.name || "education-contacts", "--format", args.format || "xlsx", "--output", output]);
6692
+ return { output };
6693
+ }
6694
+ if (name === "browser.text") {
6695
+ return runBrowserAutomation("text", { url: args.url, waitMs: Number(args.waitMs || 0), timeout: Number(args.timeout || 30000), viewport: args.viewport || "1366x768" });
6696
+ }
6697
+ if (name === "browser.screenshot") {
6698
+ const output = path.resolve(args.output || "browser-page.png");
6699
+ await runBrowserAutomation("screenshot", { url: args.url, output, waitMs: Number(args.waitMs || 0), timeout: Number(args.timeout || 30000), viewport: args.viewport || "1366x768" });
6700
+ return { output };
6701
+ }
6702
+ return executeRpc(name, { ...args, _: [] });
6703
+ }
6704
+
6705
+ async function readMcpResource(uri) {
6706
+ if (uri === "iola://status") return JSON.stringify({ db: getDbStatus(), sync: getSyncStatus() }, null, 2);
6707
+ if (uri === "iola://sync") return JSON.stringify(getSyncStatus(), null, 2);
6708
+ if (uri === "iola://settings") return JSON.stringify(await loadConfig(), null, 2);
6709
+ if (uri === "iola://skills") return JSON.stringify(listSkills(await loadConfig()), null, 2);
6710
+ if (uri === "iola://memory") return JSON.stringify(listMemory(100), null, 2);
6711
+ if (uri === "iola://artifacts") return JSON.stringify(listArtifacts(), null, 2);
6712
+ throw new Error(`MCP resource неизвестен: ${uri}`);
6713
+ }
6714
+
6715
+ function getMcpPrompt(name, args = {}) {
6716
+ if (name === "data-question") {
6717
+ return { messages: [{ role: "user", content: { type: "text", text: `Ответь по открытым данным городского округа "Город Йошкар-Ола", не выдумывая сведения: ${args.question || ""}` } }] };
6718
+ }
6719
+ if (name === "document-review") {
6720
+ return { messages: [{ role: "user", content: { type: "text", text: `Проверь документ ${args.file || ""}: полнота, источники, противоречия, ошибки оформления.` } }] };
6721
+ }
6722
+ if (name === "report-build") {
6723
+ return { messages: [{ role: "user", content: { type: "text", text: `Собери практичный отчет по слою ${args.dataset || "schools"} с выводами и источником данных.` } }] };
6724
+ }
6725
+ throw new Error(`MCP prompt неизвестен: ${name}`);
6726
+ }
6727
+
5908
6728
  function readRequestBody(req) {
5909
6729
  return new Promise((resolve, reject) => {
5910
6730
  let body = "";
@@ -6338,6 +7158,101 @@ function recordToolTrace(runId, tool, args, status, summary) {
6338
7158
  }
6339
7159
  }
6340
7160
 
7161
+ function recordUsage({ providerConfig, question, answer, sessionId, profile }) {
7162
+ try {
7163
+ initDatabase();
7164
+ const inputChars = String(question || "").length;
7165
+ const outputChars = String(answer || "").length;
7166
+ const estimatedTokens = Math.ceil((inputChars + outputChars) / 4);
7167
+ const estimatedCostUsd = estimateCost(providerConfig, estimatedTokens);
7168
+ const db = openDatabase();
7169
+ try {
7170
+ db.prepare(`
7171
+ INSERT INTO usage_events(provider, model, profile, input_chars, output_chars, estimated_tokens, estimated_cost_usd, session_id)
7172
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
7173
+ `).run(
7174
+ providerConfig.provider || "",
7175
+ providerConfig.model || "",
7176
+ profile || providerConfig.name || "",
7177
+ inputChars,
7178
+ outputChars,
7179
+ estimatedTokens,
7180
+ estimatedCostUsd,
7181
+ sessionId || null,
7182
+ );
7183
+ } finally {
7184
+ db.close();
7185
+ }
7186
+ } catch {
7187
+ // Usage accounting must not break the main answer path.
7188
+ }
7189
+ }
7190
+
7191
+ function estimateCost(providerConfig, tokens) {
7192
+ if (!providerConfig || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
7193
+ const perMillion = providerConfig.provider === "openrouter" ? 0.25 : 0.4;
7194
+ return Math.round((tokens / 1_000_000) * perMillion * 1_000_000) / 1_000_000;
7195
+ }
7196
+
7197
+ function getUsageSummary() {
7198
+ initDatabase();
7199
+ const db = openDatabase();
7200
+ try {
7201
+ 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();
7202
+ return { requests: row.requests || 0, estimated_tokens: row.tokens || 0, estimated_cost_usd: Number(row.cost || 0).toFixed(6) };
7203
+ } finally {
7204
+ db.close();
7205
+ }
7206
+ }
7207
+
7208
+ function getUsageByModel() {
7209
+ initDatabase();
7210
+ const db = openDatabase();
7211
+ try {
7212
+ return db.prepare(`
7213
+ SELECT provider, model, COUNT(*) AS requests, COALESCE(SUM(estimated_tokens),0) AS tokens, printf('%.6f', COALESCE(SUM(estimated_cost_usd),0)) AS cost
7214
+ FROM usage_events GROUP BY provider, model ORDER BY requests DESC
7215
+ `).all();
7216
+ } finally {
7217
+ db.close();
7218
+ }
7219
+ }
7220
+
7221
+ function getUsageBySession() {
7222
+ initDatabase();
7223
+ const db = openDatabase();
7224
+ try {
7225
+ return db.prepare(`
7226
+ 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
7227
+ FROM usage_events GROUP BY session_id ORDER BY requests DESC LIMIT 100
7228
+ `).all();
7229
+ } finally {
7230
+ db.close();
7231
+ }
7232
+ }
7233
+
7234
+ function listBudgets() {
7235
+ initDatabase();
7236
+ const db = openDatabase();
7237
+ try {
7238
+ 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);
7239
+ return db.prepare("SELECT scope, amount_usd, updated_at FROM budgets ORDER BY scope").all()
7240
+ .map((row) => ({ ...row, spent_usd: row.scope === "daily" ? spent.toFixed(6) : "-" }));
7241
+ } finally {
7242
+ db.close();
7243
+ }
7244
+ }
7245
+
7246
+ function setBudget(scope, amountUsd) {
7247
+ initDatabase();
7248
+ const db = openDatabase();
7249
+ try {
7250
+ 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);
7251
+ } finally {
7252
+ db.close();
7253
+ }
7254
+ }
7255
+
6341
7256
  function listTrace(limit = 20) {
6342
7257
  initDatabase();
6343
7258
  const db = openDatabase();
@@ -6348,6 +7263,20 @@ function listTrace(limit = 20) {
6348
7263
  }
6349
7264
  }
6350
7265
 
7266
+ function buildTrajectoryRows(limit = 500) {
7267
+ initDatabase();
7268
+ const db = openDatabase();
7269
+ try {
7270
+ 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);
7271
+ 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);
7272
+ return [...history, ...traces]
7273
+ .sort((left, right) => String(right.created_at).localeCompare(String(left.created_at)))
7274
+ .slice(0, limit);
7275
+ } finally {
7276
+ db.close();
7277
+ }
7278
+ }
7279
+
6351
7280
  function getTraceRun(runId) {
6352
7281
  initDatabase();
6353
7282
  const db = openDatabase();
@@ -6378,6 +7307,22 @@ async function createSnapshot() {
6378
7307
  }
6379
7308
  }
6380
7309
 
7310
+ async function createSandboxCopy(name = "") {
7311
+ const config = await loadConfig();
7312
+ const workspace = resolveWorkspaceRoot(config);
7313
+ const sandboxesDir = path.join(CONFIG_DIR, "sandboxes");
7314
+ await mkdir(sandboxesDir, { recursive: true });
7315
+ const safeName = name ? String(name).replace(/[^a-zA-Z0-9_-]+/g, "-") : `sandbox-${Date.now()}`;
7316
+ const target = path.join(sandboxesDir, safeName);
7317
+ await rm(target, { recursive: true, force: true });
7318
+ await cp(workspace, target, {
7319
+ recursive: true,
7320
+ filter: (source) => !isBlockedPathForConfig(source, config) && !source.includes(`${path.sep}node_modules${path.sep}`) && !source.includes(`${path.sep}.git${path.sep}`),
7321
+ });
7322
+ const id = saveArtifact("sandbox", safeName, target, { workspace });
7323
+ return { id, workspace, path: target };
7324
+ }
7325
+
6381
7326
  function listSnapshots() {
6382
7327
  initDatabase();
6383
7328
  const db = openDatabase();
@@ -6653,7 +7598,7 @@ async function executeRpc(method, options = {}) {
6653
7598
  if (method === "index.search") {
6654
7599
  return searchDocs(options.query || options.search || "", Number(options.limit || 20));
6655
7600
  }
6656
- throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
7601
+ throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync, files.tree, files.read, files.search, index.search.`);
6657
7602
  }
6658
7603
 
6659
7604
  async function getLatestNpmVersion(packageName) {
@@ -6780,11 +7725,39 @@ async function writeConfig(value) {
6780
7725
  }
6781
7726
 
6782
7727
  async function loadConfig() {
7728
+ let config = DEFAULT_AI_CONFIG;
7729
+ for (const layer of [CONFIG_FILE, PROJECT_CONFIG_FILE, LOCAL_CONFIG_FILE]) {
7730
+ const value = await readConfigLayer(layer);
7731
+ if (value) config = mergeConfig(config, value);
7732
+ }
7733
+ return config;
7734
+ }
7735
+
7736
+ async function loadConfigLayers() {
7737
+ const files = [
7738
+ { scope: "defaults", file: "builtin", value: DEFAULT_AI_CONFIG, exists: true },
7739
+ { scope: "user", file: CONFIG_FILE },
7740
+ { scope: "project", file: PROJECT_CONFIG_FILE },
7741
+ { scope: "local", file: LOCAL_CONFIG_FILE },
7742
+ ];
7743
+ const rows = [];
7744
+ for (const layer of files) {
7745
+ if (layer.scope === "defaults") {
7746
+ rows.push({ ...layer, errors: validateConfig(layer.value) });
7747
+ continue;
7748
+ }
7749
+ const value = await readConfigLayer(layer.file);
7750
+ rows.push({ ...layer, exists: Boolean(value), value, errors: value ? validateConfig(mergeConfig(DEFAULT_AI_CONFIG, value)) : [] });
7751
+ }
7752
+ 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: [] });
7753
+ return rows;
7754
+ }
7755
+
7756
+ async function readConfigLayer(file) {
6783
7757
  try {
6784
- const text = await readFile(CONFIG_FILE, "utf8");
6785
- return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
7758
+ return JSON.parse(await readFile(file, "utf8"));
6786
7759
  } catch {
6787
- return DEFAULT_AI_CONFIG;
7760
+ return null;
6788
7761
  }
6789
7762
  }
6790
7763
 
@@ -6836,6 +7809,20 @@ function mergeConfig(base, override) {
6836
7809
  ...base.cron,
6837
7810
  ...(override.cron || {}),
6838
7811
  },
7812
+ hooks: {
7813
+ ...base.hooks,
7814
+ ...(override.hooks || {}),
7815
+ },
7816
+ subagents: {
7817
+ ...(base.subagents || {}),
7818
+ ...(override.subagents || {}),
7819
+ },
7820
+ workspaces: {
7821
+ ...(base.workspaces || {}),
7822
+ ...(override.workspaces || {}),
7823
+ },
7824
+ hooksTrusted: override.hooksTrusted ?? base.hooksTrusted,
7825
+ local: override.local ?? base.local,
6839
7826
  };
6840
7827
  }
6841
7828
 
@@ -6996,6 +7983,7 @@ function runCommand(command, args, options = {}) {
6996
7983
  const child = execFile(command, args, {
6997
7984
  windowsHide: true,
6998
7985
  maxBuffer: 1024 * 1024 * 5,
7986
+ cwd: options.cwd,
6999
7987
  env: {
7000
7988
  ...process.env,
7001
7989
  ...(options.env || {}),