@iola_adm/iola-cli 0.1.84 → 0.1.86

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.
Files changed (35) hide show
  1. package/README.md +13 -5
  2. package/package.json +2 -3
  3. package/src/cli.js +448 -48
  4. package/src/iola_hf_runner.py +136 -0
  5. package/experiments/small-model-concepts/README.md +0 -34
  6. package/experiments/small-model-concepts/concepts/agent-consensus/README.md +0 -25
  7. package/experiments/small-model-concepts/concepts/hybrid/README.md +0 -23
  8. package/experiments/small-model-concepts/concepts/model-architecture/README.md +0 -42
  9. package/experiments/small-model-concepts/datasets/adversarial-facts.jsonl +0 -100
  10. package/experiments/small-model-concepts/datasets/simple-facts.jsonl +0 -100
  11. package/experiments/small-model-concepts/lib/common.js +0 -192
  12. package/experiments/small-model-concepts/lib/concepts.js +0 -210
  13. package/experiments/small-model-concepts/results/latest/conditional-memory-adversarial-facts.jsonl +0 -100
  14. package/experiments/small-model-concepts/results/latest/conditional-memory-simple-facts.jsonl +0 -100
  15. package/experiments/small-model-concepts/results/latest/council-adversarial-facts.jsonl +0 -100
  16. package/experiments/small-model-concepts/results/latest/council-simple-facts.jsonl +0 -100
  17. package/experiments/small-model-concepts/results/latest/early-exit-adversarial-facts.jsonl +0 -100
  18. package/experiments/small-model-concepts/results/latest/early-exit-simple-facts.jsonl +0 -100
  19. package/experiments/small-model-concepts/results/latest/escalation-ladder-adversarial-facts.jsonl +0 -100
  20. package/experiments/small-model-concepts/results/latest/escalation-ladder-simple-facts.jsonl +0 -100
  21. package/experiments/small-model-concepts/results/latest/memory-verified-adversarial-facts.jsonl +0 -100
  22. package/experiments/small-model-concepts/results/latest/memory-verified-simple-facts.jsonl +0 -100
  23. package/experiments/small-model-concepts/results/latest/skill-router-adversarial-facts.jsonl +0 -100
  24. package/experiments/small-model-concepts/results/latest/skill-router-simple-facts.jsonl +0 -100
  25. package/experiments/small-model-concepts/results/latest/sparse-escalation-adversarial-facts.jsonl +0 -100
  26. package/experiments/small-model-concepts/results/latest/sparse-escalation-simple-facts.jsonl +0 -100
  27. package/experiments/small-model-concepts/results/latest/strict-skill-adversarial-facts.jsonl +0 -100
  28. package/experiments/small-model-concepts/results/latest/strict-skill-simple-facts.jsonl +0 -100
  29. package/experiments/small-model-concepts/results/latest/summary.json +0 -313
  30. package/experiments/small-model-concepts/results/latest/verify-adversarial-facts.jsonl +0 -100
  31. package/experiments/small-model-concepts/results/latest/verify-simple-facts.jsonl +0 -100
  32. package/experiments/small-model-concepts/results/latest-summary.json +0 -313
  33. package/experiments/small-model-concepts/scripts/generate-datasets.js +0 -199
  34. package/experiments/small-model-concepts/scripts/run-evaluation.js +0 -133
  35. package/experiments/small-model-concepts/scripts/summarize-results.js +0 -19
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,
@@ -434,6 +442,8 @@ export async function main(argv) {
434
442
  throw new Error(`Нужен Node.js ${MIN_NODE_VERSION} или новее. Сейчас: ${nodeStatus.current}. Запустите: iola init --upgrade-node`);
435
443
  }
436
444
 
445
+ await maybeRefreshIolaModelForCommand(command, args);
446
+
437
447
  const handler = COMMANDS.get(command);
438
448
 
