@iola_adm/iola-cli 0.1.85 → 0.1.87

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
@@ -116,11 +116,19 @@ iola review config
116
116
  iola browser status
117
117
  ```
118
118
 
119
- Локальная модель через Ollama:
119
+ Локальная модель IOLA через Hugging Face:
120
+
121
+ ```bash
122
+ iola ai setup iola --yes
123
+ iola ask "дай телефон школы № 2"
124
+ ```
125
+
126
+ CLI скачивает `LMSerg/iola-1b-router-2026-05-28-merged` в `~/.iola/models/router`, проверяет свежесть модели при запуске AI-команд и обновляет ее автоматически.
127
+
128
+ Ollama остается опциональным runtime:
120
129
 
121
130
  ```bash
122
131
  iola ai setup ollama
123
- iola ask "выгрузи школы на Петрова в csv" --profile local --tools
124
132
  ```
125
133
 
126
134
  Обновление:
@@ -154,8 +162,8 @@ iola version --check
154
162
  - интеграция с публичным MCP-сервером Йошкар-Олы;
155
163
  - поиск и выгрузка открытых данных;
156
164
  - локальная SQLite-БД, история, сессии и FTS-поиск;
157
- - AI-профили для Ollama, OpenAI, OpenRouter и Codex CLI;
158
- - локальный tool-agent для слабых моделей с минимальными tools `search_data`, `get_card`, `export_report`, `file_read`, `browser_open`;
165
+ - AI-профили для IOLA local, Ollama, OpenAI, OpenRouter и Codex CLI;
166
+ - локальный tool-agent для модели IOLA с tools `search_data`, `search_entities`, `resolve_entity_field`, `get_card`, `export_report`, `file_read`, `browser_open`;
159
167
  - ленивые skills, toolsets, permissions, memory, hooks и готовые agents;
160
168
  - subagents, skill bundles, layered settings, usage/budget accounting и trajectory export;
161
169
  - локальный MCP-сервер по stdio/http для подключения iola-cli к другим AI-клиентам;
@@ -169,6 +177,6 @@ iola version --check
169
177
  - staged changes, импорт локальных CSV/JSON, индекс локальных документов, report packs, plugins и локальный MCP endpoint;
170
178
  - чтение и индексирование `.docx`, `.xlsx`, `.pptx`, `.pdf`, `.md`, `.txt`, `.csv`, `.json`, `.html`;
171
179
  - работа с архивами через 7-Zip: `.zip`, `.7z`, `.rar`, `.tar`, `.gz`, `.tgz`, `.bz2`, `.xz` и другие;
172
- - расширенный `iola onboard` с установкой 7-Zip, браузерного runtime, Ollama, Codex CLI и настройкой выбранных компонентов;
180
+ - расширенный `iola onboard` с установкой 7-Zip, браузерного runtime, IOLA local, Ollama, Codex CLI и настройкой выбранных компонентов;
173
181
  - cron-задачи, локальный daemon, web dashboard и RPC для автоматизаций;
174
182
  - контекстные файлы `IOLA.md` и `.iola/context.md`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -16,7 +16,7 @@
16
16
  "iola": "bin/iola.js"
17
17
  },
18
18
  "scripts": {
19
- "postinstall": "node --no-warnings bin/iola.js db init --silent && node --no-warnings bin/iola.js browser install",
19
+ "postinstall": "node --no-warnings bin/iola.js db init --silent && node --no-warnings bin/iola.js browser install && node --no-warnings bin/iola.js ai setup iola --yes --quiet --optional",
20
20
  "start": "node --no-warnings bin/iola.js",
21
21
  "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js && node --no-warnings test/smoke-test.js"
22
22
  },
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { DatabaseSync } from "node:sqlite";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { inflateRawSync, inflateSync } from "node:zlib";
13
13
 
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
15
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
15
16
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
16
17
  const MIN_NODE_VERSION = "22.5.0";
@@ -20,20 +21,25 @@ const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
20
21
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
21
22
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
22
23
  const DB_SCHEMA_VERSION = 8;
24
+ const IOLA_LOCAL_MODEL = "iola-router-1b";
25
+ const IOLA_LOCAL_OLLAMA_MODEL = "gemma3:1b";
26
+ const IOLA_ROUTER_HF_REPO = process.env.IOLA_ROUTER_HF_REPO || "LMSerg/iola-1b-router-2026-05-28-merged";
27
+ const IOLA_MODEL_DIR = path.join(CONFIG_DIR, "models", "router");
28
+ const IOLA_MODEL_RUNTIME_DIR = path.join(CONFIG_DIR, "model-runtime");
29
+ const IOLA_MODEL_RUNNER = path.resolve(__dirname, "iola_hf_runner.py");
23
30
  const PROJECT_IOLA_DIR = path.join(process.cwd(), ".iola");
24
31
  const PROJECT_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "config.json");
25
32
  const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
26
33
  const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
27
34
  const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
28
35
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
29
- const LOCAL_TOOLS = ["search_data", "get_card", "export_report", "file_read", "browser_open"];
36
+ const LOCAL_TOOLS = ["search_data", "search_entities", "resolve_entity_field", "get_card", "export_report", "file_read", "browser_open"];
30
37
  const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
31
38
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
32
39
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
33
40
  const ALL_TOOL_ALIASES = [...ALL_LOCAL_TOOLS, ...LEGACY_LOCAL_TOOLS];
34
41
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "PreToolUse", "PostToolUse", "OnError", "AfterSync", "BeforeExport", "SessionEnd"];
35
42
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
36
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
43
  const BUILTIN_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
38
44
  const USER_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
39
45
  const PROJECT_CONTEXT_FILE = path.join(process.cwd(), "IOLA.md");
@@ -102,9 +108,9 @@ const SKILL_BUNDLES = {
102
108
  requirements: ["files mode read-only/workspace-write", "7-Zip для архивов"],
103
109
  },
104
110
  "local-agent": {
105
- description: "Локальная модель Ollama с проверочным reasoning и локальными tools.",
111
+ description: "Локальная модель IOLA с проверочным reasoning и локальными tools.",
106
112
  skills: ["local-model", "open-data"],
107
- requirements: ["Ollama", "локальная модель"],
113
+ requirements: ["Python", "локальная модель"],
108
114
  },
109
115
  };
110
116
  let onboardRanThisProcess = false;
@@ -115,14 +121,14 @@ const DEFAULT_AI_CONFIG = {
115
121
  },
116
122
  ai: {
117
123
  activeProfile: "local",
118
- provider: "ollama",
119
- model: "llama3.2:1b",
120
- baseUrl: "http://127.0.0.1:11434",
124
+ provider: "iola",
125
+ model: IOLA_LOCAL_MODEL,
121
126
  profiles: {
122
127
  local: {
123
- provider: "ollama",
124
- model: "llama3.2:1b",
125
- baseUrl: "http://127.0.0.1:11434",
128
+ provider: "iola",
129
+ model: IOLA_LOCAL_MODEL,
130
+ repo: IOLA_ROUTER_HF_REPO,
131
+ modelDir: IOLA_MODEL_DIR,
126
132
  },
127
133
  openai: {
128
134
  provider: "openai",
@@ -146,6 +152,8 @@ const DEFAULT_AI_CONFIG = {
146
152
  permissions: {
147
153
  localTools: {
148
154
  search_data: true,
155
+ search_entities: true,
156
+ resolve_entity_field: true,
149
157
  get_card: true,
150
158
  export_report: true,
151
159
  file_read: false,
@@ -290,6 +298,7 @@ const SLASH_COMMANDS = [
290
298
  { command: "/quality", description: "качество данных" },
291
299
  { command: "/views", description: "saved views" },
292
300
  { command: "/config get", description: "конфигурация" },
301
+ { command: "/uninstall --yes", description: "удалить локальные данные iola-cli" },
293
302
  { command: "/layers", description: "слои данных" },
294
303
  { command: "/data schools --limit 10", description: "данные слоя" },
295
304
  { command: "/schools --limit 10", description: "школы" },
@@ -381,6 +390,8 @@ const COMMANDS = new Map([
381
390
  ["alias", handleAlias],
382
391
  ["run", runNaturalLanguage],
383
392
  ["config", handleConfig],
393
+ ["uninstall", handleUninstall],
394
+ ["purge", handleUninstall],
384
395
  ["banner", showBanner],
385
396
  ["agent", startAgent],
386
397
  ["chat", startAgent],
@@ -434,6 +445,8 @@ export async function main(argv) {
434
445
  throw new Error(`Нужен Node.js ${MIN_NODE_VERSION} или новее. Сейчас: ${nodeStatus.current}. Запустите: iola init --upgrade-node`);
435
446
  }
436
447
 
448
+ await maybeRefreshIolaModelForCommand(command, args);
449
+
437
450
  const handler = COMMANDS.get(command);
438
451
 
439
452
  if (!handler) {
@@ -448,6 +461,23 @@ export async function main(argv) {
448
461
  await handler(runtime.debugFile ? [...args, "--debug-file", runtime.debugFile] : args);
449
462
  }
450
463
 
464
+ async function maybeRefreshIolaModelForCommand(command, args = []) {
465
+ if (process.env.IOLA_SKIP_MODEL_CHECK === "1") return;
466
+ const aiRuntimeCommands = new Set(["ask", "agent", "chat"]);
467
+ const isAiCommand = command === "ai" && !["setup", "models", "key", "profile", "profiles", "doctor"].includes(args[0]);
468
+ if (!aiRuntimeCommands.has(command) && !isAiCommand) return;
469
+ const config = await loadConfig();
470
+ const profile = config.ai.profiles?.[getActiveProfileName(config)];
471
+ if (profile?.provider !== "iola" && config.ai.provider !== "iola") return;
472
+ await ensureIolaModelFresh({
473
+ repo: profile?.repo || IOLA_ROUTER_HF_REPO,
474
+ modelDir: profile?.modelDir || IOLA_MODEL_DIR,
475
+ quiet: true,
476
+ }).catch((error) => {
477
+ if (process.env.IOLA_DEBUG) console.error(error instanceof Error ? error.message : String(error));
478
+ });
479
+ }
480
+
451
481
  async function showHelp() {
452
482
  await showBanner();
453
483
  console.log(`iola - CLI и AI-агент городского округа "Город Йошкар-Ола"
@@ -549,10 +579,11 @@ Usage:
549
579
  iola config set api.baseUrl URL
550
580
  iola config set api.mcpBaseUrl URL
551
581
  iola config reset
582
+ iola uninstall --yes
552
583
  iola update
553
584
  iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--files] [--plan] [--trace] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
554
585
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
555
- iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
586
+ iola ai ask TEXT [--provider iola|ollama|openai|openrouter] [--model MODEL]
556
587
  iola ai context TEXT [--json]
557
588
  iola ai key set openai
558
589
  iola ai key set openrouter
@@ -562,9 +593,10 @@ Usage:
562
593
  iola ai profile add NAME --provider PROVIDER --model MODEL
563
594
  iola ai profile use NAME
564
595
  iola ai profile delete NAME
565
- iola ai models ollama|openai|openrouter|codex [--search TEXT]
596
+ iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
566
597
  iola ai doctor [--json]
567
598
  iola ai setup
599
+ iola ai setup iola [--yes]
568
600
  iola ai setup ollama [--yes] [--model MODEL]
569
601
  iola health [--json]
570
602
  iola layers [--json]
@@ -682,9 +714,11 @@ async function getAiReadiness() {
682
714
  hasUsableOllamaModel(),
683
715
  hasUsableCodexAuth(),
684
716
  ]);
717
+ const iola = await hasUsableIolaModel();
685
718
  const openai = Boolean(process.env.OPENAI_API_KEY || secrets.openai?.apiKey);
686
719
  const openrouter = Boolean(process.env.OPENROUTER_API_KEY || secrets.openrouter?.apiKey);
