@iola_adm/iola-cli 0.1.12 → 0.1.14

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
@@ -87,12 +87,26 @@ iola agent
87
87
  iola chat
88
88
  iola init
89
89
  iola doctor
90
+ iola db status
91
+ iola db init
92
+ iola history --limit 20
93
+ iola history clear
94
+ iola sessions --limit 20
95
+ iola resume 1 "продолжи"
96
+ iola fork 1 "новый вопрос"
97
+ iola features list
98
+ iola features enable api-cache
99
+ iola mcp status
100
+ iola mcp list
101
+ iola mcp install codex
90
102
  iola config get
91
103
  iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
92
104
  iola config reset
93
105
  iola update
94
106
  iola version --check
95
107
  iola ask "Найди школу 29"
108
+ iola ask "Найди школу 29" --profile codex --events --output answer.txt
109
+ iola ask "Найди школу 29" --schema json --no-history
96
110
  iola data schools --limit 10
97
111
  iola data kindergartens --search "29"
98
112
  iola data schools --where address=Петрова --columns name,address,phone
@@ -139,6 +153,11 @@ iola agent
139
153
  /help
140
154
  /health
141
155
  /doctor
156
+ /db status
157
+ /sessions
158
+ /resume 1
159
+ /features list
160
+ /mcp status
142
161
  /config get
143
162
  /layers
144
163
  /data schools --limit 10
@@ -160,6 +179,7 @@ iola agent
160
179
  /provider
161
180
  /config
162
181
  /history
182
+ /history --limit 20
163
183
  /clear
164
184
  /update
165
185
  /init
@@ -271,6 +291,65 @@ CLI дает прямой терминальный доступ к открыт
271
291
  командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
272
292
  интерактивному агентному режиму, экспорту данных и проверке обновлений.
273
293
 
294
+ ## Локальная SQLite-БД
295
+
296
+ CLI использует встроенный `node:sqlite` и хранит локальную БД в профиле
297
+ пользователя:
298
+
299
+ ```text
300
+ %USERPROFILE%\.iola\iola.db
301
+ ```
302
+
303
+ БД создается автоматически при установке npm-пакета и при `iola init`.
304
+ В ней хранятся история AI-запросов, контекст ответа, ошибки выполнения,
305
+ служебная таблица версии схемы, а также подготовлены таблицы для кеша API и
306
+ сохраненных выборок.
307
+
308
+ Команды:
309
+
310
+ ```bash
311
+ iola db status
312
+ iola db init
313
+ iola db reset
314
+ iola history --limit 20
315
+ iola history --json
316
+ iola history clear
317
+ iola sessions --limit 20
318
+ iola sessions clear
319
+ iola resume 1 "продолжи"
320
+ iola fork 1 "новая ветка разговора"
321
+ ```
322
+
323
+ Ключи OpenAI/OpenRouter в SQLite не сохраняются. Они остаются в локальном
324
+ `secrets.json` или в переменных окружения.
325
+
326
+ ## Feature flags, MCP и машинный вывод
327
+
328
+ Экспериментальные и системные возможности можно включать отдельно:
329
+
330
+ ```bash
331
+ iola features list
332
+ iola features enable api-cache
333
+ iola features disable sqlite-history
334
+ ```
335
+
336
+ MCP-интеграции:
337
+
338
+ ```bash
339
+ iola mcp status
340
+ iola mcp list
341
+ iola mcp install codex
342
+ iola mcp remove codex
343
+ ```
344
+
345
+ Для автоматизации доступны события, JSON-ответ и запись результата в файл:
346
+
347
+ ```bash
348
+ iola ask "Найди школу 29" --events
349
+ iola ask "Найди школу 29" --schema json
350
+ iola ask "Найди школу 29" --output answer.txt
351
+ ```
352
+
274
353
  ## Переменные окружения
275
354
 
276
355
  ```bash
package/bin/iola.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --no-warnings
2
2
 
3
3
  import { main } from "../src/cli.js";
4
4
 
@@ -6,4 +6,3 @@ main(process.argv.slice(2)).catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : String(error));
7
7
  process.exitCode = 1;
8
8
  });
9
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -15,6 +15,11 @@
15
15
  "bin": {
16
16
  "iola": "bin/iola.js"
17
17
  },
18
+ "scripts": {
19
+ "postinstall": "node --no-warnings bin/iola.js db init --silent || true",
20
+ "start": "node --no-warnings bin/iola.js",
21
+ "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js"
22
+ },
18
23
  "files": [
19
24
  "bin",
20
25
  "src",
@@ -25,10 +30,6 @@
25
30
  "publishConfig": {
26
31
  "access": "public"
27
32
  },
28
- "scripts": {
29
- "start": "node bin/iola.js",
30
- "test": "node --check bin/iola.js && node --check src/cli.js"
31
- },
32
33
  "keywords": [
33
34
  "yoshkar-ola",
34
35
  "open-data",
package/src/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
2
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import readline from "node:readline/promises";
6
7
  import { stdin as input, stdout as output } from "node:process";
8
+ import { DatabaseSync } from "node:sqlite";
7
9
 
8
10
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
9
11
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
@@ -11,6 +13,16 @@ const MIN_NODE_VERSION = "22.5.0";
11
13
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
12
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
13
15
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
+ const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
+ const DB_SCHEMA_VERSION = 2;
18
+ const FEATURES = {
19
+ "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
20
+ sessions: { stage: "stable", defaultEnabled: true, description: "Сессии, resume и fork для AI-диалогов." },
21
+ "api-cache": { stage: "experimental", defaultEnabled: false, description: "Локальный кеш API-ответов." },
22
+ events: { stage: "experimental", defaultEnabled: true, description: "JSONL-события выполнения ask." },
23
+ "mcp-management": { stage: "stable", defaultEnabled: true, description: "Команды управления MCP-интеграциями." },
24
+ "web-search": { stage: "experimental", defaultEnabled: false, description: "Резерв под web-search режимы AI." },
25
+ };
14
26
  const DEFAULT_AI_CONFIG = {
15
27
  api: {
16
28
  baseUrl: "https://apiiola.yasg.ru/api/v1",
@@ -76,6 +88,13 @@ const COMMANDS = new Map([
76
88
  ["version", showVersion],
77
89
  ["update", checkUpdate],
78
90
  ["doctor", doctor],
91
+ ["db", handleDb],
92
+ ["history", handleHistory],
93
+ ["sessions", handleSessions],
94
+ ["resume", resumeSession],
95
+ ["fork", forkSession],
96
+ ["features", handleFeatures],
97
+ ["mcp", handleMcp],
79
98
  ["config", handleConfig],
80
99
  ["banner", showBanner],
81
100
  ["agent", startAgent],
@@ -119,12 +138,21 @@ Usage:
119
138
  iola chat
120
139
  iola init
121
140
  iola doctor
141
+ iola db status
142
+ iola db init
143
+ iola history [--limit 20]
144
+ iola history clear
145
+ iola sessions [--limit 20]
146
+ iola resume SESSION_ID [TEXT]
147
+ iola fork SESSION_ID [TEXT]
148
+ iola features list|enable|disable
149
+ iola mcp list|status|install|remove
122
150
  iola config get
123
151
  iola config set api.baseUrl URL
124
152
  iola config set api.mcpBaseUrl URL
125
153
  iola config reset
126
154
  iola update
127
- iola ask TEXT
155
+ iola ask TEXT [--profile NAME] [--model MODEL] [--output FILE] [--schema json|table] [--events] [--no-history]
128
156
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
129
157
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
130
158
  iola ai context TEXT [--json]
@@ -225,7 +253,41 @@ async function handleAgentLine(line, state) {
225
253
  }
226
254
 
227
255
  if (command === "history") {
228
- printAgentHistory(state.history);
256
+ if (args.length > 0) {
257
+ await handleHistory(args);
258
+ } else {
259
+ printAgentHistory(state.history);
260
+ }
261
+ return false;
262
+ }
263
+
264
+ if (command === "db") {
265
+ await handleDb(args);
266
+ return false;
267
+ }
268
+
269
+ if (command === "sessions") {
270
+ await handleSessions(args);
271
+ return false;
272
+ }
273
+
274
+ if (command === "resume") {
275
+ await resumeSession(args);
276
+ return false;
277
+ }
278
+
279
+ if (command === "fork") {
280
+ await forkSession(args);
281
+ return false;
282
+ }
283
+
284
+ if (command === "features") {
285
+ await handleFeatures(args);
286
+ return false;
287
+ }
288
+
289
+ if (command === "mcp") {
290
+ await handleMcp(args);
229
291
  return false;
230
292
  }
231
293
 
@@ -307,6 +369,13 @@ async function handleAgentLine(line, state) {
307
369
  const mapped = {
308
370
  health: ["health", args],
309
371
  doctor: ["doctor", args],
372
+ db: ["db", args],
373
+ history: ["history", args],
374
+ sessions: ["sessions", args],
375
+ resume: ["resume", args],
376
+ fork: ["fork", args],
377
+ features: ["features", args],
378
+ mcp: ["mcp", args],
310
379
  config: ["config", args],
311
380
  layers: ["layers", args],
312
381
  data: ["data", args],
@@ -333,6 +402,11 @@ function printAgentHelp() {
333
402
  /help
334
403
  /health
335
404
  /doctor
405
+ /db status
406
+ /sessions
407
+ /resume SESSION_ID
408
+ /features list
409
+ /mcp status
336
410
  /config get
337
411
  /config set api.baseUrl URL
338
412
  /layers
@@ -357,6 +431,7 @@ function printAgentHelp() {
357
431
  /provider
358
432
  /model
359
433
  /history
434
+ /history --limit 20
360
435
  /clear
361
436
  /banner
362
437
  /update
@@ -474,6 +549,7 @@ async function doctor(args = []) {
474
549
  nodeRequired: `>=${MIN_NODE_VERSION}`,
475
550
  nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
476
551
  },
552
+ db: getDbStatus(),
477
553
  api: {
478
554
  baseUrl: apiBaseUrl,
479
555
  mcpBaseUrl,
@@ -496,9 +572,26 @@ async function doctor(args = []) {
496
572
  return;
497
573
  }
498
574
 
575
+ if (options.summary) {
576
+ printTable([
577
+ { group: "cli", status: report.cli.nodeStatus === "ok" && report.cli.update !== "available" ? "ok" : "check" },
578
+ { group: "sqlite", status: report.db.status },
579
+ { group: "api", status: report.api.health },
580
+ { group: "ai", status: report.ai.provider },
581
+ { group: "ollama", status: report.ai.ollama },
582
+ ], [
583
+ ["group", "Группа"],
584
+ ["status", "Статус"],
585
+ ]);
586
+ return;
587
+ }
588
+
499
589
  console.log("CLI");
500
590
  printKeyValue(report.cli);
501
591
  console.log("");
592
+ console.log("SQLite");
593
+ printKeyValue(report.db);
594
+ console.log("");
502
595
  console.log("API/MCP");
503
596
  printKeyValue(report.api);
504
597
  console.log("");
@@ -506,6 +599,11 @@ async function doctor(args = []) {
506
599
  printKeyValue(report.ai);
507
600
  console.log("");
508
601
  printDiagnostics(diagnostics, recommendOllamaModel(diagnostics));
602
+ if (options.all) {
603
+ console.log("");
604
+ console.log("Фичи");
605
+ await handleFeatures(["list"]);
606
+ }
509
607
  }
510
608
 
511
609
  function getUpdateStatus(current, latest) {
@@ -615,6 +713,8 @@ async function initCli(args = []) {
615
713
 
616
714
  showBanner();
617
715
  console.log("Проверка окружения");
716
+ initDatabase();
717
+ const dbStatus = getDbStatus();
618
718
  printKeyValue({
619
719
  node: process.version,
620
720
  node_required: `>=${MIN_NODE_VERSION}`,
@@ -622,6 +722,8 @@ async function initCli(args = []) {
622
722
  npm: await getCommandVersion("npm", ["--version"]),
623
723
  api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
624
724
  mcp: await getMcpBaseUrl(),
725
+ sqlite: dbStatus.status,
726
+ sqlite_file: dbStatus.file,
625
727
  });
626
728
  console.log("");
627
729
 
@@ -763,6 +865,187 @@ async function handleConfig(args) {
763
865
  throw new Error("Команды config: get, set, reset.");
764
866
  }
765
867
 
868
+ async function handleDb(args) {
869
+ const [action = "status"] = args;
870
+ const options = parseOptions(args);
871
+
872
+ if (action === "init") {
873
+ initDatabase();
874
+ if (!options.silent) {
875
+ console.log(`SQLite-БД готова: ${DB_FILE}`);
876
+ }
877
+ return;
878
+ }
879
+
880
+ if (action === "status") {
881
+ printKeyValue(getDbStatus());
882
+ return;
883
+ }
884
+
885
+ if (action === "reset") {
886
+ const shouldReset = await confirm("Удалить локальную SQLite-БД iola.db? [y/N] ");
887
+ if (!shouldReset) {
888
+ console.log("Сброс отменен.");
889
+ return;
890
+ }
891
+ await rm(DB_FILE, { force: true });
892
+ initDatabase();
893
+ console.log(`SQLite-БД пересоздана: ${DB_FILE}`);
894
+ return;
895
+ }
896
+
897
+ throw new Error("Команды db: status, init, reset.");
898
+ }
899
+
900
+ async function handleHistory(args) {
901
+ const [action] = args;
902
+ const options = parseOptions(args);
903
+
904
+ if (action === "clear") {
905
+ clearHistory();
906
+ console.log("История очищена.");
907
+ return;
908
+ }
909
+
910
+ const rows = listHistory(Number(options.limit || 20));
911
+
912
+ if (options.json) {
913
+ printJson(rows);
914
+ return;
915
+ }
916
+
917
+ printTable(rows, [
918
+ ["id", "ID"],
919
+ ["created_at", "Дата"],
920
+ ["profile", "Профиль"],
921
+ ["provider", "Провайдер"],
922
+ ["question", "Вопрос"],
923
+ ["answer", "Ответ"],
924
+ ]);
925
+ }
926
+
927
+ async function handleSessions(args) {
928
+ const [action] = args;
929
+ if (action === "clear") {
930
+ clearSessions();
931
+ console.log("Сессии очищены.");
932
+ return;
933
+ }
934
+
935
+ const options = parseOptions(args);
936
+ const rows = listSessions(Number(options.limit || 20));
937
+
938
+ if (options.json) {
939
+ printJson(rows);
940
+ return;
941
+ }
942
+
943
+ printTable(rows, [
944
+ ["id", "ID"],
945
+ ["updated_at", "Обновлена"],
946
+ ["profile", "Профиль"],
947
+ ["provider", "Провайдер"],
948
+ ["model", "Модель"],
949
+ ["messages", "Сообщ."],
950
+ ["title", "Название"],
951
+ ]);
952
+ }
953
+
954
+ async function resumeSession(args) {
955
+ const [sessionId, ...questionParts] = args;
956
+ if (!sessionId) {
957
+ throw new Error("SESSION_ID обязателен. Пример: iola resume 1 \"продолжи\"");
958
+ }
959
+
960
+ const question = questionParts.join(" ").trim();
961
+ if (!question) {
962
+ printSessionMessages(Number(sessionId));
963
+ return;
964
+ }
965
+
966
+ const session = getSession(Number(sessionId));
967
+ await aiAsk([question, "--session", sessionId, "--profile", session.profile || "local"]);
968
+ }
969
+
970
+ async function forkSession(args) {
971
+ const [sessionId, ...questionParts] = args;
972
+ if (!sessionId) {
973
+ throw new Error("SESSION_ID обязателен. Пример: iola fork 1 \"новый вопрос\"");
974
+ }
975
+
976
+ const forkedId = forkSessionInDb(Number(sessionId));
977
+ console.log(`Создана новая сессия: ${forkedId}`);
978
+ const question = questionParts.join(" ").trim();
979
+ if (question) {
980
+ const session = getSession(forkedId);
981
+ await aiAsk([question, "--session", String(forkedId), "--profile", session.profile || "local"]);
982
+ }
983
+ }
984
+
985
+ async function handleFeatures(args) {
986
+ const [action = "list", name] = args;
987
+
988
+ if (action === "list" || action === "ls") {
989
+ const rows = listFeatures();
990
+ printTable(rows, [
991
+ ["name", "Фича"],
992
+ ["enabled", "Вкл"],
993
+ ["stage", "Стадия"],
994
+ ["description", "Описание"],
995
+ ]);
996
+ return;
997
+ }
998
+
999
+ if (action === "enable" || action === "disable") {
1000
+ if (!name || !FEATURES[name]) {
1001
+ throw new Error(`Неизвестная фича. Доступно: ${Object.keys(FEATURES).join(", ")}`);
1002
+ }
1003
+ setFeatureEnabled(name, action === "enable");
1004
+ console.log(`${name}: ${action === "enable" ? "enabled" : "disabled"}`);
1005
+ return;
1006
+ }
1007
+
1008
+ throw new Error("Команды features: list, enable NAME, disable NAME.");
1009
+ }
1010
+
1011
+ async function handleMcp(args) {
1012
+ const [action = "status", target = "codex"] = args;
1013
+
1014
+ if (action === "status") {
1015
+ const [health, version] = await Promise.all([
1016
+ fetchJson(`${await getMcpBaseUrl()}/mcp-health`),
1017
+ fetchJson(`${await getMcpBaseUrl()}/mcp-version`),
1018
+ ]);
1019
+ printKeyValue({
1020
+ endpoint: `${await getMcpBaseUrl()}/mcp`,
1021
+ status: health.status,
1022
+ server_version: version.server_version,
1023
+ layers: version.data_layers?.map((layer) => layer.id).join(", "),
1024
+ });
1025
+ return;
1026
+ }
1027
+
1028
+ if (action === "list") {
1029
+ await runCommand("codex", ["mcp", "list"], { inherit: true });
1030
+ return;
1031
+ }
1032
+
1033
+ if (action === "install" || action === "add") {
1034
+ await setupClient([target]);
1035
+ return;
1036
+ }
1037
+
1038
+ if (action === "remove" || action === "delete") {
1039
+ if (target !== "codex") {
1040
+ throw new Error("Пока доступно удаление только Codex MCP.");
1041
+ }
1042
+ await runCommand("codex", ["mcp", "remove", "yoshkarOlaPublicData"], { inherit: true });
1043
+ return;
1044
+ }
1045
+
1046
+ throw new Error("Команды mcp: status, list, install codex, remove codex.");
1047
+ }
1048
+
766
1049
  async function aiDoctor(args) {
767
1050
  const options = parseOptions(args);
768
1051
  const diagnostics = await getLocalDiagnostics();
@@ -1250,6 +1533,335 @@ async function deleteAiKey(provider) {
1250
1533
  console.log(`Локальный ключ ${provider} удален.`);
1251
1534
  }
1252
1535
 
1536
+ function openDatabase() {
1537
+ const db = new DatabaseSync(DB_FILE);
1538
+ db.exec("PRAGMA busy_timeout = 5000;");
1539
+ return db;
1540
+ }
1541
+
1542
+ function initDatabase() {
1543
+ mkdirSyncSafe(CONFIG_DIR);
1544
+ const db = openDatabase();
1545
+ try {
1546
+ db.exec(`
1547
+ CREATE TABLE IF NOT EXISTS meta (
1548
+ key TEXT PRIMARY KEY,
1549
+ value TEXT NOT NULL
1550
+ );
1551
+ CREATE TABLE IF NOT EXISTS ask_history (
1552
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1553
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1554
+ profile TEXT,
1555
+ provider TEXT,
1556
+ model TEXT,
1557
+ question TEXT NOT NULL,
1558
+ answer TEXT NOT NULL,
1559
+ context_json TEXT NOT NULL,
1560
+ error TEXT
1561
+ );
1562
+ CREATE INDEX IF NOT EXISTS idx_ask_history_created_at ON ask_history(created_at DESC);
1563
+ CREATE TABLE IF NOT EXISTS sessions (
1564
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1565
+ parent_id INTEGER,
1566
+ title TEXT NOT NULL,
1567
+ profile TEXT,
1568
+ provider TEXT,
1569
+ model TEXT,
1570
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1571
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1572
+ );
1573
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC);
1574
+ CREATE TABLE IF NOT EXISTS session_messages (
1575
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1576
+ session_id INTEGER NOT NULL,
1577
+ role TEXT NOT NULL,
1578
+ content TEXT NOT NULL,
1579
+ context_json TEXT,
1580
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1581
+ FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
1582
+ );
1583
+ CREATE INDEX IF NOT EXISTS idx_session_messages_session_id ON session_messages(session_id, id);
1584
+ CREATE TABLE IF NOT EXISTS feature_flags (
1585
+ name TEXT PRIMARY KEY,
1586
+ enabled INTEGER NOT NULL,
1587
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1588
+ );
1589
+ CREATE TABLE IF NOT EXISTS api_cache (
1590
+ key TEXT PRIMARY KEY,
1591
+ url TEXT NOT NULL,
1592
+ response_json TEXT NOT NULL,
1593
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1594
+ expires_at TEXT
1595
+ );
1596
+ CREATE TABLE IF NOT EXISTS saved_views (
1597
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1598
+ name TEXT NOT NULL UNIQUE,
1599
+ dataset TEXT NOT NULL,
1600
+ query_json TEXT NOT NULL,
1601
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1602
+ );
1603
+ `);
1604
+ db.prepare(`
1605
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1606
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
1607
+ `).run(String(DB_SCHEMA_VERSION));
1608
+ } finally {
1609
+ db.close();
1610
+ }
1611
+ }
1612
+
1613
+ function mkdirSyncSafe(directory) {
1614
+ try {
1615
+ mkdirSync(directory, { recursive: true });
1616
+ } catch {
1617
+ // Directory creation is retried by write operations where needed.
1618
+ }
1619
+ }
1620
+
1621
+ function getDbStatus() {
1622
+ try {
1623
+ initDatabase();
1624
+ const db = openDatabase();
1625
+ try {
1626
+ const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1627
+ const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
1628
+ const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1629
+ return {
1630
+ status: "ok",
1631
+ file: DB_FILE,
1632
+ schema: schema?.value || "-",
1633
+ history: history?.count ?? 0,
1634
+ sessions: sessions?.count ?? 0,
1635
+ };
1636
+ } finally {
1637
+ db.close();
1638
+ }
1639
+ } catch (error) {
1640
+ return {
1641
+ status: "error",
1642
+ file: DB_FILE,
1643
+ schema: "-",
1644
+ history: "-",
1645
+ error: error instanceof Error ? error.message : String(error),
1646
+ };
1647
+ }
1648
+ }
1649
+
1650
+ function recordAskHistory({ question, answer, providerConfig, dataContext, error, sessionId }) {
1651
+ try {
1652
+ initDatabase();
1653
+ const db = openDatabase();
1654
+ try {
1655
+ db.prepare(`
1656
+ INSERT INTO ask_history(profile, provider, model, question, answer, context_json, error)
1657
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1658
+ `).run(
1659
+ providerConfig.name || "",
1660
+ providerConfig.provider || "",
1661
+ providerConfig.model || "",
1662
+ question,
1663
+ answer,
1664
+ JSON.stringify(dataContext),
1665
+ error || "",
1666
+ );
1667
+ } finally {
1668
+ db.close();
1669
+ }
1670
+ } catch {
1671
+ // History must never break the main answer path.
1672
+ }
1673
+ }
1674
+
1675
+ function listHistory(limit) {
1676
+ initDatabase();
1677
+ const db = openDatabase();
1678
+ try {
1679
+ return db.prepare(`
1680
+ SELECT id, created_at, profile, provider, model, question, answer, error
1681
+ FROM ask_history
1682
+ ORDER BY id DESC
1683
+ LIMIT ?
1684
+ `).all(limit);
1685
+ } finally {
1686
+ db.close();
1687
+ }
1688
+ }
1689
+
1690
+ function clearHistory() {
1691
+ initDatabase();
1692
+ const db = openDatabase();
1693
+ try {
1694
+ db.exec("DELETE FROM ask_history");
1695
+ } finally {
1696
+ db.close();
1697
+ }
1698
+ }
1699
+
1700
+ function clearSessions() {
1701
+ initDatabase();
1702
+ const db = openDatabase();
1703
+ try {
1704
+ db.exec("DELETE FROM session_messages; DELETE FROM sessions;");
1705
+ } finally {
1706
+ db.close();
1707
+ }
1708
+ }
1709
+
1710
+ function createSession(providerConfig, title, parentId = null) {
1711
+ initDatabase();
1712
+ const db = openDatabase();
1713
+ try {
1714
+ const result = db.prepare(`
1715
+ INSERT INTO sessions(parent_id, title, profile, provider, model)
1716
+ VALUES (?, ?, ?, ?, ?)
1717
+ `).run(parentId, title.slice(0, 120), providerConfig.name || "", providerConfig.provider || "", providerConfig.model || "");
1718
+ return Number(result.lastInsertRowid);
1719
+ } finally {
1720
+ db.close();
1721
+ }
1722
+ }
1723
+
1724
+ function ensureSessionForAsk(options, providerConfig, question) {
1725
+ if (options.session) {
1726
+ return Number(options.session);
1727
+ }
1728
+ return createSession(providerConfig, question);
1729
+ }
1730
+
1731
+ function appendSessionExchange(sessionId, question, answer, dataContext, error) {
1732
+ if (!sessionId) {
1733
+ return;
1734
+ }
1735
+ const db = openDatabase();
1736
+ try {
1737
+ db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'user', ?, ?)")
1738
+ .run(sessionId, question, JSON.stringify(dataContext));
1739
+ db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'assistant', ?, ?)")
1740
+ .run(sessionId, error || answer || "", JSON.stringify({ error: error || "" }));
1741
+ db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
1742
+ } finally {
1743
+ db.close();
1744
+ }
1745
+ }
1746
+
1747
+ function listSessions(limit) {
1748
+ initDatabase();
1749
+ const db = openDatabase();
1750
+ try {
1751
+ return db.prepare(`
1752
+ SELECT s.id, s.updated_at, s.profile, s.provider, s.model, s.title, COUNT(m.id) AS messages
1753
+ FROM sessions s
1754
+ LEFT JOIN session_messages m ON m.session_id = s.id
1755
+ GROUP BY s.id
1756
+ ORDER BY s.updated_at DESC, s.id DESC
1757
+ LIMIT ?
1758
+ `).all(limit);
1759
+ } finally {
1760
+ db.close();
1761
+ }
1762
+ }
1763
+
1764
+ function getSessionAiHistory(sessionId) {
1765
+ initDatabase();
1766
+ const db = openDatabase();
1767
+ try {
1768
+ return db.prepare("SELECT role, content FROM session_messages WHERE session_id = ? ORDER BY id ASC")
1769
+ .all(sessionId)
1770
+ .map((row) => ({ role: row.role, content: row.content }));
1771
+ } finally {
1772
+ db.close();
1773
+ }
1774
+ }
1775
+
1776
+ function getSession(sessionId) {
1777
+ initDatabase();
1778
+ const db = openDatabase();
1779
+ try {
1780
+ const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId);
1781
+ if (!session) {
1782
+ throw new Error(`Сессия не найдена: ${sessionId}`);
1783
+ }
1784
+ return session;
1785
+ } finally {
1786
+ db.close();
1787
+ }
1788
+ }
1789
+
1790
+ function printSessionMessages(sessionId) {
1791
+ const rows = getSessionAiHistory(sessionId).map((row, index) => ({ id: index + 1, ...row }));
1792
+ printTable(rows, [
1793
+ ["id", "#"],
1794
+ ["role", "Роль"],
1795
+ ["content", "Текст"],
1796
+ ]);
1797
+ }
1798
+
1799
+ function forkSessionInDb(sessionId) {
1800
+ initDatabase();
1801
+ const db = openDatabase();
1802
+ try {
1803
+ const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId);
1804
+ if (!session) {
1805
+ throw new Error(`Сессия не найдена: ${sessionId}`);
1806
+ }
1807
+ const result = db.prepare(`
1808
+ INSERT INTO sessions(parent_id, title, profile, provider, model)
1809
+ VALUES (?, ?, ?, ?, ?)
1810
+ `).run(sessionId, `Fork: ${session.title}`, session.profile, session.provider, session.model);
1811
+ const newId = Number(result.lastInsertRowid);
1812
+ const messages = db.prepare("SELECT role, content, context_json FROM session_messages WHERE session_id = ? ORDER BY id ASC").all(sessionId);
1813
+ const insert = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, ?, ?, ?)");
1814
+ for (const message of messages) {
1815
+ insert.run(newId, message.role, message.content, message.context_json);
1816
+ }
1817
+ return newId;
1818
+ } finally {
1819
+ db.close();
1820
+ }
1821
+ }
1822
+
1823
+ function listFeatures() {
1824
+ initDatabase();
1825
+ const db = openDatabase();
1826
+ try {
1827
+ return Object.entries(FEATURES).map(([name, meta]) => {
1828
+ const row = db.prepare("SELECT enabled FROM feature_flags WHERE name = ?").get(name);
1829
+ const enabled = row ? Boolean(row.enabled) : meta.defaultEnabled;
1830
+ return { name, enabled: enabled ? "yes" : "no", stage: meta.stage, description: meta.description };
1831
+ });
1832
+ } finally {
1833
+ db.close();
1834
+ }
1835
+ }
1836
+
1837
+ function isFeatureEnabled(name) {
1838
+ const meta = FEATURES[name];
1839
+ if (!meta) {
1840
+ return false;
1841
+ }
1842
+ initDatabase();
1843
+ const db = openDatabase();
1844
+ try {
1845
+ const row = db.prepare("SELECT enabled FROM feature_flags WHERE name = ?").get(name);
1846
+ return row ? Boolean(row.enabled) : meta.defaultEnabled;
1847
+ } finally {
1848
+ db.close();
1849
+ }
1850
+ }
1851
+
1852
+ function setFeatureEnabled(name, enabled) {
1853
+ initDatabase();
1854
+ const db = openDatabase();
1855
+ try {
1856
+ db.prepare(`
1857
+ INSERT INTO feature_flags(name, enabled, updated_at) VALUES (?, ?, datetime('now'))
1858
+ ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at
1859
+ `).run(name, enabled ? 1 : 0);
1860
+ } finally {
1861
+ db.close();
1862
+ }
1863
+ }
1864
+
1253
1865
  function assertKeyProvider(provider) {
1254
1866
  if (provider !== "openai" && provider !== "openrouter") {
1255
1867
  throw new Error("Провайдер должен быть openai или openrouter.");
@@ -1339,9 +1951,43 @@ async function aiAsk(args, context = {}) {
1339
1951
 
1340
1952
  const config = await loadConfig();
1341
1953
  const providerConfig = resolveAiProfile(config, options);
1954
+ applyRuntimeConfig(providerConfig, options.config);
1342
1955
  const dataContext = await buildDataContext(question);
1343
- const messages = buildAiMessages(question, dataContext, context.history || []);
1344
- const answer = await callAiProvider(providerConfig, messages);
1956
+ emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
1957
+ const historyEnabled = !options["no-history"] && isFeatureEnabled("sqlite-history");
1958
+ const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
1959
+ const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
1960
+ const messages = buildAiMessages(question, dataContext, history, options);
1961
+ let answer = "";
1962
+ let errorMessage = "";
1963
+
1964
+ try {
1965
+ emitEvent(options, "provider_selected", { profile: providerConfig.name, provider: providerConfig.provider, model: providerConfig.model });
1966
+ answer = await callAiProvider(providerConfig, messages);
1967
+ } catch (error) {
1968
+ errorMessage = error instanceof Error ? error.message : String(error);
1969
+ if (historyEnabled) {
1970
+ recordAskHistory({ question, answer: "", providerConfig, dataContext, error: errorMessage, sessionId });
1971
+ appendSessionExchange(sessionId, question, "", dataContext, errorMessage);
1972
+ }
1973
+ throw error;
1974
+ }
1975
+
1976
+ if (historyEnabled) {
1977
+ recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
1978
+ appendSessionExchange(sessionId, question, answer, dataContext, "");
1979
+ }
1980
+
1981
+ emitEvent(options, "answer", { length: answer.length, sessionId });
1982
+
1983
+ if (options.output) {
1984
+ await writeFile(options.output, answer, "utf8");
1985
+ }
1986
+
1987
+ if (options.format === "json" || options.schema === "json") {
1988
+ printJson({ answer, profile: providerConfig.name, provider: providerConfig.provider, model: providerConfig.model, sessionId, context: dataContext });
1989
+ return answer;
1990
+ }
1345
1991
 
1346
1992
  console.log(answer);
1347
1993
  return answer;
@@ -1364,9 +2010,28 @@ function resolveAiProfile(config, options = {}) {
1364
2010
  provider,
1365
2011
  model: options.model || activeProfile.model || config.ai.model,
1366
2012
  baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
2013
+ temperature: options.temperature || activeProfile.temperature,
1367
2014
  };
1368
2015
  }
1369
2016
 
2017
+ function applyRuntimeConfig(target, value) {
2018
+ if (!value) {
2019
+ return;
2020
+ }
2021
+ const [key, ...parts] = String(value).split("=");
2022
+ if (!key || parts.length === 0) {
2023
+ throw new Error("Флаг --config должен быть в формате key=value.");
2024
+ }
2025
+ setConfigValue(target, key, parts.join("="));
2026
+ }
2027
+
2028
+ function emitEvent(options, type, data) {
2029
+ if (!options.events) {
2030
+ return;
2031
+ }
2032
+ printJson({ type, at: new Date().toISOString(), ...data });
2033
+ }
2034
+
1370
2035
  async function buildDataContext(question) {
1371
2036
  const apiBaseUrl = await getApiBaseUrl();
1372
2037
  const mcpBaseUrl = await getMcpBaseUrl();
@@ -1489,7 +2154,7 @@ function scoreItem(item, terms, patterns, layer) {
1489
2154
  return score;
1490
2155
  }
1491
2156
 
1492
- function buildAiMessages(question, dataContext, history) {
2157
+ function buildAiMessages(question, dataContext, history, options = {}) {
1493
2158
  const sourceLines = buildSourceLines(dataContext);
1494
2159
  const system = [
1495
2160
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
@@ -1498,8 +2163,10 @@ function buildAiMessages(question, dataContext, history) {
1498
2163
  "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
1499
2164
  "Не выдумывай адреса, телефоны, лицензии и руководителей.",
1500
2165
  "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
2166
+ options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
2167
+ options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
1501
2168
  "Отвечай кратко и по делу.",
1502
- ].join(" ");
2169
+ ].filter(Boolean).join(" ");
1503
2170
  const contextText = JSON.stringify(dataContext, null, 2);
1504
2171
  const recentHistory = history.slice(-6);
1505
2172
 
@@ -1625,7 +2292,7 @@ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
1625
2292
  body: JSON.stringify({
1626
2293
  model: config.model,
1627
2294
  messages,
1628
- temperature: 0.2,
2295
+ temperature: Number(config.temperature ?? 0.2),
1629
2296
  }),
1630
2297
  });
1631
2298
 
@@ -1815,9 +2482,9 @@ async function setupClient(args) {
1815
2482
  throw new Error('Only "iola setup codex" is available in this first release.');
1816
2483
  }
1817
2484
 
1818
- console.log("Run:");
1819
- console.log(" codex mcp add yoshkarOlaPublicData --url https://apiiola.yasg.ru/mcp");
1820
- console.log(" npx -y @iola_adm/yoshkar-ola-public-mcp install-skill codex");
2485
+ await runCommand("codex", ["mcp", "add", "yoshkarOlaPublicData", "--url", `${await getMcpBaseUrl()}/mcp`], { inherit: true });
2486
+ await runCommand("npx", ["-y", "@iola_adm/yoshkar-ola-public-mcp", "install-skill", "codex"], { inherit: true });
2487
+ console.log("Codex MCP и skill установлены.");
1821
2488
  }
1822
2489
 
1823
2490
  function parseOptions(args) {
@@ -1825,12 +2492,12 @@ function parseOptions(args) {
1825
2492
 
1826
2493
  for (let index = 0; index < args.length; index += 1) {
1827
2494
  const arg = args[index];
1828
- if (arg === "--json" || arg === "--yes") {
2495
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all") {
1829
2496
  result[arg.slice(2)] = true;
1830
2497
  } else if (arg === "--check" || arg === "--upgrade-node") {
1831
2498
  result.check = true;
1832
2499
  result[arg.slice(2)] = true;
1833
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format") {
2500
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config") {
1834
2501
  result[arg.slice(2)] = args[index + 1];
1835
2502
  index += 1;
1836
2503
  } else {