@iola_adm/iola-cli 0.1.49 → 0.1.51

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/README.md CHANGED
@@ -126,10 +126,12 @@ iola version --check
126
126
  - поиск и выгрузка открытых данных;
127
127
  - локальная SQLite-БД, история, сессии и FTS-поиск;
128
128
  - AI-профили для Ollama, OpenAI, OpenRouter и Codex CLI;
129
- - локальный tool-agent для слабых моделей;
130
- - skills, toolsets, permissions, memory, hooks и готовые agents;
129
+ - локальный tool-agent для слабых моделей с минимальными tools `search_data`, `get_card`, `export_report`, `file_read`, `browser_open`;
130
+ - ленивые skills, toolsets, permissions, memory, hooks и готовые agents;
131
131
  - subagents, skill bundles, layered settings, usage/budget accounting и trajectory export;
132
132
  - полноценный локальный MCP server по stdio/http: tools, resources и prompts;
133
+ - MCP-мост для локальной модели: встроенный `iola-local` доступен как `mcp:iola-local:TOOL`;
134
+ - дополнительные stdio MCP-серверы можно добавить в `~/.iola/config.json` в раздел `mcp.servers`;
133
135
  - браузерный runtime через Playwright: чтение страниц, скриншоты, PDF, клики, ввод и eval;
134
136
  - личное локальное подключение Госуслуг с явным согласием пользователя и хранением доступа только на его ПК;
135
137
  - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: browser-agent
3
+ description: Работа с браузером и веб-страницами только по явному запросу пользователя.
4
+ ---
5
+
6
+ Используй браузерные инструменты только когда пользователь просит открыть сайт, страницу, URL, сделать скриншот, получить текст страницы или проверить поведение веб-интерфейса.
7
+
8
+ Не открывай браузер для обычного диалога и запросов по локальным открытым данным, если URL или браузерный сценарий не нужен.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: local-files
3
+ description: Работа с локальными файлами только по явному запросу пользователя.
4
+ ---
5
+
6
+ Используй файловые инструменты только когда пользователь просит прочитать, найти, проверить или подготовить локальный файл, папку, архив или документ.
7
+
8
+ Не обращайся к локальным файлам для обычного диалога.
9
+
10
+ Перед записью или изменением файлов учитывай текущий режим permissions и approvals.
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { createServer } from "node:http";
@@ -27,9 +27,11 @@ const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
27
27
  const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
28
28
  const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
29
29
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
30
- const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
30
+ const LOCAL_TOOLS = ["search_data", "get_card", "export_report", "file_read", "browser_open"];
31
+ const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
31
32
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
32
33
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
34
+ const ALL_TOOL_ALIASES = [...ALL_LOCAL_TOOLS, ...LEGACY_LOCAL_TOOLS];
33
35
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
34
36
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
35
37
  const GOSUSLUGI_CONSENT_VERSION = "2026-05-26-personal-local-v1";