439
449
  if (!handler) {
@@ -448,6 +458,23 @@ export async function main(argv) {
448
458
  await handler(runtime.debugFile ? [...args, "--debug-file", runtime.debugFile] : args);
449
459
  }
450
460
 
461
+ async function maybeRefreshIolaModelForCommand(command, args = []) {
462
+ if (process.env.IOLA_SKIP_MODEL_CHECK === "1") return;
463
+ const aiRuntimeCommands = new Set(["ask", "agent", "chat"]);
464
+ const isAiCommand = command === "ai" && !["setup", "models", "key", "profile", "profiles", "doctor"].includes(args[0]);
465
+ if (!aiRuntimeCommands.has(command) && !isAiCommand) return;
466
+ const config = await loadConfig();
467
+ const profile = config.ai.profiles?.[getActiveProfileName(config)];
468
+ if (profile?.provider !== "iola" && config.ai.provider !== "iola") return;
469
+ await ensureIolaModelFresh({
470
+ repo: profile?.repo || IOLA_ROUTER_HF_REPO,
471
+ modelDir: profile?.modelDir || IOLA_MODEL_DIR,
472
+ quiet: true,
473
+ }).catch((error) => {
474
+ if (process.env.IOLA_DEBUG) console.error(error instanceof Error ? error.message : String(error));
475
+ });
476
+ }
477
+
451
478
  async function showHelp() {
452
479
  await showBanner();
453
480
  console.log(`iola - CLI и AI-агент городского округа "Город Йошкар-Ола"
@@ -552,7 +579,7 @@ Usage:
552
579
  iola update
553
580
  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
581
  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]
582
+ iola ai ask TEXT [--provider iola|ollama|openai|openrouter] [--model MODEL]
556
583
  iola ai context TEXT [--json]
557
584
  iola ai key set openai
558
585
  iola ai key set openrouter
@@ -562,9 +589,10 @@ Usage:
562
589
  iola ai profile add NAME --provider PROVIDER --model MODEL
563
590
  iola ai profile use NAME
564
591
  iola ai profile delete NAME
565
- iola ai models ollama|openai|openrouter|codex [--search TEXT]
592
+ iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
566
593
  iola ai doctor [--json]
567
594
  iola ai setup
595
+ iola ai setup iola [--yes]
568
596
  iola ai setup ollama [--yes] [--model MODEL]
569
597
  iola health [--json]
570
598
  iola layers [--json]
@@ -682,9 +710,11 @@ async function getAiReadiness() {
682
710
  hasUsableOllamaModel(),
683
711
  hasUsableCodexAuth(),
684
712
  ]);
713
+ const iola = await hasUsableIolaModel();
685
714
  const openai = Boolean(process.env.OPENAI_API_KEY || secrets.openai?.apiKey);
686
715
  const openrouter = Boolean(process.env.OPENROUTER_API_KEY || secrets.openrouter?.apiKey);
687
716
  const providerReady = {
717
+ iola,
688
718
  ollama,
689
719
  openai,
690
720
  openrouter,
@@ -695,8 +725,9 @@ async function getAiReadiness() {
695
725
  activeProfile: activeProfileName,
696
726
  activeProvider: activeProfile.provider || "-",
697
727
  activeModel: activeProfile.model || "-",
698
- anyReady: Boolean(ollama || openai || openrouter || codex),
728
+ anyReady: Boolean(iola || ollama || openai || openrouter || codex),
699
729
  profiles: config.ai.profiles || {},
730
+ iola,
700
731
  ollama,
701
732
  openai,
702
733
  openrouter,
@@ -705,7 +736,7 @@ async function getAiReadiness() {
705
736
  }
706
737
 
707
738
  function getFallbackAiProfile(readiness) {
708
- const priority = ["openai", "openrouter", "codex", "ollama"];
739
+ const priority = ["iola", "openai", "openrouter", "codex", "ollama"];
709
740
  for (const provider of priority) {
710
741
  if (!readiness[provider]) continue;
711
742
  const entry = Object.entries(readiness.profiles || {}).find(([, profile]) => profile.provider === provider);
@@ -1500,6 +1531,7 @@ function buildAgentStatusLine(state) {
1500
1531
  const ai = state.aiStatus;
1501
1532
  if (!ai) return cwd;
1502
1533
  const kind = {
1534
+ iola: "IOLA local",
1503
1535
  ollama: "локальная",
1504
1536
  openai: "API",
1505
1537
  openrouter: "API",
@@ -1885,6 +1917,10 @@ async function upgradeNodeWithInstaller() {
1885
1917
  }
1886
1918
 
1887
1919
  async function checkConfiguredModel(config) {
1920
+ if (config.ai.provider === "iola") {
1921
+ return await hasUsableIolaModel() ? "installed" : "missing";
1922
+ }
1923
+
1888
1924
  if (config.ai.provider !== "ollama") {
1889
1925
  return "external-api";
1890
1926
  }
@@ -1934,6 +1970,7 @@ async function initCli(args = []) {
1934
1970
  if (!process.stdin.isTTY || options.yes) {
1935
1971
  console.log("");
1936
1972
  console.log("Для настройки AI используйте:");
1973
+ console.log(" iola ai setup iola --yes");
1937
1974
  console.log(" iola ai setup ollama");
1938
1975
  console.log(" iola ai key set openai");
1939
1976
  console.log(" iola ai setup openai --model gpt-4.1-mini");
@@ -1957,19 +1994,20 @@ async function handleAi(args) {
1957
1994
  if (subcommand === "help") {
1958
1995
  await showBanner();
1959
1996
  console.log(`AI-команды:
1960
- iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
1997
+ iola ai ask TEXT [--provider iola|ollama|openai|openrouter] [--model MODEL]
1961
1998
  iola ai context TEXT [--json]
1962
1999
  iola ai key set openai
1963
2000
  iola ai key set openrouter
1964
2001
  iola ai key status
1965
2002
  iola ai key delete openai|openrouter
1966
2003
  iola ai profiles
1967
- iola ai profile add NAME --provider ollama|openai|openrouter|codex --model MODEL
2004
+ iola ai profile add NAME --provider iola|ollama|openai|openrouter|codex --model MODEL
1968
2005
  iola ai profile use NAME
1969
2006
  iola ai profile delete NAME
1970
- iola ai models ollama|openai|openrouter|codex [--search TEXT]
2007
+ iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
1971
2008
  iola ai doctor [--json]
1972
2009
  iola ai setup
2010
+ iola ai setup iola [--yes]
1973
2011
  iola ai setup ollama [--yes] [--model MODEL]
1974
2012
  iola ai setup openai [--model MODEL]
1975
2013
  iola ai setup openrouter [--model MODEL]
@@ -3981,6 +4019,11 @@ async function aiSetup(args) {
3981
4019
  return;
3982
4020
  }
3983
4021
 
4022
+ if (provider === "iola") {
4023
+ await setupIolaLocal(args.slice(1));
4024
+ return;
4025
+ }
4026
+
3984
4027
  if (provider === "ollama") {
3985
4028
  await setupOllama(args.slice(1));
3986
4029
  return;
@@ -4103,8 +4146,8 @@ async function aiModels(args) {
4103
4146
  const [provider] = args;
4104
4147
  const options = parseOptions(args.slice(1));
4105
4148
 
4106
- if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
4107
- throw new Error("Провайдер обязателен: iola ai models ollama|openai|openrouter|codex");
4149
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(provider)) {
4150
+ throw new Error("Провайдер обязателен: iola ai models iola|ollama|openai|openrouter|codex");
4108
4151
  }
4109
4152
 
4110
4153
  const models = await listAiModels(provider);
@@ -4125,6 +4168,18 @@ async function aiModels(args) {
4125
4168
  }
4126
4169
 
4127
4170
  async function listAiModels(provider) {
4171
+ if (provider === "iola") {
4172
+ const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR)) || {};
4173
+ const remote = await getRemoteIolaModelRevision().catch(() => null);
4174
+ return [{
4175
+ id: IOLA_LOCAL_MODEL,
4176
+ provider: "iola",
4177
+ note: state.revision
4178
+ ? `installed ${state.revision.slice(0, 12)}${remote?.sha && remote.sha !== state.revision ? ", update available" : ""}`
4179
+ : "not installed",
4180
+ }];
4181
+ }
4182
+
4128
4183
  if (provider === "ollama") {
4129
4184
  try {
4130
4185
  const config = await loadConfig();
@@ -4198,9 +4253,9 @@ async function listAiModels(provider) {
4198
4253
 
4199
4254
  function getRecommendedOllamaModels(notePrefix = "recommended") {
4200
4255
  return [
4256
+ { id: IOLA_LOCAL_OLLAMA_MODEL, provider: "ollama", note: `${notePrefix} IOLA default low RAM` },
4201
4257
  { id: "qwen3:1.7b", provider: "ollama", note: `${notePrefix} recommended low RAM` },
4202
4258
  { id: "qwen3:4b", provider: "ollama", note: `${notePrefix} recommended balanced` },
4203
- { id: "gemma3:1b", provider: "ollama", note: `${notePrefix} Gemma low RAM` },
4204
4259
  { id: "gemma3:4b", provider: "ollama", note: `${notePrefix} Gemma balanced` },
4205
4260
  { id: "llama3.2:3b", provider: "ollama", note: `${notePrefix} legacy fallback` },
4206
4261
  { id: "llama3.2:1b", provider: "ollama", note: `${notePrefix} minimal fallback only` },
@@ -4249,8 +4304,8 @@ async function addAiProfile(name, args) {
4249
4304
  const options = parseOptions(args);
4250
4305
  const provider = options.provider;
4251
4306
 
4252
- if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
4253
- throw new Error("Провайдер должен быть ollama, openai, openrouter или codex.");
4307
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(provider)) {
4308
+ throw new Error("Провайдер должен быть iola, ollama, openai, openrouter или codex.");
4254
4309
  }
4255
4310
 
4256
4311
  const profile = buildProfileFromOptions(provider, options);
@@ -4329,7 +4384,7 @@ async function deleteAiProfile(name) {
4329
4384
  }
4330
4385
 
4331
4386
  function buildProfileFromOptions(provider, options) {
4332
- const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" ? "local" : provider];
4387
+ const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" || provider === "iola" ? "local" : provider];
4333
4388
  const profile = {
4334
4389
  ...defaults,
4335
4390
  provider,
@@ -4340,6 +4395,11 @@ function buildProfileFromOptions(provider, options) {
4340
4395
  profile.baseUrl = options["base-url"];
4341
4396
  }
4342
4397
 
4398
+ if (provider === "iola") {
4399
+ profile.repo = options.repo || defaults.repo || IOLA_ROUTER_HF_REPO;
4400
+ profile.modelDir = options["model-dir"] || defaults.modelDir || IOLA_MODEL_DIR;
4401
+ }
4402
+
4343
4403
  if (provider === "codex") {
4344
4404
  profile.sandbox = options.sandbox || defaults.sandbox || "read-only";
4345
4405
  profile.approval = options.approval || defaults.approval || "never";
@@ -4363,17 +4423,18 @@ async function useAiProvider(args) {
4363
4423
 
4364
4424
  const provider = providerOrProfile;
4365
4425
 
4366
- if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
4367
- throw new Error("Провайдер должен быть ollama, openai, openrouter, codex или именем AI-профиля.");
4426
+ if (provider !== "iola" && provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
4427
+ throw new Error("Провайдер должен быть iola, ollama, openai, openrouter, codex или именем AI-профиля.");
4368
4428
  }
4369
4429
 
4370
4430
  const defaultModel = {
4371
- ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
4431
+ iola: IOLA_LOCAL_MODEL,
4432
+ ollama: config.ai.provider === "ollama" ? config.ai.model : IOLA_LOCAL_OLLAMA_MODEL,
4372
4433
  openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
4373
4434
  openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
4374
4435
  codex: config.ai.provider === "codex" ? config.ai.model : "gpt-5.5",
4375
4436
  }[provider];
4376
- const profileName = provider === "ollama" ? "local" : provider;
4437
+ const profileName = provider === "ollama" || provider === "iola" ? "local" : provider;
4377
4438
  const profile = buildProfileFromOptions(provider, { model: defaultModel });
4378
4439
 
4379
4440
  await saveConfig({
@@ -4412,7 +4473,7 @@ async function slashModelMenu(args = []) {
4412
4473
  function normalizeModelMenuTarget(value = "") {
4413
4474
  const normalized = String(value || "").trim().toLocaleLowerCase("ru-RU");
4414
4475
  if (!normalized) return "";
4415
- if (["local", "локальная", "локально", "ollama"].includes(normalized)) return "local";
4476
+ if (["local", "локальная", "локально", "iola", "иола", "ollama"].includes(normalized)) return "local";
4416
4477
  if (["api", "апи"].includes(normalized)) return "api";
4417
4478
  if (normalized === "openai") return "openai";
4418
4479
  if (normalized === "openrouter" || normalized === "router") return "openrouter";
@@ -4422,7 +4483,7 @@ function normalizeModelMenuTarget(value = "") {
4422
4483
 
4423
4484
  async function chooseModelTarget() {
4424
4485
  console.log("Выберите AI-подключение:");
4425
- console.log(" 1. Локальная модель (Ollama)");
4486
+ console.log(" 1. Локальная модель IOLA");
4426
4487
  console.log(" 2. API (OpenAI/OpenRouter)");
4427
4488
  console.log(" 3. Codex CLI");
4428
4489
  console.log(" 0. Отмена");
@@ -4433,7 +4494,7 @@ async function chooseModelTarget() {
4433
4494
 
4434
4495
  async function openModelTargetMenu(target) {
4435
4496
  if (target === "local") {
4436
- const model = await chooseAiModel("ollama");
4497
+ const model = await chooseAiModel("iola");
4437
4498
  if (model) await switchModelTarget("local", model);
4438
4499
  return;
4439
4500
  }
@@ -4522,12 +4583,15 @@ async function chooseAiModel(provider) {
4522
4583
 
4523
4584
  async function switchModelTarget(target, model) {
4524
4585
  const config = await loadConfig();
4525
- const provider = target === "local" ? "ollama" : target;
4586
+ const provider = target === "local" ? "iola" : target;
4587
+ if (provider === "iola") {
4588
+ await ensureIolaModelFresh({ quiet: false });
4589
+ }
4526
4590
  if (provider === "ollama") {
4527
4591
  const ready = await ensureOllamaModelAvailable(model, config);
4528
4592
  if (!ready) return;
4529
4593
  }
4530
- const profileName = provider === "ollama" ? "local" : provider;
4594
+ const profileName = provider === "ollama" || provider === "iola" ? "local" : provider;
4531
4595
  const currentProfile = config.ai.profiles?.[profileName] || buildProfileFromOptions(provider, { model });
4532
4596
  const profile = {
4533
4597
  ...currentProfile,
@@ -5911,20 +5975,22 @@ function assertKeyProvider(provider) {
5911
5975
 
5912
5976
  async function chooseAiProvider() {
5913
5977
  console.log("Выберите режим AI:");
5914
- console.log("1. Локальная модель через Ollama");
5978
+ console.log("1. Локальная модель IOLA");
5915
5979
  console.log("2. OpenAI API");
5916
5980
  console.log("3. OpenRouter API");
5917
5981
  console.log("4. Codex/MCP");
5982
+ console.log("5. Ollama");
5918
5983
 
5919
5984
  const rl = readline.createInterface({ input, output });
5920
5985
  try {
5921
5986
  const answer = (await rl.question("Введите номер [1]: ")).trim() || "1";
5922
5987
  return {
5923
- 1: "ollama",
5988
+ 1: "iola",
5924
5989
  2: "openai",
5925
5990
  3: "openrouter",
5926
5991
  4: "codex",
5927
- }[answer] || "ollama";
5992
+ 5: "ollama",
5993
+ }[answer] || "iola";
5928
5994
  } finally {
5929
5995
  rl.close();
5930
5996
  }
@@ -5984,6 +6050,53 @@ async function setupOllama(args) {
5984
6050
  console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
5985
6051
  }
5986
6052
 
6053
+ async function setupIolaLocal(args) {
6054
+ const options = parseOptions(args);
6055
+ const repo = options.repo || IOLA_ROUTER_HF_REPO;
6056
+ const modelDir = options["model-dir"] || IOLA_MODEL_DIR;
6057
+ const profileName = options.name || "local";
6058
+ const optional = Boolean(options.optional);
6059
+
6060
+ if (optional && process.env.CI === "true") {
6061
+ return;
6062
+ }
6063
+
6064
+ try {
6065
+ await ensureIolaModelFresh({ repo, modelDir, force: true, quiet: Boolean(options.quiet) });
6066
+ } catch (error) {
6067
+ if (!optional) throw error;
6068
+ console.warn(`IOLA local model не установлена: ${error instanceof Error ? error.message : String(error)}`);
6069
+ }
6070
+
6071
+ const config = await loadConfig();
6072
+ await saveConfig({
6073
+ ai: {
6074
+ ...config.ai,
6075
+ activeProfile: profileName,
6076
+ provider: "iola",
6077
+ model: IOLA_LOCAL_MODEL,
6078
+ profiles: {
6079
+ ...(config.ai.profiles || {}),
6080
+ [profileName]: {
6081
+ provider: "iola",
6082
+ model: IOLA_LOCAL_MODEL,
6083
+ repo,
6084
+ modelDir,
6085
+ },
6086
+ },
6087
+ },
6088
+ });
6089
+
6090
+ if (options.quiet) return;
6091
+ console.log("");
6092
+ console.log("IOLA local mode готов:");
6093
+ console.log(` runtime: Python transformers/peft`);
6094
+ console.log(` model: ${IOLA_LOCAL_MODEL}`);
6095
+ console.log(` Hugging Face: ${repo}`);
6096
+ console.log(` cache: ${modelDir}`);
6097
+ console.log(" точные данные: https://apiiola.yasg.ru/api/v1/resolve-entity-field");
6098
+ }
6099
+
5987
6100
  async function aiAsk(args, context = {}) {
5988
6101
  const options = parseOptions(args);
5989
6102
  const question = options._.join(" ").trim();
@@ -5995,9 +6108,9 @@ async function aiAsk(args, context = {}) {
5995
6108
  const config = await loadConfig();
5996
6109
  const providerConfig = await resolveUsableAiProfile(config, options);
5997
6110
  if (providerConfig.provider === "codex") await assertPermission("codex");
5998
- if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
6111
+ if (providerConfig.provider !== "ollama" && providerConfig.provider !== "iola") await assertPermission("externalAi");
5999
6112
  if (options["stream-json"]) options.events = true;
6000
- if (options.tools && providerConfig.provider === "ollama") {
6113
+ if (providerConfig.provider === "iola" || (options.tools && providerConfig.provider === "ollama")) {
6001
6114
  return localToolAsk(question, providerConfig, options);
6002
6115
  }
6003
6116
  applyRuntimeConfig(providerConfig, options.config);
@@ -6220,14 +6333,25 @@ function resolveAiProfile(config, options = {}) {
6220
6333
  provider,
6221
6334
  model: options.model || activeProfile.model || config.ai.model,
6222
6335
  baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
6336
+ repo: options.repo || activeProfile.repo,
6337
+ modelDir: options["model-dir"] || activeProfile.modelDir,
6223
6338
  temperature: options.temperature || activeProfile.temperature,
6224
6339
  };
6225
6340
  }
6226
6341
 
6227
6342
  async function localToolAsk(question, providerConfig, options) {
6228
6343
  if (options["stream-json"]) options.events = true;
6344
+ const guarded = guardNonPublicQuestion(question);
6345
+ if (guarded) {
6346
+ if (!options.quiet) console.log(guarded);
6347
+ return guarded;
6348
+ }
6229
6349
  await ensureLocalData();
6230
6350
  const plan = await buildLocalToolPlan(question, providerConfig, options);
6351
+ if (plan.directAnswer) {
6352
+ if (!options.quiet) console.log(plan.directAnswer);
6353
+ return plan.directAnswer;
6354
+ }
6231
6355
  const validated = validateToolPlan(plan, options);
6232
6356
  if (options.plan) {
6233
6357
  printToolPlan(validated);
@@ -6267,6 +6391,14 @@ async function localToolAsk(question, providerConfig, options) {
6267
6391
  return answer;
6268
6392
  }
6269
6393
 
6394
+ function guardNonPublicQuestion(question) {
6395
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6396
+ if (/(зарплат|получа[ею]т|доход|домашн|паспорт|снилс|личн|персональн)/iu.test(normalized)) {
6397
+ return "Это поле не входит в открытые публичные данные.";
6398
+ }
6399
+ return "";
6400
+ }
6401
+
6270
6402
  function printToolPlan(plan) {
6271
6403
  console.log("План выполнения:");
6272
6404
  plan.steps.forEach((step, index) => {
@@ -6276,6 +6408,17 @@ function printToolPlan(plan) {
6276
6408
 
6277
6409
  async function buildLocalToolPlan(question, providerConfig, options) {
6278
6410
  const mode = options.reasoning || "verify";
6411
+
6412
+ if (providerConfig.provider === "iola") {
6413
+ await ensureIolaModelFresh({
6414
+ repo: providerConfig.repo || IOLA_ROUTER_HF_REPO,
6415
+ modelDir: providerConfig.modelDir || IOLA_MODEL_DIR,
6416
+ quiet: true,
6417
+ });
6418
+ const raw = await callIolaLocal(providerConfig, [{ role: "user", content: question }]);
6419
+ return normalizeIolaRouterPlan(raw, question, options);
6420
+ }
6421
+
6279
6422
  const prompt = [
6280
6423
  "Ты планировщик CLI iola. Верни только JSON.",
6281
6424
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
@@ -6298,6 +6441,27 @@ async function buildLocalToolPlan(question, providerConfig, options) {
6298
6441
  }
6299
6442
  }
6300
6443
 
6444
+ function normalizeIolaRouterPlan(raw, question, options = {}) {
6445
+ const payload = typeof raw === "string" ? parseJsonObject(raw) : raw;
6446
+ if (payload.action === "tool_call") {
6447
+ const tool = payload.tool === "get_entity_field" ? "resolve_entity_field" : payload.tool;
6448
+ return { steps: [{ tool, args: payload.args || {} }] };
6449
+ }
6450
+ if (payload.action === "direct_answer") {
6451
+ return { directAnswer: payload.answer || "" };
6452
+ }
6453
+ if (payload.action === "clarify") {
6454
+ return { directAnswer: payload.question || "Уточните запрос." };
6455
+ }
6456
+ if (payload.action === "refuse") {
6457
+ return { directAnswer: payload.reason === "field_not_public" ? "Это поле не входит в открытые публичные данные." : "Не могу выполнить этот запрос." };
6458
+ }
6459
+ if (options.reasoning === "vote") {
6460
+ return inferToolPlan(question, options);
6461
+ }
6462
+ throw new Error(`IOLA router вернул неподдерживаемое действие: ${payload.action || "unknown"}`);
6463
+ }
6464
+
6301
6465
  function parseJsonObject(text) {
6302
6466
  const match = String(text).match(/\{[\s\S]*\}/);
6303
6467
  if (!match) throw new Error("JSON-план не найден.");
@@ -6347,6 +6511,50 @@ function validateToolPlan(plan, options = {}) {
6347
6511
  return plan;
6348
6512
  }
6349
6513
 
6514
+ async function searchPublicEntities(args = {}) {
6515
+ const payload = await postJson(`${await getApiBaseUrl()}/search-entities`, {
6516
+ layer: normalizeEntityLayer(args.layer),
6517
+ query: args.query || args.entity_name || args.name || "",
6518
+ limit: Number(args.limit || 10),
6519
+ filters: args.filters || undefined,
6520
+ });
6521
+ return normalizeItems(payload).map((item) => ({
6522
+ ...(item.entity || item),
6523
+ score: item.score,
6524
+ layer: payload.layer || normalizeEntityLayer(args.layer),
6525
+ }));
6526
+ }
6527
+
6528
+ async function resolvePublicEntityField(args = {}) {
6529
+ const payload = await postJson(`${await getApiBaseUrl()}/resolve-entity-field`, {
6530
+ layer: normalizeEntityLayer(args.layer),
6531
+ entity_number: args.entity_number ?? args.number,
6532
+ entity_name: args.entity_name || args.name,
6533
+ inn: args.inn,
6534
+ field: normalizeEntityField(args.field),
6535
+ must_refute_user_value: args.must_refute_user_value,
6536
+ });
6537
+ return payload;
6538
+ }
6539
+
6540
+ function normalizeEntityLayer(layer) {
6541
+ const value = String(layer || "").toLocaleLowerCase("ru-RU");
6542
+ if (value === "school" || value === "schools" || value.includes("школ")) return "schools";
6543
+ if (value === "kindergarten" || value === "kindergartens" || value.includes("сад")) return "kindergartens";
6544
+ return value || "schools";
6545
+ }
6546
+
6547
+ function normalizeEntityField(field) {
6548
+ const value = String(field || "").toLocaleLowerCase("ru-RU");
6549
+ if (value === "director" || value === "head" || value.includes("директор") || value.includes("руковод")) return "head";
6550
+ if (value === "site" || value === "url" || value === "website" || value.includes("сайт")) return "website";
6551
+ if (value === "mail" || value === "email" || value.includes("почт")) return "email";
6552
+ if (value === "phone" || value.includes("тел")) return "phone";
6553
+ if (value === "address" || value.includes("адрес")) return "address";
6554
+ if (value === "license") return "license_status";
6555
+ return value || "name";
6556
+ }
6557
+
6350
6558
  function availableToolNames(options = {}) {
6351
6559
  const names = new Set(LOCAL_TOOLS);
6352
6560
  for (const tool of getLocalMcpToolNames()) names.add(tool);
@@ -6366,6 +6574,13 @@ async function executeToolPlan(plan, options = {}) {
6366
6574
  if (step.tool === "search_data" || step.tool === "search_local") {
6367
6575
  current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
6368
6576
  outputs.push({ tool: step.tool, rows: current.length });
6577
+ } else if (step.tool === "search_entities") {
6578
+ current = await searchPublicEntities(step.args || {});
6579
+ outputs.push({ tool: step.tool, rows: current.length });
6580
+ } else if (step.tool === "resolve_entity_field") {
6581
+ const resolved = await resolvePublicEntityField(step.args || {});
6582
+ current = Array.isArray(resolved) ? resolved : [resolved];
6583
+ outputs.push({ tool: step.tool, rows: current.length });
6369
6584
  } else if (step.tool === "get_card") {
6370
6585
  const card = findCard(step.args?.query || "");
6371
6586
  current = card ? [card] : [];
@@ -6514,7 +6729,13 @@ function formatToolResult(result, options) {
6514
6729
  const exported = result.outputs.find((item) => item.output);
6515
6730
  if (exported) return `Готово. Файл сохранен: ${exported.output}. Записей: ${exported.rows}`;
6516
6731
  if (!result.rows.length) return "Данных не найдено.";
6517
- return result.rows.slice(0, 10).map((row) => `${row.name || row.check}: ${row.address || row.count || ""}`).join("\n");
6732
+ return result.rows.slice(0, 10).map((row) => {
6733
+ if (row.ok && row.entity && row.field) {
6734
+ const name = row.entity.name || row.entity.inn || "организация";
6735
+ return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
6736
+ }
6737
+ return `${row.name || row.check || row.inn || "строка"}: ${row.address || row.phone || row.email || row.website || row.count || ""}`;
6738
+ }).join("\n");
6518
6739
  }
6519
6740
 
6520
6741
  function applyRuntimeConfig(target, value) {
@@ -6906,6 +7127,10 @@ function buildSourceLines(dataContext) {
6906
7127
  }
6907
7128
 
6908
7129
  async function callAiProvider(config, messages) {
7130
+ if (config.provider === "iola") {
7131
+ return callIolaLocal(config, messages);
7132
+ }
7133
+
6909
7134
  if (config.provider === "ollama") {
6910
7135
  return callOllama(config, messages);
6911
7136
  }
@@ -6925,6 +7150,155 @@ async function callAiProvider(config, messages) {
6925
7150
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
6926
7151
  }
6927
7152
 
7153
+ async function callIolaLocal(config, messages) {
7154
+ const runtime = await ensureIolaModelRuntime({ quiet: true });
7155
+ const repo = config.repo || IOLA_ROUTER_HF_REPO;
7156
+ const modelDir = config.modelDir || IOLA_MODEL_DIR;
7157
+ const payload = {
7158
+ repo,
7159
+ cache_dir: modelDir,
7160
+ messages,
7161
+ max_new_tokens: Number(config.maxNewTokens || 180),
7162
+ temperature: Number(config.temperature ?? 0),
7163
+ };
7164
+ const { stdout, stderr } = await runCommand(runtime.python, [IOLA_MODEL_RUNNER], {
7165
+ input: JSON.stringify(payload),
7166
+ env: {
7167
+ IOLA_ROUTER_HF_REPO: repo,
7168
+ IOLA_MODEL_DIR: modelDir,
7169
+ },
7170
+ });
7171
+ const text = stdout.trim();
7172
+ if (!text) {
7173
+ throw new Error(`IOLA local model вернула пустой ответ.${stderr ? `\n${stderr}` : ""}`);
7174
+ }
7175
+ return text;
7176
+ }
7177
+
7178
+ async function hasUsableIolaModel() {
7179
+ const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR));
7180
+ return Boolean(state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
7181
+ }
7182
+
7183
+ async function ensureIolaModelFresh(options = {}) {
7184
+ const repo = options.repo || IOLA_ROUTER_HF_REPO;
7185
+ const modelDir = options.modelDir || IOLA_MODEL_DIR;
7186
+ await mkdir(modelDir, { recursive: true });
7187
+ const stateFile = getIolaModelStateFile(modelDir);
7188
+ const state = readConfigLayerSync(stateFile) || {};
7189
+ const remote = await getRemoteIolaModelRevision(repo).catch(() => null);
7190
+ const stale = options.force || state.repo !== repo || !state.revision || (remote?.sha && remote.sha !== state.revision);
7191
+ if (!stale) return state;
7192
+
7193
+ if (!options.quiet) {
7194
+ const reason = state.revision ? "обновляю" : "устанавливаю";
7195
+ console.log(`IOLA local model: ${reason} ${repo}`);
7196
+ console.log("Загрузка первой установки может занять несколько минут.");
7197
+ }
7198
+
7199
+ const runtime = await ensureIolaModelRuntime({ quiet: options.quiet });
7200
+ const { stdout } = await runCommand(runtime.python, [IOLA_MODEL_RUNNER, "--ensure"], {
7201
+ input: JSON.stringify({ repo, cache_dir: modelDir }),
7202
+ env: {
7203
+ IOLA_ROUTER_HF_REPO: repo,
7204
+ IOLA_MODEL_DIR: modelDir,
7205
+ },
7206
+ });
7207
+ const installed = parseJsonObject(stdout || "{}");
7208
+ const nextState = {
7209
+ repo,
7210
+ revision: installed.revision || remote?.sha || state.revision || `local-${Date.now()}`,
7211
+ installedAt: new Date().toISOString(),
7212
+ runtime: "transformers",
7213
+ };
7214
+ await mkdir(modelDir, { recursive: true });
7215
+ await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
7216
+ return nextState;
7217
+ }
7218
+
7219
+ function getIolaModelStateFile(modelDir = IOLA_MODEL_DIR) {
7220
+ return path.join(modelDir, "manifest.json");
7221
+ }
7222
+
7223
+ async function ensureIolaModelRuntime(options = {}) {
7224
+ const python = await getIolaRuntimePython();
7225
+ if (python && await checkIolaPythonDeps(python)) return { python };
7226
+
7227
+ const basePython = await findPythonCommand();
7228
+ if (!basePython) {
7229
+ throw new Error("Python не найден. Установите Python 3.11+ и повторите: iola ai setup iola --yes");
7230
+ }
7231
+
7232
+ await mkdir(IOLA_MODEL_RUNTIME_DIR, { recursive: true });
7233
+ if (!python) {
7234
+ if (!options.quiet) console.log(`Создаю Python runtime: ${IOLA_MODEL_RUNTIME_DIR}`);
7235
+ await runCommand(basePython.command, [...basePython.args, "-m", "venv", IOLA_MODEL_RUNTIME_DIR], { inherit: !options.quiet });
7236
+ }
7237
+
7238
+ const runtimePython = await getIolaRuntimePython();
7239
+ if (!runtimePython) {
7240
+ throw new Error("Не удалось создать Python runtime для локальной модели.");
7241
+ }
7242
+
7243
+ if (!options.quiet) console.log("Устанавливаю зависимости локальной модели: torch, transformers, peft.");
7244
+ await runCommand(runtimePython, ["-m", "pip", "install", "--upgrade", "pip"], { inherit: !options.quiet });
7245
+ 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 });
7246
+
7247
+ if (!await checkIolaPythonDeps(runtimePython)) {
7248
+ throw new Error("Python-зависимости локальной модели не установились.");
7249
+ }
7250
+ return { python: runtimePython };
7251
+ }
7252
+
7253
+ async function getIolaRuntimePython() {
7254
+ const candidate = process.platform === "win32"
7255
+ ? path.join(IOLA_MODEL_RUNTIME_DIR, "Scripts", "python.exe")
7256
+ : path.join(IOLA_MODEL_RUNTIME_DIR, "bin", "python");
7257
+ return existsSync(candidate) ? candidate : null;
7258
+ }
7259
+
7260
+ async function checkIolaPythonDeps(python) {
7261
+ try {
7262
+ await runCommand(python, [IOLA_MODEL_RUNNER, "--check-deps"]);
7263
+ return true;
7264
+ } catch {
7265
+ return false;
7266
+ }
7267
+ }
7268
+
7269
+ async function findPythonCommand() {
7270
+ const candidates = [
7271
+ { command: process.env.IOLA_PYTHON, args: [] },
7272
+ { command: "python", args: [] },
7273
+ { command: "python3", args: [] },
7274
+ { command: "py", args: ["-3"] },
7275
+ ].filter((item) => item.command);
7276
+
7277
+ for (const candidate of candidates) {
7278
+ try {
7279
+ await runCommand(candidate.command, [...candidate.args, "--version"]);
7280
+ return candidate;
7281
+ } catch {
7282
+ // Try next candidate.
7283
+ }
7284
+ }
7285
+ return null;
7286
+ }
7287
+
7288
+ async function getRemoteIolaModelRevision(repo = IOLA_ROUTER_HF_REPO) {
7289
+ const repoPath = String(repo).split("/").map((part) => encodeURIComponent(part)).join("/");
7290
+ const response = await fetch(`https://huggingface.co/api/models/${repoPath}`, {
7291
+ headers: {
7292
+ accept: "application/json",
7293
+ "user-agent": "@iola_adm/iola-cli",
7294
+ },
7295
+ signal: AbortSignal.timeout(5000),
7296
+ });
7297
+ if (!response.ok) throw new Error(`Hugging Face model metadata failed: ${response.status} ${response.statusText}`);
7298
+ const payload = await response.json();
7299
+ return { sha: payload.sha || payload.lastModified || "", lastModified: payload.lastModified || "" };
7300
+ }
7301
+
6928
7302
  async function callCodex(config, messages) {
6929
7303
  const prompt = messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
6930
7304
  const outputFile = path.join(os.tmpdir(), `iola-codex-${process.pid}-${Date.now()}.txt`);
@@ -7262,6 +7636,9 @@ async function onboard(args = []) {
7262
7636
  if (components.includes("workspace")) await handleWorkspace(["init"]);
7263
7637
  if (components.includes("policy")) await handlePolicy(["use", "analyst"]);
7264
7638
  if (components.includes("archive")) await ensureArchiveTool({ install: true });
7639
+ if (components.includes("iola")) {
7640
+ await setupIolaLocal(["--yes"]);
7641
+ }
7265
7642
  if (components.includes("ollama")) {
7266
7643
  await installOllamaIfMissing();
7267
7644
  await setupOllama(["--yes"]);
@@ -7309,7 +7686,7 @@ async function chooseOnboardComponents(status = null) {
7309
7686
  const map = {
7310
7687
  1: "workspace",
7311
7688
  2: "policy",
7312
- 3: "ollama",
7689
+ 3: "iola",
7313
7690
  4: "openai",
7314
7691
  5: "openrouter",
7315
7692
  6: "codex",
@@ -7317,6 +7694,7 @@ async function chooseOnboardComponents(status = null) {
7317
7694
  8: "archive",
7318
7695
  9: "index",
7319
7696
  10: "browser",
7697
+ 11: "ollama",
7320
7698
  };
7321
7699
  return [...selected].map((item) => map[item] || item).filter(Boolean);
7322
7700
  } finally {
@@ -7338,6 +7716,7 @@ async function getOnboardComponentStatus() {
7338
7716
  return {
7339
7717
  workspace: workspaceReady,
7340
7718
  policy: policyReady,
7719
+ iola: Boolean(readiness.iola),
7341
7720
  ollama: Boolean(ollamaVersion && readiness.ollama),
7342
7721
  openai: Boolean(readiness.openai),
7343
7722
  openrouter: Boolean(readiness.openrouter),
@@ -7353,7 +7732,7 @@ function onboardComponentRows(status) {
7353
7732
  const rows = [
7354
7733
  ["1", "workspace", "workspace и контекст", "рабочая папка, IOLA.md и .iola/context.md"],
7355
7734
  ["2", "policy", "policy analyst", "разрешения и профиль аналитика"],
7356
- ["3", "ollama", "Ollama + локальная модель", "локальная модель найдена"],
7735
+ ["3", "iola", "IOLA локальная модель", "локальная модель найдена"],
7357
7736
  ["4", "openai", "OpenAI API", "API-ключ сохранен или есть в env"],
7358
7737
  ["5", "openrouter", "OpenRouter API", "API-ключ сохранен или есть в env"],
7359
7738
  ["6", "codex", "Codex CLI", "CLI установлен и авторизация найдена"],
@@ -7361,6 +7740,7 @@ function onboardComponentRows(status) {
7361
7740
  ["8", "archive", "7-Zip / архивы", "архиватор найден"],
7362
7741
  ["9", "index", "Индекс локальных документов", "настраивается под выбранную папку"],
7363
7742
  ["10", "browser", "Browser runtime", "Playwright/Chromium установлен"],
7743
+ ["11", "ollama", "Ollama", "опциональный локальный runtime"],
7364
7744
  ];
7365
7745
  return rows.map(([number, key, title, hint]) => ({ number, key, title, hint, status: status[key] ? "готово" : "не настроено" }));
7366
7746
  }
@@ -7369,12 +7749,13 @@ function defaultOnboardSelection(status) {
7369
7749
  const defaults = [];
7370
7750
  if (!status.workspace) defaults.push("1");
7371
7751
  if (!status.policy) defaults.push("2");
7752
+ if (!status.iola) defaults.push("3");
7372
7753
  if (!status.archive) defaults.push("8");
7373
7754
  return defaults.length ? defaults : ["1", "2"];
7374
7755
  }
7375
7756
 
7376
7757
  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" };
7758
+ 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
7759
  return defaultOnboardSelection(status).map((item) => map[item]).filter(Boolean);
7379
7760
  }
7380
7761
 
@@ -7383,12 +7764,12 @@ function parseOptions(args) {
7383
7764
 
7384
7765
  for (let index = 0; index < args.length; index += 1) {
7385
7766
  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") {
7767
+ 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 === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
7387
7768
  result[arg.slice(2)] = true;
7388
7769
  } else if (arg === "--check" || arg === "--upgrade-node") {
7389
7770
  result.check = true;
7390
7771
  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") {
7772
+ } 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
7773
  result[arg.slice(2)] = args[index + 1];
7393
7774
  index += 1;
7394
7775
  } else {
@@ -8663,7 +9044,7 @@ function recordUsage({ providerConfig, question, answer, sessionId, profile }) {
8663
9044
  }
8664
9045
 
8665
9046
  function estimateCost(providerConfig, tokens) {
8666
- if (!providerConfig || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
9047
+ if (!providerConfig || providerConfig.provider === "iola" || providerConfig.provider === "ollama" || providerConfig.provider === "codex") return 0;
8667
9048
  const perMillion = providerConfig.provider === "openrouter" ? 0.25 : 0.4;
8668
9049
  return Math.round((tokens / 1_000_000) * perMillion * 1_000_000) / 1_000_000;
8669
9050
  }
@@ -9340,8 +9721,8 @@ function validateConfig(config) {
9340
9721
  if (!config.ai?.profiles || typeof config.ai.profiles !== "object") errors.push("ai.profiles обязателен");
9341
9722
  if (config.ai?.activeProfile && !config.ai.profiles?.[config.ai.activeProfile]) errors.push(`ai.activeProfile не найден в profiles: ${config.ai.activeProfile}`);
9342
9723
  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 обязателен`);
9724
+ if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
9725
+ if (profile.provider !== "codex" && profile.provider !== "iola" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
9345
9726
  }
9346
9727
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
9347
9728
  if (!ALL_TOOL_ALIASES.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
@@ -9358,7 +9739,7 @@ function configSchema() {
9358
9739
  required: ["api", "ai"],
9359
9740
  properties: {
9360
9741
  api: { required: ["baseUrl", "mcpBaseUrl"] },
9361
- ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
9742
+ ai: { required: ["activeProfile", "profiles"], providers: ["iola", "ollama", "openai", "openrouter", "codex"] },
9362
9743
  permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
9363
9744
  toolsets: { available: Object.keys(TOOLSETS) },
9364
9745
  files: { modes: ["locked", "read-only", "workspace-write", "full-access"], approvals: ["never", "on-write", "on-danger", "always"] },
@@ -9373,7 +9754,7 @@ function getActiveProfileName(config) {
9373
9754
  return config.ai.activeProfile;
9374
9755
  }
9375
9756
 
9376
- const provider = config.ai.provider === "ollama" ? "local" : config.ai.provider;
9757
+ const provider = config.ai.provider === "ollama" || config.ai.provider === "iola" ? "local" : config.ai.provider;
9377
9758
  if (provider && config.ai.profiles?.[provider]) {
9378
9759
  return provider;
9379
9760
  }
@@ -9560,6 +9941,25 @@ async function fetchJson(url) {
9560
9941
  return response.json();
9561
9942
  }
9562
9943
 
9944
+ async function postJson(url, payload) {
9945
+ const response = await fetch(url, {
9946
+ method: "POST",
9947
+ headers: {
9948
+ accept: "application/json",
9949
+ "content-type": "application/json",
9950
+ "user-agent": "@iola_adm/iola-cli",
9951
+ },
9952
+ body: JSON.stringify(payload),
9953
+ });
9954
+
9955
+ if (!response.ok) {
9956
+ const text = await response.text().catch(() => "");
9957
+ throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})${text ? `\n${text}` : ""}`);
9958
+ }
9959
+
9960
+ return response.json();
9961
+ }
9962
+
9563
9963
  function parseJsonOrSse(text) {
9564
9964
  const trimmed = String(text || "").trim();
9565
9965
  if (!trimmed) return null;