687
720
  const providerReady = {
721
+ iola,
688
722
  ollama,
689
723
  openai,
690
724
  openrouter,
@@ -695,8 +729,9 @@ async function getAiReadiness() {
695
729
  activeProfile: activeProfileName,
696
730
  activeProvider: activeProfile.provider || "-",
697
731
  activeModel: activeProfile.model || "-",
698
- anyReady: Boolean(ollama || openai || openrouter || codex),
732
+ anyReady: Boolean(iola || ollama || openai || openrouter || codex),
699
733
  profiles: config.ai.profiles || {},
734
+ iola,
700
735
  ollama,
701
736
  openai,
702
737
  openrouter,
@@ -705,7 +740,7 @@ async function getAiReadiness() {
705
740
  }
706
741
 
707
742
  function getFallbackAiProfile(readiness) {
708
- const priority = ["openai", "openrouter", "codex", "ollama"];
743
+ const priority = ["iola", "openai", "openrouter", "codex", "ollama"];
709
744
  for (const provider of priority) {
710
745
  if (!readiness[provider]) continue;
711
746
  const entry = Object.entries(readiness.profiles || {}).find(([, profile]) => profile.provider === provider);
@@ -1500,6 +1535,7 @@ function buildAgentStatusLine(state) {
1500
1535
  const ai = state.aiStatus;
1501
1536
  if (!ai) return cwd;
1502
1537
  const kind = {
1538
+ iola: "IOLA local",
1503
1539
  ollama: "локальная",
1504
1540
  openai: "API",
1505
1541
  openrouter: "API",
@@ -1885,6 +1921,10 @@ async function upgradeNodeWithInstaller() {
1885
1921
  }
1886
1922
 
1887
1923
  async function checkConfiguredModel(config) {
1924
+ if (config.ai.provider === "iola") {
1925
+ return await hasUsableIolaModel() ? "installed" : "missing";
1926
+ }
1927
+
1888
1928
  if (config.ai.provider !== "ollama") {
1889
1929
  return "external-api";
1890
1930
  }
@@ -1934,6 +1974,7 @@ async function initCli(args = []) {
1934
1974
  if (!process.stdin.isTTY || options.yes) {
1935
1975
  console.log("");
1936
1976
  console.log("Для настройки AI используйте:");
1977
+ console.log(" iola ai setup iola --yes");
1937
1978
  console.log(" iola ai setup ollama");
1938
1979
  console.log(" iola ai key set openai");
1939
1980
  console.log(" iola ai setup openai --model gpt-4.1-mini");
@@ -1957,19 +1998,20 @@ async function handleAi(args) {
1957
1998
  if (subcommand === "help") {
1958
1999
  await showBanner();
1959
2000
  console.log(`AI-команды:
1960
- iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
2001
+ iola ai ask TEXT [--provider iola|ollama|openai|openrouter] [--model MODEL]
1961
2002
  iola ai context TEXT [--json]
1962
2003
  iola ai key set openai
1963
2004
  iola ai key set openrouter
1964
2005
  iola ai key status
1965
2006
  iola ai key delete openai|openrouter
1966
2007
  iola ai profiles
1967
- iola ai profile add NAME --provider ollama|openai|openrouter|codex --model MODEL
2008
+ iola ai profile add NAME --provider iola|ollama|openai|openrouter|codex --model MODEL
1968
2009
  iola ai profile use NAME
1969
2010
  iola ai profile delete NAME
1970
- iola ai models ollama|openai|openrouter|codex [--search TEXT]
2011
+ iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
1971
2012
  iola ai doctor [--json]
1972
2013
  iola ai setup
2014
+ iola ai setup iola [--yes]
1973
2015
  iola ai setup ollama [--yes] [--model MODEL]
1974
2016
  iola ai setup openai [--model MODEL]
1975
2017
  iola ai setup openrouter [--model MODEL]
@@ -2078,6 +2120,79 @@ async function handleConfig(args) {
2078
2120
  throw new Error("Команды config: get, set, validate, schema, reset.");
2079
2121
  }
2080
2122
 
2123
+ async function handleUninstall(args = []) {
2124
+ const options = parseOptions(args);
2125
+ const targets = [
2126
+ {
2127
+ label: "user data",
2128
+ path: CONFIG_DIR,
2129
+ description: "config, secrets, SQLite-БД, модель IOLA, Python/browser runtime, cache, history",
2130
+ },
2131
+ ];
2132
+
2133
+ if (options.project) {
2134
+ targets.push({
2135
+ label: "project data",
2136
+ path: PROJECT_IOLA_DIR,
2137
+ description: "локальная папка .iola текущего проекта",
2138
+ });
2139
+ }
2140
+
2141
+ const safeTargets = targets.map((target) => ({
2142
+ ...target,
2143
+ path: path.resolve(target.path),
2144
+ }));
2145
+ const home = path.resolve(os.homedir());
2146
+ for (const target of safeTargets) {
2147
+ const isUserConfig = target.path === path.resolve(CONFIG_DIR) && target.path.startsWith(home);
2148
+ const isProjectConfig = target.path === path.resolve(PROJECT_IOLA_DIR) && target.path.startsWith(path.resolve(process.cwd()));
2149
+ if (!isUserConfig && !isProjectConfig) {
2150
+ throw new Error(`Небезопасный путь удаления: ${target.path}`);
2151
+ }
2152
+ }
2153
+
2154
+ if (options["dry-run"] || options.json) {
2155
+ const payload = {
2156
+ willDelete: safeTargets.map((target) => ({
2157
+ label: target.label,
2158
+ path: target.path,
2159
+ exists: existsSync(target.path),
2160
+ description: target.description,
2161
+ })),
2162
+ willKeep: ["Codex CLI", "Codex auth/config", "npm package files"],
2163
+ reinstall: "npm install -g @iola_adm/iola-cli@latest",
2164
+ };
2165
+ if (options.json) printJson(payload);
2166
+ else printKeyValue(Object.fromEntries(payload.willDelete.map((item) => [item.label, `${item.path} (${item.exists ? "exists" : "missing"})`])));
2167
+ return;
2168
+ }
2169
+
2170
+ if (!options.yes) {
2171
+ console.log("Будет удалено:");
2172
+ for (const target of safeTargets) {
2173
+ console.log(`- ${target.path}`);
2174
+ console.log(` ${target.description}`);
2175
+ }
2176
+ console.log("");
2177
+ console.log("Codex CLI и его настройки не удаляются.");
2178
+ const confirmed = await confirm("Удалить локальные данные iola-cli? [y/N] ");
2179
+ if (!confirmed) {
2180
+ console.log("Удаление отменено.");
2181
+ return;
2182
+ }
2183
+ }
2184
+
2185
+ for (const target of safeTargets) {
2186
+ await rm(target.path, { recursive: true, force: true });
2187
+ }
2188
+
2189
+ console.log("Локальные данные iola-cli удалены.");
2190
+ console.log("Codex CLI не тронут.");
2191
+ console.log("Для полной переустановки npm-пакета:");
2192
+ console.log(" npm uninstall -g @iola_adm/iola-cli");
2193
+ console.log(" npm install -g @iola_adm/iola-cli@latest");
2194
+ }
2195
+
2081
2196
  async function handleDb(args) {
2082
2197
  const [action = "status"] = args;
2083
2198
  const options = parseOptions(args);
@@ -3981,6 +4096,11 @@ async function aiSetup(args) {
3981
4096
  return;
3982
4097
  }
3983
4098
 
4099
+ if (provider === "iola") {
4100
+ await setupIolaLocal(args.slice(1));
4101
+ return;
4102
+ }
4103
+
3984
4104
  if (provider === "ollama") {
3985
4105
  await setupOllama(args.slice(1));
3986
4106
  return;
@@ -4103,8 +4223,8 @@ async function aiModels(args) {
4103
4223
  const [provider] = args;
4104
4224
  const options = parseOptions(args.slice(1));
4105
4225
 
4106
- if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
4107
- throw new Error("Провайдер обязателен: iola ai models ollama|openai|openrouter|codex");
4226
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(provider)) {
4227
+ throw new Error("Провайдер обязателен: iola ai models iola|ollama|openai|openrouter|codex");
4108
4228
  }
4109
4229
 
4110
4230
  const models = await listAiModels(provider);
@@ -4125,6 +4245,18 @@ async function aiModels(args) {
4125
4245
  }
4126
4246
 
4127
4247
  async function listAiModels(provider) {
4248
+ if (provider === "iola") {
4249
+ const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR)) || {};
4250
+ const remote = await getRemoteIolaModelRevision().catch(() => null);
4251
+ return [{
4252
+ id: IOLA_LOCAL_MODEL,
4253
+ provider: "iola",
4254
+ note: state.revision
4255
+ ? `installed ${state.revision.slice(0, 12)}${remote?.sha && remote.sha !== state.revision ? ", update available" : ""}`
4256
+ : "not installed",
4257
+ }];
4258
+ }
4259
+
4128
4260
  if (provider === "ollama") {
4129
4261
  try {
4130
4262
  const config = await loadConfig();
@@ -4198,9 +4330,9 @@ async function listAiModels(provider) {
4198
4330
 
4199
4331
  function getRecommendedOllamaModels(notePrefix = "recommended") {
4200
4332
  return [
4333
+ { id: IOLA_LOCAL_OLLAMA_MODEL, provider: "ollama", note: `${notePrefix} IOLA default low RAM` },
4201
4334
  { id: "qwen3:1.7b", provider: "ollama", note: `${notePrefix} recommended low RAM` },
4202
4335
  { id: "qwen3:4b", provider: "ollama", note: `${notePrefix} recommended balanced` },
4203
- { id: "gemma3:1b", provider: "ollama", note: `${notePrefix} Gemma low RAM` },
4204
4336
  { id: "gemma3:4b", provider: "ollama", note: `${notePrefix} Gemma balanced` },
4205
4337
  { id: "llama3.2:3b", provider: "ollama", note: `${notePrefix} legacy fallback` },
4206
4338
  { id: "llama3.2:1b", provider: "ollama", note: `${notePrefix} minimal fallback only` },
@@ -4249,8 +4381,8 @@ async function addAiProfile(name, args) {
4249
4381
  const options = parseOptions(args);
4250
4382
  const provider = options.provider;
4251
4383
 
4252
- if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
4253
- throw new Error("Провайдер должен быть ollama, openai, openrouter или codex.");
4384
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(provider)) {
4385
+ throw new Error("Провайдер должен быть iola, ollama, openai, openrouter или codex.");
4254
4386
  }
4255
4387
 
4256
4388
  const profile = buildProfileFromOptions(provider, options);
@@ -4329,7 +4461,7 @@ async function deleteAiProfile(name) {
4329
4461
  }
4330
4462
 
4331
4463
  function buildProfileFromOptions(provider, options) {
4332
- const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" ? "local" : provider];
4464
+ const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" || provider === "iola" ? "local" : provider];
4333
4465
  const profile = {
4334
4466
  ...defaults,
4335
4467
  provider,
@@ -4340,6 +4472,11 @@ function buildProfileFromOptions(provider, options) {
4340
4472
  profile.baseUrl = options["base-url"];
4341
4473
  }
4342
4474
 
4475
+ if (provider === "iola") {
4476
+ profile.repo = options.repo || defaults.repo || IOLA_ROUTER_HF_REPO;
4477
+ profile.modelDir = options["model-dir"] || defaults.modelDir || IOLA_MODEL_DIR;
4478
+ }
4479
+
4343
4480
  if (provider === "codex") {
4344
4481
  profile.sandbox = options.sandbox || defaults.sandbox || "read-only";
4345
4482
  profile.approval = options.approval || defaults.approval || "never";
@@ -4363,17 +4500,18 @@ async function useAiProvider(args) {
4363
4500
 
4364
4501
  const provider = providerOrProfile;
4365
4502
 
4366
- if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
4367
- throw new Error("Провайдер должен быть ollama, openai, openrouter, codex или именем AI-профиля.");
4503
+ if (provider !== "iola" && provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
4504
+ throw new Error("Провайдер должен быть iola, ollama, openai, openrouter, codex или именем AI-профиля.");
4368
4505
  }
4369
4506
 
4370
4507
  const defaultModel = {
4371
- ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
4508
+ iola: IOLA_LOCAL_MODEL,
4509
+ ollama: config.ai.provider === "ollama" ? config.ai.model : IOLA_LOCAL_OLLAMA_MODEL,
4372
4510
  openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
4373
4511
  openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
4374
4512
  codex: config.ai.provider === "codex" ? config.ai.model : "gpt-5.5",
4375
4513
  }[provider];
4376
- const profileName = provider === "ollama" ? "local" : provider;
4514
+ const profileName = provider === "ollama" || provider === "iola" ? "local" : provider;
4377
4515
  const profile = buildProfileFromOptions(provider, { model: defaultModel });
4378
4516
 
4379
4517
  await saveConfig({
@@ -4412,7 +4550,7 @@ async function slashModelMenu(args = []) {
4412
4550
  function normalizeModelMenuTarget(value = "") {
4413
4551
  const normalized = String(value || "").trim().toLocaleLowerCase("ru-RU");
4414
4552
  if (!normalized) return "";
4415
- if (["local", "локальная", "локально", "ollama"].includes(normalized)) return "local";
4553
+ if (["local", "локальная", "локально", "iola", "иола", "ollama"].includes(normalized)) return "local";
4416
4554
  if (["api", "апи"].includes(normalized)) return "api";
4417
4555
  if (normalized === "openai") return "openai";
4418
4556
  if (normalized === "openrouter" || normalized === "router") return "openrouter";
@@ -4422,7 +4560,7 @@ function normalizeModelMenuTarget(value = "") {
4422
4560
 
4423
4561
  async function chooseModelTarget() {
4424
4562
  console.log("Выберите AI-подключение:");
4425
- console.log(" 1. Локальная модель (Ollama)");
4563
+ console.log(" 1. Локальная модель IOLA");
4426
4564
  console.log(" 2. API (OpenAI/OpenRouter)");
4427
4565
  console.log(" 3. Codex CLI");
4428
4566
  console.log(" 0. Отмена");
@@ -4433,7 +4571,7 @@ async function chooseModelTarget() {
4433
4571
 
4434
4572
  async function openModelTargetMenu(target) {
4435
4573
  if (target === "local") {
4436
- const model = await chooseAiModel("ollama");
4574
+ const model = await chooseAiModel("iola");
4437
4575
  if (model) await switchModelTarget("local", model);
4438
4576
  return;
4439
4577
  }
@@ -4522,12 +4660,15 @@ async function chooseAiModel(provider) {
4522
4660
 
4523
4661
  async function switchModelTarget(target, model) {
4524
4662
  const config = await loadConfig();
4525
- const provider = target === "local" ? "ollama" : target;
4663
+ const provider = target === "local" ? "iola" : target;
4664
+ if (provider === "iola") {
4665
+ await ensureIolaModelFresh({ quiet: false });
4666
+ }
4526
4667
  if (provider === "ollama") {
4527
4668
  const ready = await ensureOllamaModelAvailable(model, config);
4528
4669
  if (!ready) return;
4529
4670
  }
4530
- const profileName = provider === "ollama" ? "local" : provider;
4671
+ const profileName = provider === "ollama" || provider === "iola" ? "local" : provider;
4531
4672
  const currentProfile = config.ai.profiles?.[profileName] || buildProfileFromOptions(provider, { model });
4532
4673
  const profile = {
4533
4674
  ...currentProfile,
@@ -5911,20 +6052,22 @@ function assertKeyProvider(provider) {
5911
6052
 
5912
6053
  async function chooseAiProvider() {
5913
6054
  console.log("Выберите режим AI:");
5914
- console.log("1. Локальная модель через Ollama");
6055
+ console.log("1. Локальная модель IOLA");
5915
6056
  console.log("2. OpenAI API");
5916
6057
  console.log("3. OpenRouter API");
5917
6058
  console.log("4. Codex/MCP");
6059
+ console.log("5. Ollama");
5918
6060
 
5919
6061
  const rl = readline.createInterface({ input, output });
5920
6062
  try {
5921
6063
  const answer = (await rl.question("Введите номер [1]: ")).trim() || "1";
5922
6064
  return {
5923
- 1: "ollama",
6065
+ 1: "iola",
5924
6066
  2: "openai",
5925
6067
  3: "openrouter",
5926
6068
  4: "codex",
5927
- }[answer] || "ollama";
6069
+ 5: "ollama",
6070
+ }[answer] || "iola";
5928
6071
  } finally {
5929
6072
  rl.close();
5930
6073
  }
@@ -5984,6 +6127,53 @@ async function setupOllama(args) {
5984
6127
  console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
5985
6128
  }
5986
6129
 
6130
+ async function setupIolaLocal(args) {
6131
+ const options = parseOptions(args);
6132
+ const repo = options.repo || IOLA_ROUTER_HF_REPO;
6133
+ const modelDir = options["model-dir"] || IOLA_MODEL_DIR;
6134
+ const profileName = options.name || "local";
6135
+ const optional = Boolean(options.optional);
6136
+
6137
+ if (optional && process.env.CI === "true") {
6138
+ return;
6139
+ }
6140
+
6141
+ try {
6142
+ await ensureIolaModelFresh({ repo, modelDir, force: true, quiet: Boolean(options.quiet) });
6143
+ } catch (error) {
6144
+ if (!optional) throw error;
6145
+ console.warn(`IOLA local model не установлена: ${error instanceof Error ? error.message : String(error)}`);
6146
+ }
6147
+
6148
+ const config = await loadConfig();
6149
+ await saveConfig({
6150
+ ai: {
6151
+ ...config.ai,
6152
+ activeProfile: profileName,
6153
+ provider: "iola",
6154
+ model: IOLA_LOCAL_MODEL,
6155
+ profiles: {
6156
+ ...(config.ai.profiles || {}),
6157
+ [profileName]: {
6158
+ provider: "iola",
6159
+ model: IOLA_LOCAL_MODEL,
6160
+ repo,
6161
+ modelDir,
6162
+ },
6163
+ },
6164
+ },
6165
+ });
6166
+
6167
+ if (options.quiet) return;
6168
+ console.log("");
6169
+ console.log("IOLA local mode готов:");
6170
+ console.log(` runtime: Python transformers/peft`);
6171
+ console.log(` model: ${IOLA_LOCAL_MODEL}`);
6172
+ console.log(` Hugging Face: ${repo}`);
6173
+ console.log(` cache: ${modelDir}`);
6174
+ console.log(" точные данные: https://apiiola.yasg.ru/api/v1/resolve-entity-field");
6175
+ }
6176
+
5987
6177
  async function aiAsk(args, context = {}) {
5988
6178
  const options = parseOptions(args);
5989
6179
  const question = options._.join(" ").trim();
@@ -5995,9 +6185,9 @@ async function aiAsk(args, context = {}) {
5995
6185
  const config = await loadConfig();
5996
6186
  const providerConfig = await resolveUsableAiProfile(config, options);
5997
6187
  if (providerConfig.provider === "codex") await assertPermission("codex");
5998
- if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
6188
+ if (providerConfig.provider !== "ollama" && providerConfig.provider !== "iola") await assertPermission("externalAi");
5999
6189
  if (options["stream-json"]) options.events = true;
6000
- if (options.tools && providerConfig.provider === "ollama") {
6190
+ if (providerConfig.provider === "iola" || (options.tools && providerConfig.provider === "ollama")) {
6001
6191
  return localToolAsk(question, providerConfig, options);
6002
6192
  }
6003
6193
  applyRuntimeConfig(providerConfig, options.config);
@@ -6220,14 +6410,25 @@ function resolveAiProfile(config, options = {}) {
6220
6410
  provider,
6221
6411
  model: options.model || activeProfile.model || config.ai.model,
6222
6412
  baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
6413
+ repo: options.repo || activeProfile.repo,
6414
+ modelDir: options["model-dir"] || activeProfile.modelDir,
6223
6415
  temperature: options.temperature || activeProfile.temperature,
6224
6416
  };
6225
6417
  }
6226
6418
 
6227
6419
  async function localToolAsk(question, providerConfig, options) {
6228
6420
  if (options["stream-json"]) options.events = true;
6421
+ const guarded = guardNonPublicQuestion(question);
6422
+ if (guarded) {
6423
+ if (!options.quiet) console.log(guarded);
6424
+ return guarded;
6425
+ }
6229
6426
  await ensureLocalData();
6230
6427
  const plan = await buildLocalToolPlan(question, providerConfig, options);
6428
+ if (plan.directAnswer) {
6429
+ if (!options.quiet) console.log(plan.directAnswer);
6430
+ return plan.directAnswer;
6431
+ }
6231
6432
  const validated = validateToolPlan(plan, options);
6232
6433
  if (options.plan) {
6233
6434
  printToolPlan(validated);
@@ -6267,6 +6468,14 @@ async function localToolAsk(question, providerConfig, options) {
6267
6468
  return answer;
6268
6469
  }
6269
6470
 
6471
+ function guardNonPublicQuestion(question) {
6472
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6473
+ if (/(зарплат|получа[ею]т|доход|домашн|паспорт|снилс|личн|персональн)/iu.test(normalized)) {
6474
+ return "Это поле не входит в открытые публичные данные.";
6475
+ }
6476
+ return "";
6477
+ }
6478
+
6270
6479
  function printToolPlan(plan) {
6271
6480
  console.log("План выполнения:");
6272
6481
  plan.steps.forEach((step, index) => {
@@ -6276,6 +6485,17 @@ function printToolPlan(plan) {
6276
6485
 
6277
6486
  async function buildLocalToolPlan(question, providerConfig, options) {
6278
6487
  const mode = options.reasoning || "verify";
6488
+
6489
+ if (providerConfig.provider === "iola") {
6490
+ await ensureIolaModelFresh({
6491
+ repo: providerConfig.repo || IOLA_ROUTER_HF_REPO,
6492
+ modelDir: providerConfig.modelDir || IOLA_MODEL_DIR,
6493
+ quiet: true,
6494
+ });
6495
+ const raw = await callIolaLocal(providerConfig, [{ role: "user", content: question }]);
6496
+ return normalizeIolaRouterPlan(raw, question, options);
6497
+ }
6498
+
6279
6499
  const prompt = [
6280
6500
  "Ты планировщик CLI iola. Верни только JSON.",
6281
6501
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
@@ -6298,6 +6518,27 @@ async function buildLocalToolPlan(question, providerConfig, options) {
6298
6518
  }
6299
6519
  }
6300
6520
 
6521
+ function normalizeIolaRouterPlan(raw, question, options = {}) {
6522
+ const payload = typeof raw === "string" ? parseJsonObject(raw) : raw;
6523
+ if (payload.action === "tool_call") {
6524
+ const tool = payload.tool === "get_entity_field" ? "resolve_entity_field" : payload.tool;
6525
+ return { steps: [{ tool, args: payload.args || {} }] };
6526
+ }
6527
+ if (payload.action === "direct_answer") {
6528
+ return { directAnswer: payload.answer || "" };
6529
+ }
6530
+ if (payload.action === "clarify") {
6531
+ return { directAnswer: payload.question || "Уточните запрос." };
6532
+ }
6533
+ if (payload.action === "refuse") {
6534
+ return { directAnswer: payload.reason === "field_not_public" ? "Это поле не входит в открытые публичные данные." : "Не могу выполнить этот запрос." };
6535
+ }
6536
+ if (options.reasoning === "vote") {
6537
+ return inferToolPlan(question, options);
6538
+ }
6539
+ throw new Error(`IOLA router вернул неподдерживаемое действие: ${payload.action || "unknown"}`);
6540
+ }
6541
+
6301
6542
  function parseJsonObject(text) {
6302
6543
  const match = String(text).match(/\{[\s\S]*\}/);
6303
6544
  if (!match) throw new Error("JSON-план не найден.");
@@ -6347,6 +6588,50 @@ function validateToolPlan(plan, options = {}) {
6347
6588
  return plan;
6348
6589
  }
6349
6590
 
6591
+ async function searchPublicEntities(args = {}) {
6592
+ const payload = await postJson(`${await getApiBaseUrl()}/search-entities`, {
6593
+ layer: normalizeEntityLayer(args.layer),
6594
+ query: args.query || args.entity_name || args.name || "",
6595
+ limit: Number(args.limit || 10),
6596
+ filters: args.filters || undefined,
6597
+ });
6598
+ return normalizeItems(payload).map((item) => ({
6599
+ ...(item.entity || item),
6600
+ score: item.score,
6601
+ layer: payload.layer || normalizeEntityLayer(args.layer),
6602
+ }));
6603
+ }
6604
+
6605
+ async function resolvePublicEntityField(args = {}) {
6606
+ const payload = await postJson(`${await getApiBaseUrl()}/resolve-entity-field`, {
6607
+ layer: normalizeEntityLayer(args.layer),
6608
+ entity_number: args.entity_number ?? args.number,
6609
+ entity_name: args.entity_name || args.name,
6610
+ inn: args.inn,
6611
+ field: normalizeEntityField(args.field),
6612
+ must_refute_user_value: args.must_refute_user_value,
6613
+ });
6614
+ return payload;
6615
+ }
6616
+
6617
+ function normalizeEntityLayer(layer) {
6618
+ const value = String(layer || "").toLocaleLowerCase("ru-RU");
6619
+ if (value === "school" || value === "schools" || value.includes("школ")) return "schools";
6620
+ if (value === "kindergarten" || value === "kindergartens" || value.includes("сад")) return "kindergartens";
6621
+ return value || "schools";
6622
+ }
6623
+
6624
+ function normalizeEntityField(field) {
6625
+ const value = String(field || "").toLocaleLowerCase("ru-RU");
6626
+ if (value === "director" || value === "head" || value.includes("директор") || value.includes("руковод")) return "head";
6627
+ if (value === "site" || value === "url" || value === "website" || value.includes("сайт")) return "website";
6628
+ if (value === "mail" || value === "email" || value.includes("почт")) return "email";
6629
+ if (value === "phone" || value.includes("тел")) return "phone";
6630
+ if (value === "address" || value.includes("адрес")) return "address";
6631
+ if (value === "license") return "license_status";
6632
+ return value || "name";
6633
+ }
6634
+
6350
6635
  function availableToolNames(options = {}) {
6351
6636
  const names = new Set(LOCAL_TOOLS);
6352
6637
  for (const tool of getLocalMcpToolNames()) names.add(tool);
@@ -6366,6 +6651,13 @@ async function executeToolPlan(plan, options = {}) {
6366
6651
  if (step.tool === "search_data" || step.tool === "search_local") {
6367
6652
  current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
6368
6653
  outputs.push({ tool: step.tool, rows: current.length });
6654
+ } else if (step.tool === "search_entities") {
6655
+ current = await searchPublicEntities(step.args || {});
6656
+ outputs.push({ tool: step.tool, rows: current.length });
6657
+ } else if (step.tool === "resolve_entity_field") {
6658
+ const resolved = await resolvePublicEntityField(step.args || {});
6659
+ current = Array.isArray(resolved) ? resolved : [resolved];
6660
+ outputs.push({ tool: step.tool, rows: current.length });
6369
6661
  } else if (step.tool === "get_card") {
6370
6662
  const card = findCard(step.args?.query || "");
6371
6663
  current = card ? [card] : [];
@@ -6514,7 +6806,13 @@ function formatToolResult(result, options) {
6514
6806
  const exported = result.outputs.find((item) => item.output);
6515
6807
  if (exported) return `Готово. Файл сохранен: ${exported.output}. Записей: ${exported.rows}`;
6516
6808
  if (!result.rows.length) return "Данных не найдено.";
6517
- return result.rows.slice(0, 10).map((row) => `${row.name || row.check}: ${row.address || row.count || ""}`).join("\n");
6809
+ return result.rows.slice(0, 10).map((row) => {
6810
+ if (row.ok && row.entity && row.field) {
6811
+ const name = row.entity.name || row.entity.inn || "организация";
6812
+ return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
6813
+ }
6814
+ return `${row.name || row.check || row.inn || "строка"}: ${row.address || row.phone || row.email || row.website || row.count || ""}`;
6815
+ }).join("\n");
6518
6816
  }
6519
6817
 
6520
6818
  function applyRuntimeConfig(target, value) {
@@ -6906,6 +7204,10 @@ function buildSourceLines(dataContext) {
6906
7204
  }
6907
7205
 
6908
7206
  async function callAiProvider(config, messages) {
7207
+ if (config.provider === "iola") {
7208
+ return callIolaLocal(config, messages);
7209
+ }
7210
+
6909
7211
  if (config.provider === "ollama") {
6910
7212
  return callOllama(config, messages);
6911
7213
  }
@@ -6925,6 +7227,155 @@ async function callAiProvider(config, messages) {
6925
7227
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
6926
7228
  }
6927
7229
 
7230
+ async function callIolaLocal(config, messages) {
7231
+ const runtime = await ensureIolaModelRuntime({ quiet: true });
7232
+ const repo = config.repo || IOLA_ROUTER_HF_REPO;
7233
+ const modelDir = config.modelDir || IOLA_MODEL_DIR;
7234
+ const payload = {
7235
+ repo,
7236
+ cache_dir: modelDir,
7237
+ messages,
7238
+ max_new_tokens: Number(config.maxNewTokens || 180),
7239
+ temperature: Number(config.temperature ?? 0),
7240
+ };
7241
+ const { stdout, stderr } = await runCommand(runtime.python, [IOLA_MODEL_RUNNER], {
7242
+ input: JSON.stringify(payload),
7243
+ env: {
7244
+ IOLA_ROUTER_HF_REPO: repo,
7245
+ IOLA_MODEL_DIR: modelDir,
7246
+ },
7247
+ });
7248
+ const text = stdout.trim();
7249
+ if (!text) {
7250
+ throw new Error(`IOLA local model вернула пустой ответ.${stderr ? `\n${stderr}` : ""}`);
7251
+ }
7252
+ return text;
7253
+ }
7254
+
7255
+ async function hasUsableIolaModel() {
7256
+ const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR));
7257
+ return Boolean(state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
7258
+ }
7259
+
7260
+ async function ensureIolaModelFresh(options = {}) {
7261
+ const repo = options.repo || IOLA_ROUTER_HF_REPO;
7262
+ const modelDir = options.modelDir || IOLA_MODEL_DIR;
7263
+ await mkdir(modelDir, { recursive: true });
7264
+ const stateFile = getIolaModelStateFile(modelDir);
7265
+ const state = readConfigLayerSync(stateFile) || {};
7266
+ const remote = await getRemoteIolaModelRevision(repo).catch(() => null);
7267
+ const stale = options.force || state.repo !== repo || !state.revision || (remote?.sha && remote.sha !== state.revision);
7268
+ if (!stale) return state;
7269
+
7270
+ if (!options.quiet) {
7271
+ const reason = state.revision ? "обновляю" : "устанавливаю";
7272
+ console.log(`IOLA local model: ${reason} ${repo}`);
7273
+ console.log("Загрузка первой установки может занять несколько минут.");
7274
+ }
7275
+
7276
+ const runtime = await ensureIolaModelRuntime({ quiet: options.quiet });
7277
+ const { stdout } = await runCommand(runtime.python, [IOLA_MODEL_RUNNER, "--ensure"], {
7278
+ input: JSON.stringify({ repo, cache_dir: modelDir }),
7279
+ env: {
7280
+ IOLA_ROUTER_HF_REPO: repo,
7281
+ IOLA_MODEL_DIR: modelDir,
7282
+ },
7283
+ });
7284
+ const installed = parseJsonObject(stdout || "{}");
7285
+ const nextState = {
7286
+ repo,
7287
+ revision: installed.revision || remote?.sha || state.revision || `local-${Date.now()}`,
7288
+ installedAt: new Date().toISOString(),
7289
+ runtime: "transformers",
7290
+ };
7291
+ await mkdir(modelDir, { recursive: true });
7292
+ await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
7293
+ return nextState;
7294
+ }
7295
+
7296
+ function getIolaModelStateFile(modelDir = IOLA_MODEL_DIR) {
7297
+ return path.join(modelDir, "manifest.json");
7298
+ }
7299
+
7300
+ async function ensureIolaModelRuntime(options = {}) {
7301
+ const python = await getIolaRuntimePython();
7302
+ if (python && await checkIolaPythonDeps(python)) return { python };
7303
+
7304
+ const basePython = await findPythonCommand();
7305
+ if (!basePython) {
7306
+ throw new Error("Python не найден. Установите Python 3.11+ и повторите: iola ai setup iola --yes");
7307
+ }
7308
+
7309
+ await mkdir(IOLA_MODEL_RUNTIME_DIR, { recursive: true });
7310
+ if (!python) {
7311
+ if (!options.quiet) console.log(`Создаю Python runtime: ${IOLA_MODEL_RUNTIME_DIR}`);
7312
+ await runCommand(basePython.command, [...basePython.args, "-m", "venv", IOLA_MODEL_RUNTIME_DIR], { inherit: !options.quiet });
7313
+ }
7314
+
7315
+ const runtimePython = await getIolaRuntimePython();
7316
+ if (!runtimePython) {
7317
+ throw new Error("Не удалось создать Python runtime для локальной модели.");
7318
+ }
7319
+
7320
+ if (!options.quiet) console.log("Устанавливаю зависимости локальной модели: torch, transformers, peft.");
7321
+ await runCommand(runtimePython, ["-m", "pip", "install", "--upgrade", "pip"], { inherit: !options.quiet });
7322
+ await runCommand(runtimePython, ["-m", "pip", "install", "torch>=2.6.0", "transformers>=4.57.0,<5.0", "peft>=0.15.0", "accelerate>=1.8.0", "huggingface_hub>=0.34.0,<1.0", "hf_xet>=1.1.0", "safetensors>=0.4.0"], { inherit: !options.quiet });
7323
+
7324
+ if (!await checkIolaPythonDeps(runtimePython)) {
7325
+ throw new Error("Python-зависимости локальной модели не установились.");
7326
+ }
7327
+ return { python: runtimePython };
7328
+ }
7329
+
7330
+ async function getIolaRuntimePython() {
7331
+ const candidate = process.platform === "win32"
7332
+ ? path.join(IOLA_MODEL_RUNTIME_DIR, "Scripts", "python.exe")
7333
+ : path.join(IOLA_MODEL_RUNTIME_DIR, "bin", "python");
7334
+ return existsSync(candidate) ? candidate : null;
7335
+ }
7336
+
7337
+ async function checkIolaPythonDeps(python) {
7338
+ try {
7339
+ await runCommand(python, [IOLA_MODEL_RUNNER, "--check-deps"]);
7340
+ return true;
7341
+ } catch {
7342
+ return false;
7343
+ }
7344
+ }
7345
+
7346
+ async function findPythonCommand() {
7347
+ const candidates = [
7348
+ { command: process.env.IOLA_PYTHON, args: [] },
7349
+ { command: "python", args: [] },
7350
+ { command: "python3", args: [] },
7351
+ { command: "py", args: ["-3"] },
7352
+ ].filter((item) => item.command);
7353
+
7354
+ for (const candidate of candidates) {
7355
+ try {
7356
+ await runCommand(candidate.command, [...candidate.args, "--version"]);
7357
+ return candidate;
7358
+ } catch {
7359
+ // Try next candidate.
7360
+ }
7361
+ }
7362
+ return null;
7363
+ }
7364
+
7365
+ async function getRemoteIolaModelRevision(repo = IOLA_ROUTER_HF_REPO) {
7366
+ const repoPath = String(repo).split("/").map((part) => encodeURIComponent(part)).join("/");
7367
+ const response = await fetch(`https://huggingface.co/api/models/${repoPath}`, {
7368
+ headers: {
7369
+ accept: "application/json",
7370
+ "user-agent": "@iola_adm/iola-cli",
7371
+ },
7372
+ signal: AbortSignal.timeout(5000),
7373
+ });
7374
+ if (!response.ok) throw new Error(`Hugging Face model metadata failed: ${response.status} ${response.statusText}`);
7375
+ const payload = await response.json();
7376
+ return { sha: payload.sha || payload.lastModified || "", lastModified: payload.lastModified || "" };
7377
+ }
7378
+
6928
7379
  async function callCodex(config, messages) {
6929
7380
  const prompt = messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
6930
7381
  const outputFile = path.join(os.tmpdir(), `iola-codex-${process.pid}-${Date.now()}.txt`);
@@ -7262,6 +7713,9 @@ async function onboard(args = []) {
7262
7713
  if (components.includes("workspace")) await handleWorkspace(["init"]);
7263
7714
  if (components.includes("policy")) await handlePolicy(["use", "analyst"]);
7264
7715
  if (components.includes("archive")) await ensureArchiveTool({ install: true });
7716
+ if (components.includes("iola")) {
7717
+ await setupIolaLocal(["--yes"]);
7718
+ }
7265
7719
  if (components.includes("ollama")) {
7266
7720
  await installOllamaIfMissing();
7267
7721
  await setupOllama(["--yes"]);
@@ -7309,7 +7763,7 @@ async function chooseOnboardComponents(status = null) {
7309
7763
  const map = {
7310
7764
  1: "workspace",
7311
7765
  2: "policy",
7312
- 3: "ollama",
7766
+ 3: "iola",
7313
7767
  4: "openai",
7314
7768
  5: "openrouter",
7315
7769
  6: "codex",
@@ -7317,6 +7771,7 @@ async function chooseOnboardComponents(status = null) {
7317
7771
  8: "archive",
7318
7772
  9: "index",
7319
7773
  10: "browser",
7774
+ 11: "ollama",
7320
7775
  };
7321
7776
  return [...selected].map((item) => map[item] || item).filter(Boolean);
7322
7777
  } finally {
@@ -7338,6 +7793,7 @@ async function getOnboardComponentStatus() {
7338
7793
  return {
7339
7794
  workspace: workspaceReady,
7340
7795
  policy: policyReady,
7796
+ iola: Boolean(readiness.iola),
7341
7797
  ollama: Boolean(ollamaVersion && readiness.ollama),
7342
7798
  openai: Boolean(readiness.openai),
7343
7799
  openrouter: Boolean(readiness.openrouter),
@@ -7353,7 +7809,7 @@ function onboardComponentRows(status) {
7353
7809
  const rows = [
7354
7810
  ["1", "workspace", "workspace и контекст", "рабочая папка, IOLA.md и .iola/context.md"],
7355
7811
  ["2", "policy", "policy analyst", "разрешения и профиль аналитика"],
7356
- ["3", "ollama", "Ollama + локальная модель", "локальная модель найдена"],
7812
+ ["3", "iola", "IOLA локальная модель", "локальная модель найдена"],
7357
7813
  ["4", "openai", "OpenAI API", "API-ключ сохранен или есть в env"],
7358
7814
  ["5", "openrouter", "OpenRouter API", "API-ключ сохранен или есть в env"],
7359
7815
  ["6", "codex", "Codex CLI", "CLI установлен и авторизация найдена"],
@@ -7361,6 +7817,7 @@ function onboardComponentRows(status) {
7361
7817
  ["8", "archive", "7-Zip / архивы", "архиватор найден"],
7362
7818
  ["9", "index", "Индекс локальных документов", "настраивается под выбранную папку"],
7363
7819
  ["10", "browser", "Browser runtime", "Playwright/Chromium установлен"],
7820
+ ["11", "ollama", "Ollama", "опциональный локальный runtime"],
7364
7821
  ];
7365
7822
  return rows.map(([number, key, title, hint]) => ({ number, key, title, hint, status: status[key] ? "готово" : "не настроено" }));
7366
7823
  }
@@ -7369,12 +7826,13 @@ function defaultOnboardSelection(status) {
7369
7826
  const defaults = [];
7370
7827
  if (!status.workspace) defaults.push("1");
7371
7828
  if (!status.policy) defaults.push("2");
7829
+ if (!status.iola) defaults.push("3");
7372
7830
  if (!status.archive) defaults.push("8");
7373
7831
  return defaults.length ? defaults : ["1", "2"];
7374
7832
  }
7375
7833
 
7376
7834
  function defaultOnboardComponents(status) {
7377
- const map = { 1: "workspace", 2: "policy", 3: "ollama", 4: "openai", 5: "openrouter", 6: "codex", 7: "codex-mcp", 8: "archive", 9: "index", 10: "browser" };
7835
+ const map = { 1: "workspace", 2: "policy", 3: "iola", 4: "openai", 5: "openrouter", 6: "codex", 7: "codex-mcp", 8: "archive", 9: "index", 10: "browser", 11: "ollama" };
7378
7836
  return defaultOnboardSelection(status).map((item) => map[item]).filter(Boolean);
7379
7837
  }
7380
7838
 
@@ -7383,12 +7841,12 @@ function parseOptions(args) {
7383
7841
 
7384
7842
  for (let index = 0; index < args.length; index += 1) {
7385
7843
  const arg = args[index];
7386
- 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 === "--full" || arg === "--unread" || arg === "--once" || 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") {
7844
+ 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 === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--optional" || arg === "--project" || arg === "--dry-run" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
7387
7845
  result[arg.slice(2)] = true;
7388
7846
  } else if (arg === "--check" || arg === "--upgrade-node") {
7389
7847
  result.check = true;
7390
7848
  result[arg.slice(2)] = true;
7391
- } 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 === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file") {
7849
+ } 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 === "--repo" || arg === "--model-dir" || 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 === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file") {
7392
7850
  result[arg.slice(2)] = args[index + 1];
7393
7851
  index += 1;
7394
7852
  } else {
@@ -8663,7 +9121,7 @@ function recordUsage({ providerConfig, question, answer, sessionId, profile }) {
8663
9121
  }
8664
9122
 
8665
9123
  function estimateCost(providerConfig, tokens) {
8666
- if (!providerConfig || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
9124
+ if (!providerConfig || providerConfig.provider === "iola" || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
8667
9125
  const perMillion = providerConfig.provider === "openrouter" ? 0.25 : 0.4;
8668
9126
  return Math.round((tokens / 1_000_000) * perMillion * 1_000_000) / 1_000_000;
8669
9127
  }
@@ -9340,8 +9798,8 @@ function validateConfig(config) {
9340
9798
  if (!config.ai?.profiles || typeof config.ai.profiles !== "object") errors.push("ai.profiles обязателен");
9341
9799
  if (config.ai?.activeProfile && !config.ai.profiles?.[config.ai.activeProfile]) errors.push(`ai.activeProfile не найден в profiles: ${config.ai.activeProfile}`);
9342
9800
  for (const [name, profile] of Object.entries(config.ai?.profiles || {})) {
9343
- if (!["ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
9344
- if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
9801
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
9802
+ if (profile.provider !== "codex" && profile.provider !== "iola" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
9345
9803
  }
9346
9804
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
9347
9805
  if (!ALL_TOOL_ALIASES.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
@@ -9358,7 +9816,7 @@ function configSchema() {
9358
9816
  required: ["api", "ai"],
9359
9817
  properties: {
9360
9818
  api: { required: ["baseUrl", "mcpBaseUrl"] },
9361
- ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
9819
+ ai: { required: ["activeProfile", "profiles"], providers: ["iola", "ollama", "openai", "openrouter", "codex"] },
9362
9820
  permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
9363
9821
  toolsets: { available: Object.keys(TOOLSETS) },
9364
9822
  files: { modes: ["locked", "read-only", "workspace-write", "full-access"], approvals: ["never", "on-write", "on-danger", "always"] },
@@ -9373,7 +9831,7 @@ function getActiveProfileName(config) {
9373
9831
  return config.ai.activeProfile;
9374
9832
  }
9375
9833
 
9376
- const provider = config.ai.provider === "ollama" ? "local" : config.ai.provider;
9834
+ const provider = config.ai.provider === "ollama" || config.ai.provider === "iola" ? "local" : config.ai.provider;
9377
9835
  if (provider && config.ai.profiles?.[provider]) {
9378
9836
  return provider;
9379
9837
  }
@@ -9560,6 +10018,25 @@ async function fetchJson(url) {
9560
10018
  return response.json();
9561
10019
  }
9562
10020
 
10021
+ async function postJson(url, payload) {
10022
+ const response = await fetch(url, {
10023
+ method: "POST",
10024
+ headers: {
10025
+ accept: "application/json",
10026
+ "content-type": "application/json",
10027
+ "user-agent": "@iola_adm/iola-cli",
10028
+ },
10029
+ body: JSON.stringify(payload),
10030
+ });
10031
+
10032
+ if (!response.ok) {
10033
+ const text = await response.text().catch(() => "");
10034
+ throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})${text ? `\n${text}` : ""}`);
10035
+ }
10036
+
10037
+ return response.json();
10038
+ }
10039
+
9563
10040
  function parseJsonOrSse(text) {
9564
10041
  const trimmed = String(text || "").trim();
9565
10042
  if (!trimmed) return null;
@@ -0,0 +1,136 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ if hasattr(sys.stdout, "reconfigure"):
7
+ sys.stdout.reconfigure(encoding="utf-8")
8
+ if hasattr(sys.stderr, "reconfigure"):
9
+ sys.stderr.reconfigure(encoding="utf-8")
10
+
11
+
12
+ def check_deps():
13
+ import torch # noqa: F401
14
+ import transformers # noqa: F401
15
+ import peft # noqa: F401
16
+ import huggingface_hub # noqa: F401
17
+ import hf_xet # noqa: F401
18
+
19
+
20
+ def load_payload():
21
+ raw = sys.stdin.buffer.read().decode("utf-8").strip()
22
+ if not raw:
23
+ return {}
24
+ return json.loads(raw)
25
+
26
+
27
+ def model_revision(repo, token):
28
+ from huggingface_hub import HfApi
29
+
30
+ try:
31
+ info = HfApi(token=token).model_info(repo)
32
+ return getattr(info, "sha", "") or ""
33
+ except Exception:
34
+ return ""
35
+
36
+
37
+ def load_model(repo, cache_dir, token):
38
+ import torch
39
+ from peft import PeftConfig, PeftModel
40
+ from transformers import AutoModelForCausalLM, AutoTokenizer
41
+
42
+ peft_config = None
43
+ base_model = repo
44
+ try:
45
+ peft_config = PeftConfig.from_pretrained(repo, cache_dir=cache_dir, token=token)
46
+ base_model = peft_config.base_model_name_or_path
47
+ except Exception:
48
+ peft_config = None
49
+
50
+ tokenizer = AutoTokenizer.from_pretrained(base_model, cache_dir=cache_dir, token=token)
51
+ if tokenizer.pad_token is None:
52
+ tokenizer.pad_token = tokenizer.eos_token
53
+
54
+ dtype = torch.float16 if torch.cuda.is_available() else (torch.bfloat16 if peft_config is not None else torch.float32)
55
+ model = AutoModelForCausalLM.from_pretrained(
56
+ base_model,
57
+ cache_dir=cache_dir,
58
+ token=token,
59
+ torch_dtype=dtype,
60
+ device_map="auto" if torch.cuda.is_available() else None,
61
+ attn_implementation="sdpa",
62
+ )
63
+ if peft_config is not None:
64
+ model = PeftModel.from_pretrained(model, repo, cache_dir=cache_dir, token=token)
65
+ model = model.merge_and_unload()
66
+
67
+ model.eval()
68
+ return tokenizer, model
69
+
70
+
71
+ def ensure_model(payload):
72
+ repo = payload.get("repo") or os.environ.get("IOLA_ROUTER_HF_REPO") or "LMSerg/iola-1b-router-2026-05-28-merged"
73
+ cache_dir = payload.get("cache_dir") or os.environ.get("IOLA_MODEL_DIR")
74
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
75
+ if cache_dir:
76
+ os.makedirs(cache_dir, exist_ok=True)
77
+
78
+ tokenizer, model = load_model(repo, cache_dir, token)
79
+ del tokenizer
80
+ del model
81
+ print(json.dumps({"repo": repo, "revision": model_revision(repo, token)}, ensure_ascii=False))
82
+
83
+
84
+ def generate(payload):
85
+ repo = payload.get("repo") or os.environ.get("IOLA_ROUTER_HF_REPO") or "LMSerg/iola-1b-router-2026-05-28-merged"
86
+ cache_dir = payload.get("cache_dir") or os.environ.get("IOLA_MODEL_DIR")
87
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
88
+ messages = payload.get("messages") or []
89
+ max_new_tokens = int(payload.get("max_new_tokens") or 180)
90
+ temperature = float(payload.get("temperature") or 0)
91
+
92
+ tokenizer, model = load_model(repo, cache_dir, token)
93
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
94
+ encoded = tokenizer(prompt, return_tensors="pt").to(model.device)
95
+
96
+ generate_kwargs = {
97
+ **encoded,
98
+ "max_new_tokens": max_new_tokens,
99
+ "do_sample": temperature > 0,
100
+ "pad_token_id": tokenizer.pad_token_id,
101
+ "eos_token_id": tokenizer.eos_token_id,
102
+ }
103
+ if temperature > 0:
104
+ generate_kwargs["temperature"] = temperature
105
+
106
+ import torch
107
+
108
+ with torch.no_grad():
109
+ output = model.generate(**generate_kwargs)
110
+
111
+ answer_ids = output[0][encoded["input_ids"].shape[1]:]
112
+ answer = tokenizer.decode(answer_ids, skip_special_tokens=True).strip()
113
+ print(answer)
114
+
115
+
116
+ def main():
117
+ parser = argparse.ArgumentParser()
118
+ parser.add_argument("--check-deps", action="store_true")
119
+ parser.add_argument("--ensure", action="store_true")
120
+ args = parser.parse_args()
121
+
122
+ if args.check_deps:
123
+ check_deps()
124
+ print("ok")
125
+ return
126
+
127
+ payload = load_payload()
128
+ if args.ensure:
129
+ ensure_model(payload)
130
+ return
131
+
132
+ generate(payload)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -48,6 +48,7 @@ assertIncludes(help, "iola ask", "help");
48
48
  const commands = await runCli(["commands"]);
49
49
  assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
50
50
  assertIncludes(commands, "iola mcp list|status|install|remove|serve [--stdio]", "commands");
51
+ assertIncludes(commands, "iola uninstall --yes", "commands");
51
52
  assertNotIncludes(commands, "Госуслуг", "commands");
52
53
  assertNotIncludes(commands, "gosuslugi", "commands");
53
54
 
@@ -61,4 +62,9 @@ assertIncludes(skills, "open-data", "skills list");
61
62
  assertIncludes(skills, "reports", "skills list");
62
63
  assertNotIncludes(skills, "gosuslugi", "skills list");
63
64
 
65
+ const uninstallPlan = JSON.parse(await runCli(["uninstall", "--dry-run", "--json"]));
66
+ if (!Array.isArray(uninstallPlan.willDelete) || !uninstallPlan.willKeep.includes("Codex CLI")) {
67
+ throw new Error("uninstall dry-run should list delete targets and keep Codex CLI");
68
+ }
69
+
64
70
  console.log("smoke tests passed");