@@ -54,11 +56,11 @@ const PROJECT_CONTEXT_DIR_FILE = path.join(process.cwd(), ".iola", "context.md")
54
56
  const TOOLSETS = {
55
57
  "data-read": {
56
58
  description: "Чтение открытых данных и локальный поиск.",
57
- permissions: { externalApi: true, localTools: { search_local: true, get_card: true, run_report: true } },
59
+ permissions: { externalApi: true, localTools: { search_data: true, get_card: true, export_report: true } },
58
60
  },
59
61
  reports: {
60
62
  description: "Отчеты, выгрузки и сохранение view.",
61
- permissions: { writeFiles: true, localTools: { export_data: true, save_view: true, run_report: true } },
63
+ permissions: { writeFiles: true, localTools: { export_report: true } },
62
64
  },
63
65
  sync: {
64
66
  description: "Обновление локальной копии данных из публичного API.",
@@ -171,11 +173,11 @@ const DEFAULT_AI_CONFIG = {
171
173
  },
172
174
  permissions: {
173
175
  localTools: {
174
- search_local: true,
176
+ search_data: true,
175
177
  get_card: true,
176
- export_data: true,
177
- run_report: true,
178
- save_view: true,
178
+ export_report: true,
179
+ file_read: false,
180
+ browser_open: true,
179
181
  files_tree: false,
180
182
  files_read: false,
181
183
  files_search: false,
@@ -206,12 +208,15 @@ const DEFAULT_AI_CONFIG = {
206
208
  suggestions: true,
207
209
  },
208
210
  skills: {
209
- enabled: ["open-data", "reports", "local-model"],
211
+ enabled: ["open-data", "reports", "local-model", "local-files", "browser-agent"],
210
212
  },
211
213
  daemon: {
212
214
  host: "127.0.0.1",
213
215
  port: DAEMON_PORT,
214
216
  },
217
+ mcp: {
218
+ servers: {},
219
+ },
215
220
  cron: {
216
221
  enabled: true,
217
222
  },
@@ -3440,12 +3445,12 @@ async function handlePermissions(args) {
3440
3445
 
3441
3446
  if (action === "allow" || action === "deny") {
3442
3447
  if (!name) {
3443
- throw new Error("Пример: iola permissions deny export_data");
3448
+ throw new Error("Пример: iola permissions deny export_report");
3444
3449
  }
3445
3450
  const allow = action === "allow";
3446
3451
  const next = { ...(config.permissions || DEFAULT_AI_CONFIG.permissions) };
3447
3452
  next.localTools = { ...(next.localTools || {}) };
3448
- if (ALL_LOCAL_TOOLS.includes(name)) {
3453
+ if (ALL_TOOL_ALIASES.includes(name)) {
3449
3454
  next.localTools[name] = allow;
3450
3455
  } else if (name in DEFAULT_AI_CONFIG.permissions) {
3451
3456
  next[name] = allow;
@@ -5904,7 +5909,8 @@ async function aiAsk(args, context = {}) {
5904
5909
  return localToolAsk(question, providerConfig, options);
5905
5910
  }
5906
5911
  applyRuntimeConfig(providerConfig, options.config);
5907
- const dataContext = options.bare ? { layers: [], query: { text: question, terms: [], patterns: { numbers: [], inns: [], streets: [], targetLayers: [] } }, schools: [], kindergartens: [] } : await buildDataContext(question);
5912
+ const useDataContext = !options.bare && shouldUseDataContext(question, options);
5913
+ const dataContext = useDataContext ? await buildDataContext(question) : emptyDataContext(question);
5908
5914
  emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
5909
5915
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
5910
5916
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
@@ -6061,9 +6067,10 @@ async function buildLocalToolPlan(question, providerConfig, options) {
6061
6067
  const prompt = [
6062
6068
  "Ты планировщик CLI iola. Верни только JSON.",
6063
6069
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
6064
- "Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
6065
- options.files ? "Файловые tools: files_tree {path,depth,limit}, files_read {path}, files_search {query,path}, files_write {path,text}, files_patch {path,search,replace}." : "",
6066
- "Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
6070
+ "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
6071
+ "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
6072
+ "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
6073
+ "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
6067
6074
  `Вопрос: ${question}`,
6068
6075
  ].join("\n");
6069
6076
 
@@ -6090,19 +6097,19 @@ function inferToolPlan(question, options = {}) {
6090
6097
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
6091
6098
  const steps = [];
6092
6099
  if (normalized.includes("без телефона")) {
6093
- steps.push({ tool: "run_report", args: { name: "missing-phones" } });
6100
+ steps.push({ tool: "export_report", args: { name: "missing-phones" } });
6094
6101
  } else {
6095
6102
  const query = normalized.match(/петрова|школ[а-яё ]*\d+|сад[а-яё ]*\d+|лицей[а-яё ]*\d+/iu)?.[0] || question;
6096
- steps.push({ tool: "search_local", args: { dataset, query, limit: 20 } });
6103
+ steps.push({ tool: "search_data", args: { dataset, query, limit: 20 } });
6097
6104
  }
6098
6105
  if (normalized.includes("csv") || normalized.includes("выгруз")) {
6099
- steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
6106
+ steps.push({ tool: "export_report", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
6100
6107
  }
6101
6108
  if (options.files || normalized.includes("файл") || normalized.includes("папк") || normalized.includes("readme")) {
6102
6109
  if (normalized.includes("найди") || normalized.includes("поиск")) {
6103
- steps.unshift({ tool: "files_search", args: { query: question, path: "." } });
6110
+ steps.unshift({ tool: "mcp:iola-local:index.search", args: { query: question, limit: 20 } });
6104
6111
  } else {
6105
- steps.unshift({ tool: "files_tree", args: { path: ".", depth: 2, limit: 80 } });
6112
+ steps.unshift({ tool: "file_read", args: { path: "." } });
6106
6113
  }
6107
6114
  }
6108
6115
  return { steps };
@@ -6123,13 +6130,15 @@ function validateToolPlan(plan, options = {}) {
6123
6130
  const allowed = new Set(availableToolNames(options));
6124
6131
  if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
6125
6132
  for (const step of plan.steps) {
6126
- if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
6133
+ if (!allowed.has(step.tool) && !String(step.tool || "").startsWith("mcp:")) throw new Error(`Недопустимый tool: ${step.tool}`);
6127
6134
  }
6128
6135
  return plan;
6129
6136
  }
6130
6137
 
6131
6138
  function availableToolNames(options = {}) {
6132
- return options.files ? ALL_LOCAL_TOOLS : LOCAL_TOOLS;
6139
+ const names = new Set(LOCAL_TOOLS);
6140
+ for (const tool of getLocalMcpToolNames()) names.add(tool);
6141
+ return [...names];
6133
6142
  }
6134
6143
 
6135
6144
  async function executeToolPlan(plan, options = {}) {
@@ -6142,16 +6151,24 @@ async function executeToolPlan(plan, options = {}) {
6142
6151
  await runHooks("PreToolUse", { tool: step.tool, args: step.args || {} });
6143
6152
  await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
6144
6153
  try {
6145
- if (step.tool === "search_local") {
6154
+ if (step.tool === "search_data" || step.tool === "search_local") {
6146
6155
  current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
6147
6156
  outputs.push({ tool: step.tool, rows: current.length });
6148
6157
  } else if (step.tool === "get_card") {
6149
6158
  const card = findCard(step.args?.query || "");
6150
6159
  current = card ? [card] : [];
6151
6160
  outputs.push({ tool: step.tool, rows: current.length });
6152
- } else if (step.tool === "run_report") {
6161
+ } else if (step.tool === "export_report" || step.tool === "run_report") {
6153
6162
  current = runQuality(step.args?.name || "all");
6154
6163
  outputs.push({ tool: step.tool, rows: current.length });
6164
+ if (step.args?.output || step.args?.format) {
6165
+ await assertPermission("writeFiles");
6166
+ const output = step.args?.output || `${step.args?.name || "report"}.${step.args?.format || "csv"}`;
6167
+ const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
6168
+ await writeFile(output, text, "utf8");
6169
+ saveArtifact("export", output, output, { rows: current.length });
6170
+ outputs.push({ tool: step.tool, output, rows: current.length });
6171
+ }
6155
6172
  } else if (step.tool === "save_view") {
6156
6173
  saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
6157
6174
  outputs.push({ tool: step.tool, saved: step.args?.name });
@@ -6162,6 +6179,18 @@ async function executeToolPlan(plan, options = {}) {
6162
6179
  await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
6163
6180
  saveArtifact("export", step.args?.output || "iola-export.csv", step.args?.output || "iola-export.csv", { rows: current.length });
6164
6181
  outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
6182
+ } else if (step.tool === "file_read") {
6183
+ const text = await filesRead(step.args?.path || step.args?.file || ".", step.args || {});
6184
+ current = [{ path: step.args?.path || step.args?.file || ".", text }];
6185
+ outputs.push({ tool: step.tool, bytes: text.length });
6186
+ } else if (step.tool === "browser_open") {
6187
+ const text = await runBrowserAutomation("text", { url: step.args?.url, waitMs: Number(step.args?.waitMs || 0), timeout: Number(step.args?.timeout || 30000), viewport: step.args?.viewport || "1366x768" });
6188
+ current = [{ url: step.args?.url, text }];
6189
+ outputs.push({ tool: step.tool, rows: 1 });
6190
+ } else if (String(step.tool || "").startsWith("mcp:")) {
6191
+ const result = await callConfiguredMcpTool(step.tool, step.args || {});
6192
+ current = Array.isArray(result) ? result : [result];
6193
+ outputs.push({ tool: step.tool, rows: current.length });
6165
6194
  } else if (step.tool === "files_tree") {
6166
6195
  current = await filesTree(step.args?.path || ".", step.args || {});
6167
6196
  outputs.push({ tool: step.tool, rows: current.length });
@@ -6196,6 +6225,78 @@ async function executeToolPlan(plan, options = {}) {
6196
6225
  return { rows: current, outputs };
6197
6226
  }
6198
6227
 
6228
+ function getLocalMcpToolNames() {
6229
+ return mcpTools().map((tool) => `mcp:iola-local:${tool.name}`);
6230
+ }
6231
+
6232
+ async function callConfiguredMcpTool(toolId, args = {}) {
6233
+ const [, serverName, ...toolParts] = String(toolId).split(":");
6234
+ const toolName = toolParts.join(":");
6235
+ if (!serverName || !toolName) throw new Error(`Некорректный MCP tool id: ${toolId}`);
6236
+ const server = getConfiguredMcpServers()[serverName];
6237
+ if (!server) throw new Error(`MCP server не настроен: ${serverName}`);
6238
+ return callStdioMcpTool(server, toolName, args);
6239
+ }
6240
+
6241
+ function getConfiguredMcpServers() {
6242
+ const userConfig = readConfigLayerSync(CONFIG_FILE);
6243
+ const configured = userConfig?.mcp?.servers && typeof userConfig.mcp.servers === "object" ? userConfig.mcp.servers : {};
6244
+ return {
6245
+ "iola-local": {
6246
+ command: process.execPath,
6247
+ args: [path.resolve(__dirname, "..", "bin", "iola.js"), "mcp", "serve", "--stdio"],
6248
+ },
6249
+ ...configured,
6250
+ };
6251
+ }
6252
+
6253
+ async function callStdioMcpTool(server, toolName, args = {}) {
6254
+ const child = spawn(server.command, server.args || [], {
6255
+ cwd: server.cwd || process.cwd(),
6256
+ stdio: ["pipe", "pipe", "pipe"],
6257
+ windowsHide: true,
6258
+ });
6259
+ let stdout = "";
6260
+ let stderr = "";
6261
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
6262
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
6263
+ const request = { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } };
6264
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`);
6265
+ child.stdin.write(`${JSON.stringify(request)}\n`);
6266
+ child.stdin.end();
6267
+ await waitForProcess(child, 15000);
6268
+ const responses = stdout.split(/\r?\n/).filter(Boolean).map((line) => {
6269
+ try { return JSON.parse(line); } catch { return null; }
6270
+ }).filter(Boolean);
6271
+ const response = responses.find((item) => item.id === 2) || responses.at(-1);
6272
+ if (!response) throw new Error(`MCP server ${server.command} не вернул ответ. ${stderr}`.trim());
6273
+ if (response.error) throw new Error(response.error.message || JSON.stringify(response.error));
6274
+ const content = response.result?.content || [];
6275
+ const text = content.map((item) => item.text || "").join("\n").trim();
6276
+ try {
6277
+ return JSON.parse(text);
6278
+ } catch {
6279
+ return text || response.result;
6280
+ }
6281
+ }
6282
+
6283
+ function waitForProcess(child, timeoutMs) {
6284
+ return new Promise((resolve, reject) => {
6285
+ const timer = setTimeout(() => {
6286
+ child.kill();
6287
+ reject(new Error("MCP call timeout"));
6288
+ }, timeoutMs);
6289
+ child.once("error", (error) => {
6290
+ clearTimeout(timer);
6291
+ reject(error);
6292
+ });
6293
+ child.once("close", () => {
6294
+ clearTimeout(timer);
6295
+ resolve();
6296
+ });
6297
+ });
6298
+ }
6299
+
6199
6300
  function formatToolResult(result, options) {
6200
6301
  if (options.schema === "json") return JSON.stringify(result, null, 2);
6201
6302
  const exported = result.outputs.find((item) => item.output);
@@ -6220,7 +6321,7 @@ async function runHooks(event, payload = {}) {
6220
6321
  const commands = config.hooks?.[event] || [];
6221
6322
  for (const command of commands) {
6222
6323
  const [maybeFilter, ...rest] = String(command).split(":");
6223
- const commandText = payload.tool && rest.length > 0 && ALL_LOCAL_TOOLS.includes(maybeFilter.trim())
6324
+ const commandText = payload.tool && rest.length > 0 && ALL_TOOL_ALIASES.includes(maybeFilter.trim())
6224
6325
  ? (maybeFilter.trim() === payload.tool ? rest.join(":").trim() : "")
6225
6326
  : command;
6226
6327
  if (!commandText) continue;
@@ -6239,7 +6340,7 @@ async function runHooks(event, payload = {}) {
6239
6340
  async function assertPermission(name) {
6240
6341
  const config = await loadConfig();
6241
6342
  const permissions = applyToolsetPermissions(config.permissions || DEFAULT_AI_CONFIG.permissions, config.toolsets?.enabled || []);
6242
- if (ALL_LOCAL_TOOLS.includes(name)) {
6343
+ if (ALL_TOOL_ALIASES.includes(name)) {
6243
6344
  if (permissions.localTools?.[name] === false) {
6244
6345
  throw new Error(`Tool запрещен политикой permissions: ${name}`);
6245
6346
  }
@@ -6306,6 +6407,29 @@ async function buildDataContext(question) {
6306
6407
  };
6307
6408
  }
6308
6409
 
6410
+ function emptyDataContext(question) {
6411
+ return {
6412
+ enabled: false,
6413
+ layers: [],
6414
+ query: {
6415
+ text: question,
6416
+ terms: [],
6417
+ patterns: { numbers: [], inns: [], streets: [], targetLayers: [] },
6418
+ },
6419
+ schools: [],
6420
+ kindergartens: [],
6421
+ };
6422
+ }
6423
+
6424
+ function shouldUseDataContext(question, options = {}) {
6425
+ if (options.tools || options.files || options.schema || options.output) return true;
6426
+ const normalized = question.toLocaleLowerCase("ru-RU").trim();
6427
+ if (/^(привет|здравствуй|здравствуйте|добрый день|доброе утро|добрый вечер|hi|hello|hey)[!.?\s]*$/iu.test(normalized)) return false;
6428
+ if (/^(спасибо|благодарю|ок|окей|понял|поняла|ясно|хорошо|да|нет)[!.?\s]*$/iu.test(normalized)) return false;
6429
+ if (normalized.length <= 24 && /^(как дела|что нового|ты тут|ты здесь|кто ты)[?.!\s]*$/iu.test(normalized)) return false;
6430
+ return /\b(школ|сад|детсад|детский сад|лицей|гимнази|инн|адрес|телефон|почт|email|сайт|лиценз|руководител|директор|слой|слои|данн|отчет|отчёт|выгруз|csv|json|найди|покажи|список|карточк|организац|учрежден|йошкар|ола|петрова|строител|советск|первомайск)\b/iu.test(normalized);
6431
+ }
6432
+
6309
6433
  function extractSearchTerms(question) {
6310
6434
  const normalized = question
6311
6435
  .toLocaleLowerCase("ru-RU")
@@ -6401,14 +6525,15 @@ async function buildAiMessages(question, dataContext, history, options = {}, con
6401
6525
  const sourceLines = buildSourceLines(dataContext);
6402
6526
  const memoryText = options.bare ? "" : buildMemoryText();
6403
6527
  const projectContext = options.bare ? "" : await buildProjectContextText();
6404
- const skillsText = options.bare ? "" : await buildSkillsText(config);
6528
+ const skillsText = options.bare ? "" : await buildSkillsText(config, question, options);
6529
+ const hasDataContext = dataContext.enabled !== false;
6405
6530
  const system = [
6406
- "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
6407
- "Отвечай на русском языке.",
6408
- "Используй только данные из переданного контекста.",
6409
- "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
6410
- "Не выдумывай адреса, телефоны, лицензии и руководителей.",
6411
- "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
6531
+ "Ты терминальный AI-агент городского округа Йошкар-Ола.",
6532
+ "Отвечай на русском языке естественно и по смыслу запроса пользователя.",
6533
+ hasDataContext ? "Используй только данные из переданного контекста открытых данных." : "Для обычного диалога отвечай как полноценный AI-ассистент, не перечисляй слои и возможности без запроса пользователя.",
6534
+ hasDataContext ? "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно." : "",
6535
+ hasDataContext ? "Не выдумывай адреса, телефоны, лицензии и руководителей." : "",
6536
+ hasDataContext ? "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН." : "",
6412
6537
  options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
6413
6538
  options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
6414
6539
  memoryText ? `Учитывай пользовательскую память:\n${memoryText}` : "",
@@ -6418,14 +6543,14 @@ async function buildAiMessages(question, dataContext, history, options = {}, con
6418
6543
  ].filter(Boolean).join(" ");
6419
6544
  const contextText = JSON.stringify(dataContext, null, 2);
6420
6545
  const recentHistory = history.slice(-6);
6546
+ const userContent = hasDataContext
6547
+ ? `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`
6548
+ : question;
6421
6549
 
6422
6550
  return [
6423
6551
  { role: "system", content: system },
6424
6552
  ...recentHistory,
6425
- {
6426
- role: "user",
6427
- content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`,
6428
- },
6553
+ { role: "user", content: userContent },
6429
6554
  ];
6430
6555
  }
6431
6556
 
@@ -7088,16 +7213,29 @@ function isSkillEnabled(config, name) {
7088
7213
  return (config.skills?.enabled || []).includes(name);
7089
7214
  }
7090
7215
 
7091
- async function buildSkillsText(config) {
7216
+ async function buildSkillsText(config, question = "", options = {}) {
7092
7217
  const chunks = [];
7218
+ const selected = selectSkillsForPrompt(config, question, options);
7093
7219
  for (const skill of listSkills(config)) {
7094
- if (!skill.enabled) continue;
7220
+ if (!skill.enabled || !selected.has(skill.name)) continue;
7095
7221
  const text = await readFile(skill.file, "utf8");
7096
7222
  chunks.push(`## Skill: ${skill.name}\n${stripFrontmatter(text).trim()}`);
7097
7223
  }
7098
7224
  return chunks.join("\n\n").slice(0, 12000);
7099
7225
  }
7100
7226
 
7227
+ function selectSkillsForPrompt(config, question = "", options = {}) {
7228
+ const enabled = new Set(config.skills?.enabled || []);
7229
+ const selected = new Set();
7230
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
7231
+ if (enabled.has("local-model")) selected.add("local-model");
7232
+ if (enabled.has("open-data") && shouldUseDataContext(question, options)) selected.add("open-data");
7233
+ if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
7234
+ if (enabled.has("local-files") && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
7235
+ if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
7236
+ return selected;
7237
+ }
7238
+
7101
7239
  function stripFrontmatter(text) {
7102
7240
  return String(text).replace(/^---\n[\s\S]*?\n---\n?/, "");
7103
7241
  }
@@ -8616,6 +8754,14 @@ async function readConfigLayer(file) {
8616
8754
  }
8617
8755
  }
8618
8756
 
8757
+ function readConfigLayerSync(file) {
8758
+ try {
8759
+ return JSON.parse(readFileSync(file, "utf8"));
8760
+ } catch {
8761
+ return null;
8762
+ }
8763
+ }
8764
+
8619
8765
  function mergeConfig(base, override) {
8620
8766
  return {
8621
8767
  ...base,
@@ -8664,6 +8810,14 @@ function mergeConfig(base, override) {
8664
8810
  ...base.daemon,
8665
8811
  ...(override.daemon || {}),
8666
8812
  },
8813
+ mcp: {
8814
+ ...base.mcp,
8815
+ ...(override.mcp || {}),
8816
+ servers: {
8817
+ ...(base.mcp?.servers || {}),
8818
+ ...(override.mcp?.servers || {}),
8819
+ },
8820
+ },
8667
8821
  cron: {
8668
8822
  ...base.cron,
8669
8823
  ...(override.cron || {}),
@@ -8697,7 +8851,7 @@ function validateConfig(config) {
8697
8851
  if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
8698
8852
  }
8699
8853
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
8700
- if (!ALL_LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
8854
+ if (!ALL_TOOL_ALIASES.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
8701
8855
  }
8702
8856
  for (const toolset of config.toolsets?.enabled || []) {
8703
8857
  if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
@@ -2,6 +2,16 @@
2
2
 
3
3
  `iola-cli` использует skills как подключаемые инструкции для работы с данными городского округа "Город Йошкар-Ола".
4
4
 
5
+ Skills не подмешиваются в каждый запрос целиком. CLI выбирает их по смыслу:
6
+
7
+ - `open-data` - когда запрос про открытые данные, школы, детские сады, адреса, ИНН, слои;
8
+ - `reports` - когда нужен отчет, выгрузка, CSV/XLSX или проверка качества;
9
+ - `local-files` - когда пользователь просит работать с локальными файлами, папками, архивами или документами;
10
+ - `browser-agent` - когда запрос связан с сайтом, URL, страницей, скриншотом или браузером;
11
+ - `local-model` - инструкции для локальных компактных моделей и tool-планирования.
12
+
13
+ Обычный диалог вроде `привет` не получает инструкции про слои, отчеты, файлы и браузер.
14
+
5
15
  ```bash
6
16
  iola skills list
7
17
  iola skills show open-data
@@ -28,4 +38,3 @@ iola tools profile full
28
38
 
29
39
  Режим `safe` подходит для чтения и анализа без записи файлов и без запуска sync.
30
40
  Режим `full` предназначен для доверенного локального пользователя.
31
-
@@ -9,13 +9,43 @@ iola ask "выгрузи школы на Петрова в csv" --profile local
9
9
  iola ask "найди детсады без телефона" --profile local --tools --reasoning verify
10
10
  ```
11
11
 
12
- Доступные tools:
12
+ Минимальные встроенные tools:
13
13
 
14
- - `search_local` - поиск по локальной SQLite-БД.
14
+ - `search_data` - поиск по локальной SQLite-БД и открытым данным.
15
15
  - `get_card` - карточка объекта.
16
- - `run_report` - встроенная проверка/отчет.
17
- - `export_data` - выгрузка результата в CSV/JSON.
18
- - `save_view` - сохранение представления.
16
+ - `export_report` - отчет или выгрузка результата в CSV/JSON.
17
+ - `file_read` - чтение локального файла в разрешенном workspace.
18
+ - `browser_open` - открыть страницу через browser runtime и вернуть текст.
19
+
20
+ Старые имена `search_local`, `run_report`, `export_data` сохранены как алиасы для совместимости, но новый планировщик их не предлагает.
21
+
22
+ MCP tools:
23
+
24
+ Локальная модель может вызывать подключенные MCP tools через CLI-мост. Встроенный локальный MCP-сервер доступен как `iola-local`, а tool указывается в виде:
25
+
26
+ ```text
27
+ mcp:iola-local:search
28
+ mcp:iola-local:card
29
+ mcp:iola-local:report
30
+ mcp:iola-local:browser.text
31
+ ```
32
+
33
+ Дополнительные stdio MCP-серверы можно добавить в `~/.iola/config.json`:
34
+
35
+ ```json
36
+ {
37
+ "mcp": {
38
+ "servers": {
39
+ "example": {
40
+ "command": "npx",
41
+ "args": ["-y", "some-mcp-server"]
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ После этого локальный plan может вызвать tool как `mcp:example:tool_name`.
19
49
 
20
50
  Toolsets:
21
51
 
@@ -31,8 +61,8 @@ iola tools profile full
31
61
 
32
62
  ```bash
33
63
  iola permissions list
34
- iola permissions deny export_data
35
- iola permissions allow export_data
64
+ iola permissions deny export_report
65
+ iola permissions allow export_report
36
66
  ```
37
67
 
38
68
  Режимы планирования: