@iola_adm/iola-cli 0.1.15 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { mkdirSync } from "node:fs";
3
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline/promises";
@@ -14,7 +14,9 @@ const CONFIG_DIR = path.join(os.homedir(), ".iola");
14
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
15
15
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
16
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
- const DB_SCHEMA_VERSION = 2;
17
+ const DB_SCHEMA_VERSION = 3;
18
+ const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
19
+ const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
18
20
  const FEATURES = {
19
21
  "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
20
22
  sessions: { stage: "stable", defaultEnabled: true, description: "Сессии, resume и fork для AI-диалогов." },
@@ -58,6 +60,63 @@ const DEFAULT_AI_CONFIG = {
58
60
  },
59
61
  },
60
62
  },
63
+ permissions: {
64
+ localTools: {
65
+ search_local: true,
66
+ get_card: true,
67
+ export_data: true,
68
+ run_report: true,
69
+ save_view: true,
70
+ },
71
+ writeFiles: true,
72
+ sync: true,
73
+ externalApi: true,
74
+ externalAi: true,
75
+ codex: true,
76
+ },
77
+ memory: {
78
+ enabled: true,
79
+ },
80
+ hooks: {},
81
+ };
82
+ const AGENTS = {
83
+ "data-analyst": {
84
+ profile: null,
85
+ tools: true,
86
+ reasoning: "verify",
87
+ description: "Анализирует открытые данные, ищет объекты и отвечает с опорой на локальные данные.",
88
+ },
89
+ "quality-checker": {
90
+ profile: "local",
91
+ tools: true,
92
+ reasoning: "verify",
93
+ prefix: "Проверь качество данных и укажи найденные проблемы: ",
94
+ description: "Проверяет телефоны, email, ИНН и неполные карточки.",
95
+ },
96
+ exporter: {
97
+ profile: "local",
98
+ tools: true,
99
+ reasoning: "fast",
100
+ prefix: "Подготовь выгрузку данных: ",
101
+ description: "Готовит CSV/JSON выгрузки через локальные инструменты.",
102
+ },
103
+ "mcp-helper": {
104
+ profile: null,
105
+ tools: false,
106
+ description: "Помогает с MCP, профилями AI и диагностикой подключения.",
107
+ },
108
+ "local-fast": {
109
+ profile: "local",
110
+ tools: true,
111
+ reasoning: "fast",
112
+ description: "Быстрый локальный режим для простых запросов.",
113
+ },
114
+ reviewer: {
115
+ profile: null,
116
+ tools: false,
117
+ prefix: "Проверь ответ и найди слабые места: ",
118
+ description: "Режим проверки и уточнения ответов.",
119
+ },
61
120
  };
62
121
  const DATASETS = {
63
122
  schools: {
@@ -94,11 +153,18 @@ const COMMANDS = new Map([
94
153
  ["resume", resumeSession],
95
154
  ["fork", forkSession],
96
155
  ["features", handleFeatures],
156
+ ["permissions", handlePermissions],
157
+ ["memory", handleMemory],
158
+ ["hooks", handleHooks],
159
+ ["agents", handleAgents],
97
160
  ["mcp", handleMcp],
98
161
  ["cache", handleCache],
99
162
  ["sync", handleSync],
163
+ ["diff", handleDiff],
100
164
  ["views", handleViews],
101
165
  ["view", handleView],
166
+ ["card", handleCard],
167
+ ["quality", handleQuality],
102
168
  ["report", handleReport],
103
169
  ["privacy", handlePrivacy],
104
170
  ["backup", handleBackup],
@@ -122,6 +188,28 @@ const COMMANDS = new Map([
122
188
  ]);
123
189
 
124
190
  export async function main(argv) {
191
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
192
+ await showHelp();
193
+ return;
194
+ }
195
+
196
+ const runtime = parseGlobalOptions(argv);
197
+ if (runtime.help) {
198
+ await showHelp();
199
+ return;
200
+ }
201
+ if (runtime.debug) {
202
+ process.env.IOLA_DEBUG = "1";
203
+ }
204
+ if (runtime.debugFile) {
205
+ process.env.IOLA_DEBUG = "1";
206
+ process.env.IOLA_DEBUG_FILE = runtime.debugFile;
207
+ }
208
+ if (runtime.noColor) {
209
+ process.env.NO_COLOR = "1";
210
+ }
211
+
212
+ argv = runtime.args;
125
213
  const [command = "help", ...args] = argv;
126
214
  const nodeStatus = getNodeRequirementStatus();
127
215
  if (!nodeStatus.ok && !["help", "version", "doctor", "init"].includes(command)) {
@@ -139,7 +227,7 @@ export async function main(argv) {
139
227
  throw new Error(`Unknown command: ${command}\nRun "iola help" to see available commands.`);
140
228
  }
141
229
 
142
- await handler(args);
230
+ await handler(runtime.debugFile ? [...args, "--debug-file", runtime.debugFile] : args);
143
231
  }
144
232
 
145
233
  async function showHelp() {
@@ -160,9 +248,18 @@ Usage:
160
248
  iola resume SESSION_ID [TEXT]
161
249
  iola fork SESSION_ID [TEXT]
162
250
  iola features list|enable|disable
251
+ iola permissions list|allow|deny
252
+ iola memory show|add|set|clear|export
253
+ iola hooks list|add|delete|run
254
+ iola agents list|run
163
255
  iola mcp list|status|install|remove
164
256
  iola cache status|warm|clear
165
257
  iola sync [--dataset schools|kindergartens]
258
+ iola sync status
259
+ iola diff [schools|kindergartens]
260
+ iola card schools 1215067180
261
+ iola card "школа 29"
262
+ iola quality [schools|kindergartens|missing-phones|invalid-emails|duplicate-inn]
166
263
  iola views
167
264
  iola view NAME [--format table|json|csv] [--output FILE]
168
265
  iola report schools-summary|education-contacts|missing-phones|licenses
@@ -175,7 +272,7 @@ Usage:
175
272
  iola config set api.mcpBaseUrl URL
176
273
  iola config reset
177
274
  iola update
178
- iola ask TEXT [--profile NAME] [--model MODEL] [--output FILE] [--schema json|table] [--events] [--no-history]
275
+ iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
179
276
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
180
277
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
181
278
  iola ai context TEXT [--json]
@@ -214,6 +311,7 @@ Requirements:
214
311
  async function startAgent() {
215
312
  showBanner();
216
313
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
314
+ await runHooks("SessionStart", { mode: "agent" });
217
315
 
218
316
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
219
317
  const state = {
@@ -248,6 +346,7 @@ async function startAgent() {
248
346
  if (!closed) {
249
347
  rl.close();
250
348
  }
349
+ await runHooks("SessionEnd", { mode: "agent" });
251
350
  }
252
351
 
253
352
  async function handleAgentLine(line, state) {
@@ -309,6 +408,31 @@ async function handleAgentLine(line, state) {
309
408
  return false;
310
409
  }
311
410
 
411
+ if (command === "permissions") {
412
+ await handlePermissions(args);
413
+ return false;
414
+ }
415
+
416
+ if (command === "memory") {
417
+ await handleMemory(args);
418
+ return false;
419
+ }
420
+
421
+ if (command === "hooks") {
422
+ await handleHooks(args);
423
+ return false;
424
+ }
425
+
426
+ if (command === "agents") {
427
+ await handleAgents(args);
428
+ return false;
429
+ }
430
+
431
+ if (command === "tools") {
432
+ await handlePermissions(["tools"]);
433
+ return false;
434
+ }
435
+
312
436
  if (command === "mcp") {
313
437
  await handleMcp(args);
314
438
  return false;
@@ -324,7 +448,7 @@ async function handleAgentLine(line, state) {
324
448
  return false;
325
449
  }
326
450
 
327
- if (command === "views" || command === "view" || command === "report" || command === "privacy" || command === "backup" || command === "alias" || command === "run") {
451
+ if (command === "diff" || command === "card" || command === "quality" || command === "views" || command === "view" || command === "report" || command === "privacy" || command === "backup" || command === "alias" || command === "run") {
328
452
  await COMMANDS.get(command)(args);
329
453
  return false;
330
454
  }
@@ -413,9 +537,15 @@ async function handleAgentLine(line, state) {
413
537
  resume: ["resume", args],
414
538
  fork: ["fork", args],
415
539
  features: ["features", args],
540
+ permissions: ["permissions", args],
541
+ memory: ["memory", args],
542
+ hooks: ["hooks", args],
543
+ agents: ["agents", args],
544
+ tools: ["permissions", ["tools", ...args]],
416
545
  mcp: ["mcp", args],
417
546
  cache: ["cache", args],
418
547
  sync: ["sync", args],
548
+ diff: ["diff", args],
419
549
  config: ["config", args],
420
550
  layers: ["layers", args],
421
551
  data: ["data", args],
@@ -446,9 +576,17 @@ function printAgentHelp() {
446
576
  /sessions
447
577
  /resume SESSION_ID
448
578
  /features list
579
+ /permissions
580
+ /tools
581
+ /memory show
582
+ /hooks list
583
+ /agents list
449
584
  /mcp status
450
585
  /cache status
451
586
  /sync
587
+ /diff
588
+ /card школа 29
589
+ /quality
452
590
  /views
453
591
  /config get
454
592
  /config set api.baseUrl URL
@@ -1051,6 +1189,208 @@ async function handleFeatures(args) {
1051
1189
  throw new Error("Команды features: list, enable NAME, disable NAME.");
1052
1190
  }
1053
1191
 
1192
+ async function handlePermissions(args) {
1193
+ const [action = "list", name] = args;
1194
+ const config = await loadConfig();
1195
+
1196
+ if (action === "list" || action === "ls" || action === "tools") {
1197
+ const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
1198
+ const rows = [
1199
+ ...LOCAL_TOOLS.map((tool) => ({
1200
+ permission: `localTools.${tool}`,
1201
+ value: permissions.localTools?.[tool] === false ? "deny" : "allow",
1202
+ scope: "local-tool",
1203
+ })),
1204
+ { permission: "writeFiles", value: permissions.writeFiles === false ? "deny" : "allow", scope: "runtime" },
1205
+ { permission: "sync", value: permissions.sync === false ? "deny" : "allow", scope: "runtime" },
1206
+ { permission: "externalApi", value: permissions.externalApi === false ? "deny" : "allow", scope: "network" },
1207
+ { permission: "externalAi", value: permissions.externalAi === false ? "deny" : "allow", scope: "network" },
1208
+ { permission: "codex", value: permissions.codex === false ? "deny" : "allow", scope: "external-cli" },
1209
+ ];
1210
+ printTable(rows, [
1211
+ ["permission", "Разрешение"],
1212
+ ["value", "Статус"],
1213
+ ["scope", "Область"],
1214
+ ]);
1215
+ return;
1216
+ }
1217
+
1218
+ if (action === "allow" || action === "deny") {
1219
+ if (!name) {
1220
+ throw new Error("Пример: iola permissions deny export_data");
1221
+ }
1222
+ const allow = action === "allow";
1223
+ const next = { ...(config.permissions || DEFAULT_AI_CONFIG.permissions) };
1224
+ next.localTools = { ...(next.localTools || {}) };
1225
+ if (LOCAL_TOOLS.includes(name)) {
1226
+ next.localTools[name] = allow;
1227
+ } else if (name in DEFAULT_AI_CONFIG.permissions) {
1228
+ next[name] = allow;
1229
+ } else {
1230
+ throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...LOCAL_TOOLS, "writeFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
1231
+ }
1232
+ await saveConfig({ permissions: next });
1233
+ console.log(`${name}: ${allow ? "allow" : "deny"}`);
1234
+ return;
1235
+ }
1236
+
1237
+ throw new Error("Команды permissions: list, tools, allow NAME, deny NAME.");
1238
+ }
1239
+
1240
+ async function handleMemory(args) {
1241
+ const [action = "show", ...rest] = args;
1242
+ const options = parseOptions(rest);
1243
+
1244
+ if (action === "show" || action === "list" || action === "ls") {
1245
+ const rows = listMemory(Number(options.limit || 50));
1246
+ if (options.json) {
1247
+ printJson(rows);
1248
+ return;
1249
+ }
1250
+ printTable(rows, [
1251
+ ["id", "ID"],
1252
+ ["scope", "Область"],
1253
+ ["content", "Память"],
1254
+ ["created_at", "Дата"],
1255
+ ]);
1256
+ return;
1257
+ }
1258
+
1259
+ if (action === "add" || action === "set") {
1260
+ const text = rest.join(" ").trim();
1261
+ if (!text) {
1262
+ throw new Error('Пример: iola memory add "Отвечай кратко и по данным Йошкар-Олы"');
1263
+ }
1264
+ const id = addMemory(text, options.scope || "user");
1265
+ console.log(`Память сохранена: ${id}`);
1266
+ return;
1267
+ }
1268
+
1269
+ if (action === "delete" || action === "remove" || action === "rm") {
1270
+ const id = rest[0];
1271
+ if (!id) throw new Error("Пример: iola memory delete 1");
1272
+ deleteMemory(Number(id));
1273
+ console.log(`Память удалена: ${id}`);
1274
+ return;
1275
+ }
1276
+
1277
+ if (action === "clear") {
1278
+ clearMemory();
1279
+ console.log("Память очищена.");
1280
+ return;
1281
+ }
1282
+
1283
+ if (action === "export") {
1284
+ const rows = listMemory(1000);
1285
+ const file = rest[0] || path.join(CONFIG_DIR, "memory-export.json");
1286
+ await writeFile(file, `${JSON.stringify(rows, null, 2)}\n`, "utf8");
1287
+ console.log(`Память экспортирована: ${file}`);
1288
+ return;
1289
+ }
1290
+
1291
+ throw new Error("Команды memory: show, add TEXT, delete ID, clear, export [FILE].");
1292
+ }
1293
+
1294
+ async function handleHooks(args) {
1295
+ const [action = "list", event, ...commandParts] = args;
1296
+ const config = await loadConfig();
1297
+
1298
+ if (action === "list" || action === "ls") {
1299
+ const rows = Object.entries(config.hooks || {}).flatMap(([hookEvent, commands]) =>
1300
+ (commands || []).map((command, index) => ({ event: hookEvent, index, command })));
1301
+ printTable(rows, [
1302
+ ["event", "Событие"],
1303
+ ["index", "#"],
1304
+ ["command", "Команда"],
1305
+ ]);
1306
+ return;
1307
+ }
1308
+
1309
+ if (action === "events") {
1310
+ printTable(HOOK_EVENTS.map((name) => ({ name })), [["name", "Событие"]]);
1311
+ return;
1312
+ }
1313
+
1314
+ if (action === "add") {
1315
+ if (!HOOK_EVENTS.includes(event) || commandParts.length === 0) {
1316
+ throw new Error(`Пример: iola hooks add AfterSync "iola quality" Доступно: ${HOOK_EVENTS.join(", ")}`);
1317
+ }
1318
+ const hooks = { ...(config.hooks || {}) };
1319
+ hooks[event] = [...(hooks[event] || []), commandParts.join(" ")];
1320
+ await saveConfig({ hooks });
1321
+ console.log(`Hook добавлен: ${event}`);
1322
+ return;
1323
+ }
1324
+
1325
+ if (action === "delete" || action === "remove") {
1326
+ const index = Number(commandParts[0] ?? event);
1327
+ const hookEvent = Number.isFinite(Number(event)) ? null : event;
1328
+ const hooks = { ...(config.hooks || {}) };
1329
+ if (hookEvent) {
1330
+ hooks[hookEvent] = (hooks[hookEvent] || []).filter((_, itemIndex) => itemIndex !== index);
1331
+ } else {
1332
+ for (const key of Object.keys(hooks)) hooks[key] = (hooks[key] || []).filter((_, itemIndex) => itemIndex !== index);
1333
+ }
1334
+ await saveConfig({ hooks });
1335
+ console.log("Hook удален.");
1336
+ return;
1337
+ }
1338
+
1339
+ if (action === "run") {
1340
+ if (!HOOK_EVENTS.includes(event)) throw new Error(`Событие обязательно: ${HOOK_EVENTS.join(", ")}`);
1341
+ await runHooks(event, { manual: true });
1342
+ return;
1343
+ }
1344
+
1345
+ throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT.");
1346
+ }
1347
+
1348
+ async function handleAgents(args) {
1349
+ const [action = "list", name, ...rest] = args;
1350
+
1351
+ if (action === "list" || action === "ls") {
1352
+ const rows = Object.entries(AGENTS).map(([agent, meta]) => ({
1353
+ agent,
1354
+ profile: meta.profile || "active",
1355
+ tools: meta.tools ? "yes" : "no",
1356
+ reasoning: meta.reasoning || "-",
1357
+ description: meta.description,
1358
+ }));
1359
+ printTable(rows, [
1360
+ ["agent", "Агент"],
1361
+ ["profile", "Профиль"],
1362
+ ["tools", "Tools"],
1363
+ ["reasoning", "Reasoning"],
1364
+ ["description", "Описание"],
1365
+ ]);
1366
+ return;
1367
+ }
1368
+
1369
+ if (action === "run") {
1370
+ if (!AGENTS[name]) {
1371
+ throw new Error(`Неизвестный агент: ${name}. Доступно: ${Object.keys(AGENTS).join(", ")}`);
1372
+ }
1373
+ const agent = AGENTS[name];
1374
+ const options = parseOptions(rest);
1375
+ const question = options._.join(" ").trim();
1376
+ if (!question) throw new Error(`Пример: iola agents run ${name} "найди школы на Петрова"`);
1377
+ const askArgs = [agent.prefix ? `${agent.prefix}${question}` : question, "--agent", name];
1378
+ if (agent.profile) askArgs.push("--profile", agent.profile);
1379
+ if (agent.tools) askArgs.push("--tools");
1380
+ if (agent.reasoning) askArgs.push("--reasoning", agent.reasoning);
1381
+ for (const flag of ["no-history", "quiet", "bare", "events", "fail-on-empty"]) {
1382
+ if (options[flag]) askArgs.push(`--${flag}`);
1383
+ }
1384
+ for (const flag of ["profile", "model", "output", "schema", "format", "reasoning"]) {
1385
+ if (options[flag]) askArgs.push(`--${flag}`, options[flag]);
1386
+ }
1387
+ await aiAsk(askArgs);
1388
+ return;
1389
+ }
1390
+
1391
+ throw new Error("Команды agents: list, run NAME TEXT.");
1392
+ }
1393
+
1054
1394
  async function handleMcp(args) {
1055
1395
  const [action = "status", target = "codex"] = args;
1056
1396
 
@@ -1109,12 +1449,24 @@ async function handleCache(args) {
1109
1449
  }
1110
1450
 
1111
1451
  async function handleSync(args) {
1452
+ const [action] = args;
1453
+ if (action === "status") {
1454
+ printTable(getSyncStatus(), [
1455
+ ["dataset", "Слой"],
1456
+ ["records", "Записей"],
1457
+ ["last_sync", "Последний sync"],
1458
+ ["status", "Статус"],
1459
+ ]);
1460
+ return;
1461
+ }
1462
+ await assertPermission("sync");
1112
1463
  const options = parseOptions(args);
1113
1464
  const datasets = options.dataset ? [options.dataset] : Object.keys(DATASETS);
1114
1465
  const rows = [];
1115
1466
  for (const dataset of datasets) {
1116
1467
  rows.push(await syncDataset(dataset));
1117
1468
  }
1469
+ await runHooks("AfterSync", { datasets, rows });
1118
1470
  printTable(rows, [
1119
1471
  ["dataset", "Слой"],
1120
1472
  ["records", "Записей"],
@@ -1123,6 +1475,44 @@ async function handleSync(args) {
1123
1475
  ]);
1124
1476
  }
1125
1477
 
1478
+ async function handleDiff(args) {
1479
+ const [dataset] = args;
1480
+ const rows = listSyncChanges(dataset);
1481
+ printTable(rows, [
1482
+ ["created_at", "Дата"],
1483
+ ["dataset", "Слой"],
1484
+ ["change_type", "Тип"],
1485
+ ["record_key", "Ключ"],
1486
+ ["summary", "Сводка"],
1487
+ ]);
1488
+ }
1489
+
1490
+ async function handleCard(args) {
1491
+ await ensureLocalData();
1492
+ const options = parseOptions(args);
1493
+ const query = args.join(" ").trim();
1494
+ if (!query) throw new Error('Пример: iola card "школа 29"');
1495
+ const item = findCard(query);
1496
+ if (!item) throw new Error(`Объект не найден: ${query}`);
1497
+ if (options.json) {
1498
+ printJson(item);
1499
+ return;
1500
+ }
1501
+ printKeyValue(item);
1502
+ }
1503
+
1504
+ async function handleQuality(args) {
1505
+ const [scope = "all"] = args;
1506
+ await ensureLocalData();
1507
+ const rows = runQuality(scope);
1508
+ printTable(rows, [
1509
+ ["check", "Проверка"],
1510
+ ["dataset", "Слой"],
1511
+ ["count", "Кол-во"],
1512
+ ["sample", "Пример"],
1513
+ ]);
1514
+ }
1515
+
1126
1516
  async function handleViews(args) {
1127
1517
  const [action, name] = args;
1128
1518
  if (action === "delete" || action === "remove") {
@@ -1803,11 +2193,28 @@ function initDatabase() {
1803
2193
  message TEXT,
1804
2194
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
1805
2195
  );
2196
+ CREATE TABLE IF NOT EXISTS sync_changes (
2197
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2198
+ dataset TEXT NOT NULL,
2199
+ record_key TEXT NOT NULL,
2200
+ change_type TEXT NOT NULL,
2201
+ old_json TEXT,
2202
+ new_json TEXT,
2203
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2204
+ );
2205
+ CREATE INDEX IF NOT EXISTS idx_sync_changes_dataset_created_at ON sync_changes(dataset, created_at DESC);
1806
2206
  CREATE TABLE IF NOT EXISTS aliases (
1807
2207
  name TEXT PRIMARY KEY,
1808
2208
  command TEXT NOT NULL,
1809
2209
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
1810
2210
  );
2211
+ CREATE TABLE IF NOT EXISTS memory (
2212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2213
+ scope TEXT NOT NULL DEFAULT 'user',
2214
+ content TEXT NOT NULL,
2215
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2216
+ );
2217
+ CREATE INDEX IF NOT EXISTS idx_memory_created_at ON memory(created_at DESC);
1811
2218
  `);
1812
2219
  db.prepare(`
1813
2220
  INSERT INTO meta(key, value) VALUES ('schema_version', ?)
@@ -1836,6 +2243,7 @@ function getDbStatus() {
1836
2243
  const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1837
2244
  const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
1838
2245
  const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
2246
+ const memory = db.prepare("SELECT COUNT(*) AS count FROM memory").get();
1839
2247
  return {
1840
2248
  status: "ok",
1841
2249
  file: DB_FILE,
@@ -1844,6 +2252,7 @@ function getDbStatus() {
1844
2252
  sessions: sessions?.count ?? 0,
1845
2253
  local_records: local?.count ?? 0,
1846
2254
  cache: cache?.count ?? 0,
2255
+ memory: memory?.count ?? 0,
1847
2256
  };
1848
2257
  } finally {
1849
2258
  db.close();
@@ -2157,6 +2566,7 @@ async function syncDataset(dataset) {
2157
2566
  if (!DATASETS[dataset]) {
2158
2567
  throw new Error(`Неизвестный слой: ${dataset}`);
2159
2568
  }
2569
+ await assertPermission("externalApi");
2160
2570
  try {
2161
2571
  const payload = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
2162
2572
  const items = normalizeItems(payload);
@@ -2174,17 +2584,29 @@ function saveLocalRecords(dataset, items) {
2174
2584
  initDatabase();
2175
2585
  const db = openDatabase();
2176
2586
  try {
2587
+ const oldRows = db.prepare("SELECT record_key, record_json FROM local_records WHERE dataset = ?").all(dataset);
2588
+ const oldMap = new Map(oldRows.map((row) => [row.record_key, row.record_json]));
2589
+ const newKeys = new Set();
2177
2590
  db.prepare("DELETE FROM local_records WHERE dataset = ?").run(dataset);
2178
2591
  db.prepare("DELETE FROM local_records_fts WHERE dataset = ?").run(dataset);
2179
2592
  const insert = db.prepare("INSERT INTO local_records(dataset, record_key, record_json, searchable_text, synced_at) VALUES (?, ?, ?, ?, datetime('now'))");
2180
2593
  const insertFts = db.prepare("INSERT INTO local_records_fts(dataset, record_key, searchable_text) VALUES (?, ?, ?)");
2594
+ const insertChange = db.prepare("INSERT INTO sync_changes(dataset, record_key, change_type, old_json, new_json) VALUES (?, ?, ?, ?, ?)");
2181
2595
  for (const item of items) {
2182
2596
  const summary = selectPublicSummary(item);
2183
2597
  const key = String(summary.inn || item.id || `${dataset}-${Math.random()}`);
2598
+ newKeys.add(key);
2599
+ const newJson = JSON.stringify(item);
2600
+ const oldJson = oldMap.get(key);
2601
+ if (!oldJson) insertChange.run(dataset, key, "added", null, newJson);
2602
+ else if (oldJson !== newJson) insertChange.run(dataset, key, "changed", oldJson, newJson);
2184
2603
  const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
2185
- insert.run(dataset, key, JSON.stringify(item), text);
2604
+ insert.run(dataset, key, newJson, text);
2186
2605
  insertFts.run(dataset, key, text);
2187
2606
  }
2607
+ for (const [key, oldJson] of oldMap.entries()) {
2608
+ if (!newKeys.has(key)) insertChange.run(dataset, key, "removed", oldJson, null);
2609
+ }
2188
2610
  } finally {
2189
2611
  db.close();
2190
2612
  }
@@ -2213,6 +2635,14 @@ function searchLocalRecords(query, options = {}) {
2213
2635
  const dataset = options.dataset || "all";
2214
2636
  const limit = Number(options.limit || 20);
2215
2637
  try {
2638
+ if (options.fts && query) {
2639
+ const ftsQuery = query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, "")}"`).join(" ");
2640
+ const params = dataset === "all" ? [ftsQuery, limit] : [ftsQuery, dataset, limit];
2641
+ const sql = dataset === "all"
2642
+ ? "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? LIMIT ?"
2643
+ : "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? AND f.dataset = ? LIMIT ?";
2644
+ return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
2645
+ }
2216
2646
  const params = [];
2217
2647
  let sql = "SELECT dataset, record_json FROM local_records";
2218
2648
  const where = [];
@@ -2233,6 +2663,76 @@ function searchLocalRecords(query, options = {}) {
2233
2663
  }
2234
2664
  }
2235
2665
 
2666
+ function getSyncStatus() {
2667
+ initDatabase();
2668
+ const db = openDatabase();
2669
+ try {
2670
+ return Object.keys(DATASETS).map((dataset) => {
2671
+ const records = db.prepare("SELECT COUNT(*) AS count FROM local_records WHERE dataset = ?").get(dataset);
2672
+ const run = db.prepare("SELECT status, created_at FROM sync_runs WHERE dataset = ? ORDER BY id DESC LIMIT 1").get(dataset);
2673
+ return { dataset, records: records?.count || 0, last_sync: run?.created_at || "-", status: run?.status || "never" };
2674
+ });
2675
+ } finally {
2676
+ db.close();
2677
+ }
2678
+ }
2679
+
2680
+ function listSyncChanges(dataset) {
2681
+ initDatabase();
2682
+ const db = openDatabase();
2683
+ try {
2684
+ const rows = dataset
2685
+ ? db.prepare("SELECT * FROM sync_changes WHERE dataset = ? ORDER BY id DESC LIMIT 50").all(dataset)
2686
+ : db.prepare("SELECT * FROM sync_changes ORDER BY id DESC LIMIT 50").all();
2687
+ return rows.map((row) => ({
2688
+ ...row,
2689
+ summary: summarizeChange(row),
2690
+ }));
2691
+ } finally {
2692
+ db.close();
2693
+ }
2694
+ }
2695
+
2696
+ function summarizeChange(row) {
2697
+ const payload = row.new_json || row.old_json;
2698
+ if (!payload) return "-";
2699
+ try {
2700
+ const item = selectPublicSummary(JSON.parse(payload));
2701
+ return item.name || item.inn || "-";
2702
+ } catch {
2703
+ return "-";
2704
+ }
2705
+ }
2706
+
2707
+ function findCard(query) {
2708
+ const normalized = query.toLocaleLowerCase("ru-RU");
2709
+ const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
2710
+ const inn = normalized.match(/\b\d{10,12}\b/)?.[0];
2711
+ const number = normalized.match(/\b\d{1,3}\b/)?.[0];
2712
+ const rows = searchLocalRecords(inn || number || query, { dataset, limit: 20, fts: false });
2713
+ if (inn) return rows.find((row) => String(row.inn) === inn) || null;
2714
+ if (number) return rows.find((row) => String(row.name || "").includes(`№ ${number}`) || String(row.name || "").includes(`№${number}`)) || rows[0] || null;
2715
+ return rows[0] || null;
2716
+ }
2717
+
2718
+ function runQuality(scope) {
2719
+ const datasets = ["schools", "kindergartens"];
2720
+ const rows = [];
2721
+ for (const dataset of datasets) {
2722
+ if (scope !== "all" && scope !== dataset && !scope.includes("-")) continue;
2723
+ const records = searchLocalRecords("", { dataset, limit: 1000 });
2724
+ const missingPhones = records.filter((item) => !item.phone || item.phone === "-");
2725
+ const invalidEmails = records.filter((item) => item.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(item.email));
2726
+ const innCounts = new Map();
2727
+ for (const item of records) innCounts.set(item.inn, (innCounts.get(item.inn) || 0) + 1);
2728
+ const duplicateInn = records.filter((item) => item.inn && innCounts.get(item.inn) > 1);
2729
+ if (scope === "all" || scope === dataset || scope === "missing-phones") rows.push({ check: "missing-phones", dataset, count: missingPhones.length, sample: missingPhones[0]?.name || "-" });
2730
+ if (scope === "all" || scope === dataset || scope === "invalid-emails") rows.push({ check: "invalid-emails", dataset, count: invalidEmails.length, sample: invalidEmails[0]?.email || "-" });
2731
+ if (scope === "all" || scope === dataset || scope === "duplicate-inn") rows.push({ check: "duplicate-inn", dataset, count: duplicateInn.length, sample: duplicateInn[0]?.inn || "-" });
2732
+ }
2733
+ return rows;
2734
+ }
2735
+
2236
2736
  function saveView(name, dataset, args) {
2237
2737
  initDatabase();
2238
2738
  const db = openDatabase();
@@ -2298,11 +2798,58 @@ function exportDbSnapshot() {
2298
2798
  views: listSavedViews(),
2299
2799
  aliases: listAliases(),
2300
2800
  features: listFeatures(),
2801
+ memory: listMemory(1000),
2301
2802
  sessions: listSessions(100),
2302
2803
  history: listHistory(100),
2303
2804
  };
2304
2805
  }
2305
2806
 
2807
+ function listMemory(limit = 50) {
2808
+ initDatabase();
2809
+ const db = openDatabase();
2810
+ try {
2811
+ return db.prepare("SELECT id, scope, content, created_at FROM memory ORDER BY id DESC LIMIT ?").all(limit);
2812
+ } finally {
2813
+ db.close();
2814
+ }
2815
+ }
2816
+
2817
+ function addMemory(content, scope = "user") {
2818
+ initDatabase();
2819
+ const db = openDatabase();
2820
+ try {
2821
+ const result = db.prepare("INSERT INTO memory(scope, content) VALUES (?, ?)").run(scope, content);
2822
+ return Number(result.lastInsertRowid);
2823
+ } finally {
2824
+ db.close();
2825
+ }
2826
+ }
2827
+
2828
+ function deleteMemory(id) {
2829
+ initDatabase();
2830
+ const db = openDatabase();
2831
+ try {
2832
+ db.prepare("DELETE FROM memory WHERE id = ?").run(id);
2833
+ } finally {
2834
+ db.close();
2835
+ }
2836
+ }
2837
+
2838
+ function clearMemory() {
2839
+ initDatabase();
2840
+ const db = openDatabase();
2841
+ try {
2842
+ db.exec("DELETE FROM memory");
2843
+ } finally {
2844
+ db.close();
2845
+ }
2846
+ }
2847
+
2848
+ function buildMemoryText(limit = 20) {
2849
+ const rows = listMemory(limit).reverse();
2850
+ return rows.map((row) => `- ${row.content}`).join("\n");
2851
+ }
2852
+
2306
2853
  function listAliases() {
2307
2854
  initDatabase();
2308
2855
  const db = openDatabase();
@@ -2363,6 +2910,7 @@ function inferCommandFromText(text) {
2363
2910
  async function outputData(value, options, format) {
2364
2911
  const text = format === "csv" ? toCsv(value) : `${JSON.stringify(value, null, 2)}\n`;
2365
2912
  if (options.output) {
2913
+ await assertPermission("writeFiles");
2366
2914
  await writeFile(options.output, text, "utf8");
2367
2915
  console.log(`Файл сохранен: ${options.output}`);
2368
2916
  return;
@@ -2466,10 +3014,15 @@ async function aiAsk(args, context = {}) {
2466
3014
 
2467
3015
  const config = await loadConfig();
2468
3016
  const providerConfig = resolveAiProfile(config, options);
3017
+ if (providerConfig.provider === "codex") await assertPermission("codex");
3018
+ if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
3019
+ if (options.tools && providerConfig.provider === "ollama") {
3020
+ return localToolAsk(question, providerConfig, options);
3021
+ }
2469
3022
  applyRuntimeConfig(providerConfig, options.config);
2470
- const dataContext = await buildDataContext(question);
3023
+ const dataContext = options.bare ? { layers: [], query: { text: question, terms: [], patterns: { numbers: [], inns: [], streets: [], targetLayers: [] } }, schools: [], kindergartens: [] } : await buildDataContext(question);
2471
3024
  emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
2472
- const historyEnabled = !options["no-history"] && isFeatureEnabled("sqlite-history");
3025
+ const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
2473
3026
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
2474
3027
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
2475
3028
  const messages = buildAiMessages(question, dataContext, history, options);
@@ -2496,15 +3049,20 @@ async function aiAsk(args, context = {}) {
2496
3049
  emitEvent(options, "answer", { length: answer.length, sessionId });
2497
3050
 
2498
3051
  if (options.output) {
3052
+ await assertPermission("writeFiles");
2499
3053
  await writeFile(options.output, answer, "utf8");
2500
3054
  }
2501
3055
 
3056
+ if (options["fail-on-empty"] && !answer.trim()) {
3057
+ throw new Error("AI вернул пустой ответ.");
3058
+ }
3059
+
2502
3060
  if (options.format === "json" || options.schema === "json") {
2503
3061
  printJson({ answer, profile: providerConfig.name, provider: providerConfig.provider, model: providerConfig.model, sessionId, context: dataContext });
2504
3062
  return answer;
2505
3063
  }
2506
3064
 
2507
- console.log(answer);
3065
+ if (!options.quiet) console.log(answer);
2508
3066
  return answer;
2509
3067
  }
2510
3068
 
@@ -2529,6 +3087,140 @@ function resolveAiProfile(config, options = {}) {
2529
3087
  };
2530
3088
  }
2531
3089
 
3090
+ async function localToolAsk(question, providerConfig, options) {
3091
+ await ensureLocalData();
3092
+ const plan = await buildLocalToolPlan(question, providerConfig, options);
3093
+ const validated = validateToolPlan(plan);
3094
+ const result = await executeToolPlan(validated);
3095
+ const answer = formatToolResult(result, options);
3096
+
3097
+ if (!options["no-history"] && isFeatureEnabled("sqlite-history")) {
3098
+ recordAskHistory({
3099
+ question,
3100
+ answer,
3101
+ providerConfig,
3102
+ dataContext: { tool_plan: validated, tool_result: result },
3103
+ error: "",
3104
+ sessionId: null,
3105
+ });
3106
+ }
3107
+
3108
+ emitEvent(options, "tool_plan", { plan: validated });
3109
+ if (options.output) {
3110
+ await assertPermission("writeFiles");
3111
+ await writeFile(options.output, answer, "utf8");
3112
+ }
3113
+ if (options.format === "json" || options.schema === "json") {
3114
+ printJson({ answer, plan: validated, result });
3115
+ } else {
3116
+ if (!options.quiet) console.log(answer);
3117
+ }
3118
+ return answer;
3119
+ }
3120
+
3121
+ async function buildLocalToolPlan(question, providerConfig, options) {
3122
+ const mode = options.reasoning || "verify";
3123
+ const prompt = [
3124
+ "Ты планировщик CLI iola. Верни только JSON.",
3125
+ "Доступные tools: search_local, get_card, export_data, run_report, save_view.",
3126
+ "Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
3127
+ "Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
3128
+ `Вопрос: ${question}`,
3129
+ ].join("\n");
3130
+
3131
+ try {
3132
+ const raw = await callOllama(providerConfig, [{ role: "user", content: prompt }]);
3133
+ const parsed = parseJsonObject(raw);
3134
+ if (mode === "vote") {
3135
+ return chooseBestPlan([parsed, inferToolPlan(question)]);
3136
+ }
3137
+ return parsed;
3138
+ } catch {
3139
+ return inferToolPlan(question);
3140
+ }
3141
+ }
3142
+
3143
+ function parseJsonObject(text) {
3144
+ const match = String(text).match(/\{[\s\S]*\}/);
3145
+ if (!match) throw new Error("JSON-план не найден.");
3146
+ return JSON.parse(match[0]);
3147
+ }
3148
+
3149
+ function inferToolPlan(question) {
3150
+ const normalized = question.toLocaleLowerCase("ru-RU");
3151
+ const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
3152
+ const steps = [];
3153
+ if (normalized.includes("без телефона")) {
3154
+ steps.push({ tool: "run_report", args: { name: "missing-phones" } });
3155
+ } else {
3156
+ const query = normalized.match(/петрова|школ[а-яё ]*\d+|сад[а-яё ]*\d+|лицей[а-яё ]*\d+/iu)?.[0] || question;
3157
+ steps.push({ tool: "search_local", args: { dataset, query, limit: 20 } });
3158
+ }
3159
+ if (normalized.includes("csv") || normalized.includes("выгруз")) {
3160
+ steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
3161
+ }
3162
+ return { steps };
3163
+ }
3164
+
3165
+ function chooseBestPlan(plans) {
3166
+ return plans.find((plan) => {
3167
+ try {
3168
+ validateToolPlan(plan);
3169
+ return true;
3170
+ } catch {
3171
+ return false;
3172
+ }
3173
+ }) || plans.at(-1);
3174
+ }
3175
+
3176
+ function validateToolPlan(plan) {
3177
+ const allowed = new Set(LOCAL_TOOLS);
3178
+ if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
3179
+ for (const step of plan.steps) {
3180
+ if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
3181
+ }
3182
+ return plan;
3183
+ }
3184
+
3185
+ async function executeToolPlan(plan) {
3186
+ let current = [];
3187
+ const outputs = [];
3188
+ for (const step of plan.steps) {
3189
+ await assertPermission(step.tool);
3190
+ await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
3191
+ if (step.tool === "search_local") {
3192
+ current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
3193
+ outputs.push({ tool: step.tool, rows: current.length });
3194
+ } else if (step.tool === "get_card") {
3195
+ const card = findCard(step.args?.query || "");
3196
+ current = card ? [card] : [];
3197
+ outputs.push({ tool: step.tool, rows: current.length });
3198
+ } else if (step.tool === "run_report") {
3199
+ current = runQuality(step.args?.name || "all");
3200
+ outputs.push({ tool: step.tool, rows: current.length });
3201
+ } else if (step.tool === "save_view") {
3202
+ saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
3203
+ outputs.push({ tool: step.tool, saved: step.args?.name });
3204
+ } else if (step.tool === "export_data") {
3205
+ await assertPermission("writeFiles");
3206
+ await runHooks("BeforeExport", { output: step.args?.output || "iola-export.csv", format: step.args?.format || "csv", rows: current.length });
3207
+ const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
3208
+ await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
3209
+ outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
3210
+ }
3211
+ await runHooks("AfterTool", { tool: step.tool, rows: current.length });
3212
+ }
3213
+ return { rows: current, outputs };
3214
+ }
3215
+
3216
+ function formatToolResult(result, options) {
3217
+ if (options.schema === "json") return JSON.stringify(result, null, 2);
3218
+ const exported = result.outputs.find((item) => item.output);
3219
+ if (exported) return `Готово. Файл сохранен: ${exported.output}. Записей: ${exported.rows}`;
3220
+ if (!result.rows.length) return "Данных не найдено.";
3221
+ return result.rows.slice(0, 10).map((row) => `${row.name || row.check}: ${row.address || row.count || ""}`).join("\n");
3222
+ }
3223
+
2532
3224
  function applyRuntimeConfig(target, value) {
2533
3225
  if (!value) {
2534
3226
  return;
@@ -2540,6 +3232,36 @@ function applyRuntimeConfig(target, value) {
2540
3232
  setConfigValue(target, key, parts.join("="));
2541
3233
  }
2542
3234
 
3235
+ async function runHooks(event, payload = {}) {
3236
+ const config = await loadConfig();
3237
+ const commands = config.hooks?.[event] || [];
3238
+ for (const command of commands) {
3239
+ const parts = splitCommandLine(command);
3240
+ if (parts.length === 0) continue;
3241
+ await runCommand(parts[0], parts.slice(1), {
3242
+ inherit: true,
3243
+ env: {
3244
+ IOLA_HOOK_EVENT: event,
3245
+ IOLA_HOOK_PAYLOAD: JSON.stringify(payload),
3246
+ },
3247
+ });
3248
+ }
3249
+ }
3250
+
3251
+ async function assertPermission(name) {
3252
+ const config = await loadConfig();
3253
+ const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
3254
+ if (LOCAL_TOOLS.includes(name)) {
3255
+ if (permissions.localTools?.[name] === false) {
3256
+ throw new Error(`Tool запрещен политикой permissions: ${name}`);
3257
+ }
3258
+ return;
3259
+ }
3260
+ if (permissions[name] === false) {
3261
+ throw new Error(`Действие запрещено политикой permissions: ${name}`);
3262
+ }
3263
+ }
3264
+
2543
3265
  function emitEvent(options, type, data) {
2544
3266
  if (!options.events) {
2545
3267
  return;
@@ -2548,6 +3270,7 @@ function emitEvent(options, type, data) {
2548
3270
  }
2549
3271
 
2550
3272
  async function buildDataContext(question) {
3273
+ await assertPermission("externalApi");
2551
3274
  const apiBaseUrl = await getApiBaseUrl();
2552
3275
  const mcpBaseUrl = await getMcpBaseUrl();
2553
3276
  const [layers, schools, kindergartens] = await Promise.all([
@@ -2671,6 +3394,7 @@ function scoreItem(item, terms, patterns, layer) {
2671
3394
 
2672
3395
  function buildAiMessages(question, dataContext, history, options = {}) {
2673
3396
  const sourceLines = buildSourceLines(dataContext);
3397
+ const memoryText = options.bare ? "" : buildMemoryText();
2674
3398
  const system = [
2675
3399
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
2676
3400
  "Отвечай на русском языке.",
@@ -2680,6 +3404,7 @@ function buildAiMessages(question, dataContext, history, options = {}) {
2680
3404
  "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
2681
3405
  options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
2682
3406
  options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
3407
+ memoryText ? `Учитывай пользовательскую память:\n${memoryText}` : "",
2683
3408
  "Отвечай кратко и по делу.",
2684
3409
  ].filter(Boolean).join(" ");
2685
3410
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -2912,7 +3637,7 @@ async function listDataset(dataset, args) {
2912
3637
  params.set("offset", options.offset || "0");
2913
3638
 
2914
3639
  const data = options.local
2915
- ? searchLocalRecords(options.search || "", { dataset, limit: Number(options.limit || 20) })
3640
+ ? searchLocalRecords(options.search || options._.join(" ") || "", { dataset, limit: Number(options.limit || 20), fts: options.fts })
2916
3641
  : normalizeItems(await fetchJsonMaybeCached(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`, options));
2917
3642
  const items = data;
2918
3643
  const filtered = applyDatasetFilters(items, options);
@@ -2968,8 +3693,8 @@ async function searchAll(args) {
2968
3693
  const limit = Number(options.limit || 5);
2969
3694
  const [schools, kindergartens] = options.local
2970
3695
  ? [
2971
- searchLocalRecords(query, { dataset: "schools", limit }),
2972
- searchLocalRecords(query, { dataset: "kindergartens", limit }),
3696
+ searchLocalRecords(query, { dataset: "schools", limit, fts: options.fts }),
3697
+ searchLocalRecords(query, { dataset: "kindergartens", limit, fts: options.fts }),
2973
3698
  ]
2974
3699
  : await Promise.all([
2975
3700
  fetchJsonMaybeCached(`${await getApiBaseUrl()}/schools?limit=100&offset=0`, options),
@@ -3018,12 +3743,12 @@ function parseOptions(args) {
3018
3743
 
3019
3744
  for (let index = 0; index < args.length; index += 1) {
3020
3745
  const arg = args[index];
3021
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache") {
3746
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug") {
3022
3747
  result[arg.slice(2)] = true;
3023
3748
  } else if (arg === "--check" || arg === "--upgrade-node") {
3024
3749
  result.check = true;
3025
3750
  result[arg.slice(2)] = true;
3026
- } 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" || arg === "--dataset" || arg === "--save") {
3751
+ } 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" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--debug-file") {
3027
3752
  result[arg.slice(2)] = args[index + 1];
3028
3753
  index += 1;
3029
3754
  } else {
@@ -3034,6 +3759,21 @@ function parseOptions(args) {
3034
3759
  return result;
3035
3760
  }
3036
3761
 
3762
+ function parseGlobalOptions(argv) {
3763
+ const result = { args: [] };
3764
+ for (let index = 0; index < argv.length; index += 1) {
3765
+ const arg = argv[index];
3766
+ if (arg === "--help" || arg === "-h") result.help = true;
3767
+ else if (arg === "--debug") result.debug = true;
3768
+ else if (arg === "--no-color") result.noColor = true;
3769
+ else if (arg === "--debug-file") {
3770
+ result.debugFile = argv[index + 1];
3771
+ index += 1;
3772
+ } else result.args.push(arg);
3773
+ }
3774
+ return result;
3775
+ }
3776
+
3037
3777
  function splitCommandLine(line) {
3038
3778
  const result = [];
3039
3779
  let current = "";
@@ -3497,9 +4237,17 @@ async function printAiConfigField(field) {
3497
4237
 
3498
4238
  function runCommand(command, args, options = {}) {
3499
4239
  return new Promise((resolve, reject) => {
4240
+ if (process.env.IOLA_DEBUG) {
4241
+ console.error(`[debug] run: ${command} ${args.join(" ")}`);
4242
+ debugLog(`run: ${command} ${args.join(" ")}`);
4243
+ }
3500
4244
  const child = execFile(command, args, {
3501
4245
  windowsHide: true,
3502
4246
  maxBuffer: 1024 * 1024 * 5,
4247
+ env: {
4248
+ ...process.env,
4249
+ ...(options.env || {}),
4250
+ },
3503
4251
  }, (error, stdout, stderr) => {
3504
4252
  if (error) {
3505
4253
  if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
@@ -3528,6 +4276,11 @@ function runCommand(command, args, options = {}) {
3528
4276
  });
3529
4277
  }
3530
4278
 
4279
+ function debugLog(message) {
4280
+ if (!process.env.IOLA_DEBUG_FILE) return;
4281
+ appendFile(process.env.IOLA_DEBUG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf8").catch(() => {});
4282
+ }
4283
+
3531
4284
  function quoteWindowsCommand(command, args) {
3532
4285
  return [command, ...args].map((value) => {
3533
4286
  const text = String(value);