@iola_adm/iola-cli 0.1.29 → 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,6 +259,7 @@ 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],
@@ -254,7 +275,11 @@ const COMMANDS = new Map([
254
275
  ["tasks", handleTasks],
255
276
  ["artifacts", handleArtifacts],
256
277
  ["snapshot", handleSnapshot],
278
+ ["sandbox", handleSandbox],
257
279
  ["trace", handleTrace],
280
+ ["trajectory", handleTrajectory],
281
+ ["usage", handleUsage],
282
+ ["budget", handleBudget],
258
283
  ["policy", handlePolicy],
259
284
  ["export", handleExport],
260
285
  ["cron", handleCron],
@@ -264,6 +289,8 @@ const COMMANDS = new Map([
264
289
  ["memory", handleMemory],
265
290
  ["hooks", handleHooks],
266
291
  ["agents", handleAgents],
292
+ ["subagents", handleSubagents],
293
+ ["review", handleReview],
267
294
  ["mcp", handleMcp],
268
295
  ["cache", handleCache],
269
296
  ["sync", handleSync],
@@ -353,12 +380,14 @@ Usage:
353
380
  iola history [--limit 20]
354
381
  iola history clear
355
382
  iola sessions [--limit 20]
383
+ iola sessions replay SESSION_ID
356
384
  iola resume SESSION_ID [TEXT]
357
385
  iola fork SESSION_ID [TEXT]
358
386
  iola features list|enable|disable
387
+ iola settings list|get|validate|doctor|init
359
388
  iola wiki [open|links]
360
389
  iola context list|show|init
361
- iola skills list|show|paths|enable|disable
390
+ iola skills list|show|paths|enable|disable|bundles|bundle|doctor
362
391
  iola tools list|toolsets|enable|disable|profile
363
392
  iola files status|mode|approvals|tree|read|search|write|patch
364
393
  iola archive doctor|list|test|extract|create|index
@@ -371,17 +400,23 @@ Usage:
371
400
  iola tasks list|add|done|run
372
401
  iola artifacts list|show|open
373
402
  iola snapshot create|list|restore
403
+ iola sandbox fork|run|diff|apply
374
404
  iola trace last|show
405
+ iola trajectory export|last
406
+ iola usage summary|models|sessions
407
+ iola budget status|set
375
408
  iola policy use safe|analyst|developer|full
376
409
  iola export REPORT --format docx|xlsx --output FILE
377
410
  iola cron list|add|delete|run|tick
378
411
  iola daemon start|status
379
412
  iola rpc call METHOD [ARGS] [--json]
380
413
  iola permissions list|allow|deny
381
- iola memory show|add|set|clear|export
382
- 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
383
416
  iola agents list|run
384
- 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]
385
420
  iola cache status|warm|clear
386
421
  iola sync [--dataset schools|kindergartens]
387
422
  iola sync status
@@ -975,6 +1010,14 @@ function showBanner() {
975
1010
  console.log("открытые данные • MCP • локальный AI");
976
1011
  }
977
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
+
978
1021
  async function showVersion(args = []) {
979
1022
  const options = parseOptions(args);
980
1023
  const packageJson = await import("../package.json", { with: { type: "json" } });
@@ -1512,6 +1555,17 @@ async function handleSessions(args) {
1512
1555
  return;
1513
1556
  }
1514
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
+
1515
1569
  const rows = listSessions(Number(options.limit || 20));
1516
1570
 
1517
1571
  if (options.json) {
@@ -1587,6 +1641,61 @@ async function handleFeatures(args) {
1587
1641
  throw new Error("Команды features: list, enable NAME, disable NAME.");
1588
1642
  }
1589
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
+
1590
1699
  async function handleWiki(args) {
1591
1700
  const [action = "links"] = args;
1592
1701
  const base = "https://github.com/adm-iola/iola-cli/wiki";
@@ -1599,6 +1708,7 @@ async function handleWiki(args) {
1599
1708
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1600
1709
  ["Локальные файлы", `${base}/Локальные-файлы`],
1601
1710
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1711
+ ["Платформа агента", `${base}/Платформа-агента`],
1602
1712
  ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1603
1713
  ["Архивы и мастер настройки", `${base}/Архивы-и-мастер-настройки`],
1604
1714
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
@@ -1701,6 +1811,41 @@ async function handleSkills(args) {
1701
1811
  return;
1702
1812
  }
1703
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
+
1704
1849
  if (action === "show") {
1705
1850
  const skill = findSkill(name, config);
1706
1851
  if (!skill) throw new Error(`Skill не найден: ${name}`);
@@ -1718,7 +1863,7 @@ async function handleSkills(args) {
1718
1863
  return;
1719
1864
  }
1720
1865
 
1721
- 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.");
1722
1867
  }
1723
1868
 
1724
1869
  async function handleTools(args) {
@@ -2161,6 +2306,38 @@ async function handleSnapshot(args) {
2161
2306
  throw new Error("Команды snapshot: create, list, restore ID.");
2162
2307
  }
2163
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
+
2164
2341
  async function handleTrace(args) {
2165
2342
  const [action = "last", id] = args;
2166
2343
  if (action === "last") {
@@ -2174,6 +2351,61 @@ async function handleTrace(args) {
2174
2351
  throw new Error("Команды trace: last [LIMIT], show RUN_ID.");
2175
2352
  }
2176
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
+
2177
2409
  async function handlePolicy(args) {
2178
2410
  const [action = "list", name] = args;
2179
2411
  const policies = {
@@ -2445,7 +2677,26 @@ async function handleMemory(args) {
2445
2677
  return;
2446
2678
  }
2447
2679
 
2448
- 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.");
2449
2700
  }
2450
2701
 
2451
2702
  async function handleHooks(args) {
@@ -2468,6 +2719,22 @@ async function handleHooks(args) {
2468
2719
  return;
2469
2720
  }
2470
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
+
2471
2738
  if (action === "add") {
2472
2739
  if (!HOOK_EVENTS.includes(event) || commandParts.length === 0) {
2473
2740
  throw new Error(`Пример: iola hooks add AfterSync "iola quality" Доступно: ${HOOK_EVENTS.join(", ")}`);
@@ -2499,7 +2766,7 @@ async function handleHooks(args) {
2499
2766
  return;
2500
2767
  }
2501
2768
 
2502
- 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.");
2503
2770
  }
2504
2771
 
2505
2772
  async function handleAgents(args) {
@@ -2548,8 +2815,132 @@ async function handleAgents(args) {
2548
2815
  throw new Error("Команды agents: list, run NAME TEXT.");
2549
2816
  }
2550
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
+
2551
2941
  async function handleMcp(args) {
2552
- const [action = "status", target = "codex"] = args;
2942
+ const [action = "status", target = "codex", ...rest] = args;
2943
+ const options = parseOptions([target, ...rest]);
2553
2944
 
2554
2945
  if (action === "status") {
2555
2946
  const [health, version] = await Promise.all([
@@ -2585,6 +2976,10 @@ async function handleMcp(args) {
2585
2976
 
2586
2977
  if (action === "serve") {
2587
2978
  const config = await loadConfig();
2979
+ if (options.stdio || target === "--stdio" || target === "stdio") {
2980
+ await startMcpStdio();
2981
+ return;
2982
+ }
2588
2983
  await startMcpServer(config.daemon?.host || "127.0.0.1", Number(config.daemon?.port || DAEMON_PORT) + 1);
2589
2984
  return;
2590
2985
  }
@@ -3508,6 +3903,24 @@ function initDatabase() {
3508
3903
  command TEXT,
3509
3904
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
3510
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
+ );
3511
3924
  `);
3512
3925
  rebuildFtsIfEmpty(db);
3513
3926
  db.prepare(`
@@ -3564,6 +3977,7 @@ function getDbStatus() {
3564
3977
  const artifacts = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get();
3565
3978
  const docs = db.prepare("SELECT COUNT(*) AS count FROM doc_index").get();
3566
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();
3567
3981
  return {
3568
3982
  status: "ok",
3569
3983
  file: DB_FILE,
@@ -3579,6 +3993,7 @@ function getDbStatus() {
3579
3993
  artifacts: artifacts?.count ?? 0,
3580
3994
  indexed_docs: docs?.count ?? 0,
3581
3995
  custom_records: custom?.count ?? 0,
3996
+ usage_events: usage?.count ?? 0,
3582
3997
  };
3583
3998
  } finally {
3584
3999
  db.close();
@@ -3749,6 +4164,16 @@ function printSessionMessages(sessionId) {
3749
4164
  ]);
3750
4165
  }
3751
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
+
3752
4177
  function forkSessionInDb(sessionId) {
3753
4178
  initDatabase();
3754
4179
  const db = openDatabase();
@@ -4239,6 +4664,21 @@ function deleteMemory(id) {
4239
4664
  }
4240
4665
  }
4241
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
+
4242
4682
  function clearMemory() {
4243
4683
  initDatabase();
4244
4684
  const db = openDatabase();
@@ -4569,6 +5009,7 @@ async function aiAsk(args, context = {}) {
4569
5009
  const providerConfig = resolveAiProfile(config, options);
4570
5010
  if (providerConfig.provider === "codex") await assertPermission("codex");
4571
5011
  if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
5012
+ if (options["stream-json"]) options.events = true;
4572
5013
  if (options.tools && providerConfig.provider === "ollama") {
4573
5014
  return localToolAsk(question, providerConfig, options);
4574
5015
  }
@@ -4598,6 +5039,13 @@ async function aiAsk(args, context = {}) {
4598
5039
  recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
4599
5040
  appendSessionExchange(sessionId, question, answer, dataContext, "");
4600
5041
  }
5042
+ recordUsage({
5043
+ providerConfig,
5044
+ question,
5045
+ answer,
5046
+ sessionId,
5047
+ profile: providerConfig.name,
5048
+ });
4601
5049
  await maybeSuggestMemory(question, answer, providerConfig);
4602
5050
 
4603
5051
  emitEvent(options, "answer", { length: answer.length, sessionId });
@@ -4642,6 +5090,7 @@ function resolveAiProfile(config, options = {}) {
4642
5090
  }
4643
5091
 
4644
5092
  async function localToolAsk(question, providerConfig, options) {
5093
+ if (options["stream-json"]) options.events = true;
4645
5094
  await ensureLocalData();
4646
5095
  const plan = await buildLocalToolPlan(question, providerConfig, options);
4647
5096
  const validated = validateToolPlan(plan, options);
@@ -4667,6 +5116,7 @@ async function localToolAsk(question, providerConfig, options) {
4667
5116
  sessionId: null,
4668
5117
  });
4669
5118
  }
5119
+ recordUsage({ providerConfig, question, answer, sessionId: null, profile: providerConfig.name });
4670
5120
 
4671
5121
  emitEvent(options, "tool_plan", { plan: validated, runId });
4672
5122
  saveArtifact("tool-result", question.slice(0, 80), "", { runId, plan: validated, outputs: result.outputs });
@@ -4772,6 +5222,7 @@ async function executeToolPlan(plan, options = {}) {
4772
5222
  let status = "ok";
4773
5223
  let summary = "";
4774
5224
  await assertPermission(step.tool);
5225
+ await runHooks("PreToolUse", { tool: step.tool, args: step.args || {} });
4775
5226
  await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
4776
5227
  try {
4777
5228
  if (step.tool === "search_local") {
@@ -4818,10 +5269,12 @@ async function executeToolPlan(plan, options = {}) {
4818
5269
  status = "error";
4819
5270
  summary = error instanceof Error ? error.message : String(error);
4820
5271
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
5272
+ await runHooks("OnError", { tool: step.tool, args: step.args || {}, error: summary });
4821
5273
  throw error;
4822
5274
  }
4823
5275
  recordToolTrace(options.runId || "manual", step.tool, step.args || {}, status, summary);
4824
5276
  await runHooks("AfterTool", { tool: step.tool, rows: current.length });
5277
+ await runHooks("PostToolUse", { tool: step.tool, rows: current.length });
4825
5278
  }
4826
5279
  return { rows: current, outputs };
4827
5280
  }
@@ -4849,7 +5302,12 @@ async function runHooks(event, payload = {}) {
4849
5302
  const config = await loadConfig();
4850
5303
  const commands = config.hooks?.[event] || [];
4851
5304
  for (const command of commands) {
4852
- 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);
4853
5311
  if (parts.length === 0) continue;
4854
5312
  await runCommand(parts[0], parts.slice(1), {
4855
5313
  inherit: true,
@@ -5445,12 +5903,12 @@ function parseOptions(args) {
5445
5903
 
5446
5904
  for (let index = 0; index < args.length; index += 1) {
5447
5905
  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") {
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") {
5449
5907
  result[arg.slice(2)] = true;
5450
5908
  } else if (arg === "--check" || arg === "--upgrade-node") {
5451
5909
  result.check = true;
5452
5910
  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") {
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") {
5454
5912
  result[arg.slice(2)] = args[index + 1];
5455
5913
  index += 1;
5456
5914
  } else {
@@ -5840,6 +6298,13 @@ async function startDaemon(host, port) {
5840
6298
  const server = createServer(async (req, res) => {
5841
6299
  try {
5842
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
+
5843
6308
  res.setHeader("content-type", "application/json; charset=utf-8");
5844
6309
 
5845
6310
  if (req.method === "GET" && url.pathname === "/health") {
@@ -5852,6 +6317,26 @@ async function startDaemon(host, port) {
5852
6317
  return;
5853
6318
  }
5854
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
+
5855
6340
  if (req.method === "POST" && url.pathname === "/rpc") {
5856
6341
  const body = await readRequestBody(req);
5857
6342
  const payload = body ? JSON.parse(body) : {};
@@ -5879,22 +6364,19 @@ async function startDaemon(host, port) {
5879
6364
 
5880
6365
  async function startMcpServer(host, port) {
5881
6366
  const server = createServer(async (req, res) => {
6367
+ let payload = {};
5882
6368
  try {
5883
6369
  res.setHeader("content-type", "application/json; charset=utf-8");
5884
6370
  if (req.method !== "POST") {
5885
- 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) }));
5886
6372
  return;
5887
6373
  }
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, _: [] });
6374
+ payload = JSON.parse(await readRequestBody(req) || "{}");
6375
+ const result = await handleMcpMessage(payload);
5894
6376
  res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result }));
5895
6377
  } catch (error) {
5896
6378
  res.statusCode = 500;
5897
- 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) } }));
5898
6380
  }
5899
6381
  });
5900
6382
  await new Promise((resolve, reject) => {
@@ -5905,6 +6387,142 @@ async function startMcpServer(host, port) {
5905
6387
  await new Promise(() => {});
5906
6388
  }
5907
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
+
5908
6526
  function readRequestBody(req) {
5909
6527
  return new Promise((resolve, reject) => {
5910
6528
  let body = "";
@@ -6338,6 +6956,101 @@ function recordToolTrace(runId, tool, args, status, summary) {
6338
6956
  }
6339
6957
  }
6340
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
+
6341
7054
  function listTrace(limit = 20) {
6342
7055
  initDatabase();
6343
7056
  const db = openDatabase();
@@ -6348,6 +7061,20 @@ function listTrace(limit = 20) {
6348
7061
  }
6349
7062
  }
6350
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
+
6351
7078
  function getTraceRun(runId) {
6352
7079
  initDatabase();
6353
7080
  const db = openDatabase();
@@ -6378,6 +7105,22 @@ async function createSnapshot() {
6378
7105
  }
6379
7106
  }
6380
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
+
6381
7124
  function listSnapshots() {
6382
7125
  initDatabase();
6383
7126
  const db = openDatabase();
@@ -6653,7 +7396,7 @@ async function executeRpc(method, options = {}) {
6653
7396
  if (method === "index.search") {
6654
7397
  return searchDocs(options.query || options.search || "", Number(options.limit || 20));
6655
7398
  }
6656
- 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.`);
6657
7400
  }
6658
7401
 
6659
7402
  async function getLatestNpmVersion(packageName) {
@@ -6780,11 +7523,39 @@ async function writeConfig(value) {
6780
7523
  }
6781
7524
 
6782
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) {
6783
7555
  try {
6784
- const text = await readFile(CONFIG_FILE, "utf8");
6785
- return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
7556
+ return JSON.parse(await readFile(file, "utf8"));
6786
7557
  } catch {
6787
- return DEFAULT_AI_CONFIG;
7558
+ return null;
6788
7559
  }
6789
7560
  }
6790
7561
 
@@ -6836,6 +7607,20 @@ function mergeConfig(base, override) {
6836
7607
  ...base.cron,
6837
7608
  ...(override.cron || {}),
6838
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,
6839
7624
  };
6840
7625
  }
6841
7626
 
@@ -6996,6 +7781,7 @@ function runCommand(command, args, options = {}) {
6996
7781
  const child = execFile(command, args, {
6997
7782
  windowsHide: true,
6998
7783
  maxBuffer: 1024 * 1024 * 5,
7784
+ cwd: options.cwd,
6999
7785
  env: {
7000
7786
  ...process.env,
7001
7787
  ...(options.env || {}),