@iola_adm/iola-cli 0.1.26 → 0.1.28

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
@@ -58,6 +58,10 @@ iola policy use analyst
58
58
  iola tasks list
59
59
  iola artifacts list
60
60
  iola trace last
61
+ iola changes list
62
+ iola index status
63
+ iola reports list
64
+ iola plugins list
61
65
  iola context init
62
66
  iola cron list
63
67
  iola daemon status
@@ -87,6 +91,7 @@ iola version --check
87
91
  - [Skills и toolsets](https://github.com/adm-iola/iola-cli/wiki/Skills-и-toolsets)
88
92
  - [Локальные файлы](https://github.com/adm-iola/iola-cli/wiki/Локальные-файлы)
89
93
  - [Рабочая среда агента](https://github.com/adm-iola/iola-cli/wiki/Рабочая-среда-агента)
94
+ - [Расширения и локальные данные](https://github.com/adm-iola/iola-cli/wiki/Расширения-и-локальные-данные)
90
95
  - [Daemon, RPC и cron](https://github.com/adm-iola/iola-cli/wiki/Daemon-RPC-и-cron)
91
96
  - [Контекст и память](https://github.com/adm-iola/iola-cli/wiki/Контекст-и-память)
92
97
  - [Команды](https://github.com/adm-iola/iola-cli/wiki/Команды)
@@ -102,6 +107,8 @@ iola version --check
102
107
  - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
103
108
  - планы выполнения, traces, tasks, artifacts, snapshots и policy-профили;
104
109
  - экспорт отчетов в Excel/Word-совместимые файлы;
110
+ - staged changes, импорт локальных CSV/JSON, индекс локальных документов, report packs, plugins и локальный MCP endpoint;
111
+ - чтение и индексирование `.docx`, `.xlsx`, `.pptx`, `.pdf`, `.md`, `.txt`, `.csv`, `.json`, `.html`;
105
112
  - cron-задачи, локальный daemon и RPC для автоматизаций;
106
113
  - контекстные файлы `IOLA.md` и `.iola/context.md`;
107
114
  - интеграция с публичным MCP-сервером Йошкар-Олы.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import readline from "node:readline/promises";
8
8
  import { stdin as input, stdout as output } from "node:process";
9
9
  import { DatabaseSync } from "node:sqlite";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { inflateRawSync, inflateSync } from "node:zlib";
11
12
 
12
13
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
13
14
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
@@ -17,7 +18,8 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
18
  const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
18
19
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
19
20
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
20
- const DB_SCHEMA_VERSION = 5;
21
+ const DB_SCHEMA_VERSION = 7;
22
+ const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
21
23
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
22
24
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
23
25
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
@@ -242,6 +244,11 @@ const COMMANDS = new Map([
242
244
  ["skills", handleSkills],
243
245
  ["tools", handleTools],
244
246
  ["files", handleFiles],
247
+ ["changes", handleChanges],
248
+ ["import", handleImport],
249
+ ["index", handleIndex],
250
+ ["reports", handleReports],
251
+ ["plugins", handlePlugins],
245
252
  ["workspace", handleWorkspace],
246
253
  ["tasks", handleTasks],
247
254
  ["artifacts", handleArtifacts],
@@ -284,6 +291,7 @@ const COMMANDS = new Map([
284
291
  ["search", searchAll],
285
292
  ["mcp-info", showMcpInfo],
286
293
  ["setup", setupClient],
294
+ ["onboard", onboard],
287
295
  ]);
288
296
 
289
297
  export async function main(argv) {
@@ -352,6 +360,11 @@ Usage:
352
360
  iola skills list|show|paths|enable|disable
353
361
  iola tools list|toolsets|enable|disable|profile
354
362
  iola files status|mode|approvals|tree|read|search|write|patch
363
+ iola changes list|show|apply|discard
364
+ iola import file|folder
365
+ iola index folder|status|search
366
+ iola reports list|run
367
+ iola plugins list|install|run|remove
355
368
  iola workspace init|status|use|list
356
369
  iola tasks list|add|done|run
357
370
  iola artifacts list|show|open
@@ -366,7 +379,7 @@ Usage:
366
379
  iola memory show|add|set|clear|export
367
380
  iola hooks list|add|delete|run
368
381
  iola agents list|run
369
- iola mcp list|status|install|remove
382
+ iola mcp list|status|install|remove|serve
370
383
  iola cache status|warm|clear
371
384
  iola sync [--dataset schools|kindergartens]
372
385
  iola sync status
@@ -413,6 +426,7 @@ Usage:
413
426
  iola search TEXT [--limit 5] [--format table|json|csv]
414
427
  iola mcp-info [--json]
415
428
  iola setup codex
429
+ iola onboard
416
430
  iola version
417
431
 
418
432
  Environment:
@@ -604,6 +618,26 @@ async function handleAgentLine(line, state) {
604
618
  return false;
605
619
  }
606
620
 
621
+ if (command === "changes") {
622
+ await handleChanges(args);
623
+ return false;
624
+ }
625
+
626
+ if (command === "index") {
627
+ await handleIndex(args);
628
+ return false;
629
+ }
630
+
631
+ if (command === "reports") {
632
+ await handleReports(args);
633
+ return false;
634
+ }
635
+
636
+ if (command === "plugins") {
637
+ await handlePlugins(args);
638
+ return false;
639
+ }
640
+
607
641
  if (command === "workspace") {
608
642
  await handleWorkspace(args);
609
643
  return false;
@@ -757,6 +791,10 @@ async function handleAgentLine(line, state) {
757
791
  context: ["context", args],
758
792
  skills: ["skills", args],
759
793
  files: ["files", args],
794
+ changes: ["changes", args],
795
+ index: ["index", args],
796
+ reports: ["reports", args],
797
+ plugins: ["plugins", args],
760
798
  workspace: ["workspace", args],
761
799
  tasks: ["tasks", args],
762
800
  todos: ["tasks", args],
@@ -812,6 +850,10 @@ function printAgentHelp() {
812
850
  /permissions
813
851
  /tools
814
852
  /files status
853
+ /changes list
854
+ /index status
855
+ /reports list
856
+ /plugins list
815
857
  /workspace status
816
858
  /tasks list
817
859
  /artifacts list
@@ -1548,6 +1590,7 @@ async function handleWiki(args) {
1548
1590
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1549
1591
  ["Локальные файлы", `${base}/Локальные-файлы`],
1550
1592
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1593
+ ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1551
1594
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
1552
1595
  ["Контекст и память", `${base}/Контекст-и-память`],
1553
1596
  ["Команды", `${base}/Команды`],
@@ -1774,22 +1817,156 @@ async function handleFiles(args) {
1774
1817
  if (!target) throw new Error('Пример: iola files write report.md --text "..."');
1775
1818
  const text = options.text ?? rest.join(" ");
1776
1819
  if (!text) throw new Error('Для записи нужен --text "..." или текст после пути.');
1777
- await filesWrite(target, text, { append: Boolean(options.append) });
1778
- console.log(`Файл записан: ${target}`);
1820
+ if (options.stage) {
1821
+ const id = await stageFileChange("write", target, text);
1822
+ console.log(`Изменение подготовлено: ${id}`);
1823
+ } else {
1824
+ await filesWrite(target, text, { append: Boolean(options.append) });
1825
+ console.log(`Файл записан: ${target}`);
1826
+ }
1779
1827
  return;
1780
1828
  }
1781
1829
 
1782
1830
  if (action === "patch") {
1783
1831
  if (!target) throw new Error('Пример: iola files patch README.md --search old --replace new');
1784
1832
  if (!options.search || options.replace === undefined) throw new Error("Для patch нужны --search и --replace.");
1785
- const result = await filesPatch(target, options.search, options.replace);
1786
- printKeyValue(result);
1833
+ if (options.stage) {
1834
+ const current = await filesRead(target);
1835
+ const next = current.split(options.search).join(options.replace);
1836
+ const id = await stageFileChange("patch", target, next, current);
1837
+ console.log(`Изменение подготовлено: ${id}`);
1838
+ } else {
1839
+ const result = await filesPatch(target, options.search, options.replace);
1840
+ printKeyValue(result);
1841
+ }
1787
1842
  return;
1788
1843
  }
1789
1844
 
1790
1845
  throw new Error("Команды files: status, mode MODE, approvals POLICY, tree [PATH], read FILE, search TEXT, write FILE --text TEXT, patch FILE --search OLD --replace NEW.");
1791
1846
  }
1792
1847
 
1848
+ async function handleChanges(args) {
1849
+ const [action = "list", id] = args;
1850
+ if (action === "list" || action === "ls") {
1851
+ printTable(listChanges(), [["id", "ID"], ["kind", "Тип"], ["target", "Файл"], ["status", "Статус"], ["created_at", "Дата"]]);
1852
+ return;
1853
+ }
1854
+ if (action === "show") {
1855
+ const change = getChange(Number(id));
1856
+ console.log(unifiedPreview(change.before_text || "", change.after_text || ""));
1857
+ return;
1858
+ }
1859
+ if (action === "apply") {
1860
+ await applyChange(Number(id));
1861
+ console.log(`Изменение применено: ${id}`);
1862
+ return;
1863
+ }
1864
+ if (action === "discard") {
1865
+ updateChangeStatus(Number(id), "discarded");
1866
+ console.log(`Изменение отклонено: ${id}`);
1867
+ return;
1868
+ }
1869
+ throw new Error("Команды changes: list, show ID, apply ID, discard ID.");
1870
+ }
1871
+
1872
+ async function handleImport(args) {
1873
+ const [action, target, ...rest] = args;
1874
+ const options = parseOptions(rest);
1875
+ if (action === "file") {
1876
+ if (!target) throw new Error("Пример: iola import file data.csv --dataset custom");
1877
+ const dataset = options.dataset || path.basename(target, path.extname(target));
1878
+ const count = await importDataFile(target, dataset);
1879
+ console.log(`Импортировано записей: ${count}, dataset=${dataset}`);
1880
+ return;
1881
+ }
1882
+ if (action === "folder") {
1883
+ if (!target) throw new Error("Пример: iola import folder ./data");
1884
+ const rows = await filesTree(target, { depth: 1, limit: 200 });
1885
+ let total = 0;
1886
+ for (const row of rows.filter((item) => item.type === "file" && /\.(json|csv)$/i.test(item.path))) {
1887
+ total += await importDataFile(row.path, options.dataset || path.basename(row.path, path.extname(row.path)));
1888
+ }
1889
+ console.log(`Импортировано записей: ${total}`);
1890
+ return;
1891
+ }
1892
+ throw new Error("Команды import: file PATH --dataset NAME, folder PATH.");
1893
+ }
1894
+
1895
+ async function handleIndex(args) {
1896
+ const [action = "status", target, ...rest] = args;
1897
+ const options = parseOptions(rest);
1898
+ if (action === "status") {
1899
+ printKeyValue(getIndexStatus());
1900
+ return;
1901
+ }
1902
+ if (action === "folder") {
1903
+ if (!target) throw new Error("Пример: iola index folder ./docs");
1904
+ const count = await indexFolder(target, options);
1905
+ console.log(`Проиндексировано документов: ${count}`);
1906
+ return;
1907
+ }
1908
+ if (action === "search") {
1909
+ const query = [target, ...rest].filter(Boolean).join(" ");
1910
+ if (!query) throw new Error('Пример: iola index search "школа 29"');
1911
+ printTable(searchDocs(query, Number(options.limit || 20)), [["file", "Файл"], ["title", "Название"], ["snippet", "Фрагмент"]]);
1912
+ return;
1913
+ }
1914
+ throw new Error("Команды index: status, folder PATH, search TEXT.");
1915
+ }
1916
+
1917
+ async function handleReports(args) {
1918
+ const [action = "list", name, ...rest] = args;
1919
+ const packs = {
1920
+ "education-passport": ["education-contacts", "licenses"],
1921
+ "data-quality-pack": ["schools-summary", "missing-phones"],
1922
+ };
1923
+ if (action === "list") {
1924
+ printTable(Object.entries(packs).map(([pack, reports]) => ({ pack, reports: reports.join(", ") })), [["pack", "Пакет"], ["reports", "Отчеты"]]);
1925
+ return;
1926
+ }
1927
+ if (action === "run") {
1928
+ if (!packs[name]) throw new Error(`Пакет неизвестен: ${Object.keys(packs).join(", ")}`);
1929
+ const options = parseOptions(rest);
1930
+ const dir = options.output || path.join(process.cwd(), `iola-report-${name}-${Date.now()}`);
1931
+ await mkdir(dir, { recursive: true });
1932
+ for (const report of packs[name]) {
1933
+ await handleExport([report, "--format", "xlsx", "--output", path.join(dir, `${report}.xlsx`)]);
1934
+ await handleExport([report, "--format", "docx", "--output", path.join(dir, `${report}.docx`)]);
1935
+ }
1936
+ saveArtifact("report-pack", name, dir, { reports: packs[name] });
1937
+ console.log(`Пакет отчетов создан: ${dir}`);
1938
+ return;
1939
+ }
1940
+ throw new Error("Команды reports: list, run NAME [--output DIR].");
1941
+ }
1942
+
1943
+ async function handlePlugins(args) {
1944
+ const [action = "list", name, ...rest] = args;
1945
+ if (action === "list" || action === "ls") {
1946
+ printTable(listPlugins(), [["name", "Plugin"], ["source", "Источник"], ["command", "Команда"]]);
1947
+ return;
1948
+ }
1949
+ if (action === "install") {
1950
+ const options = parseOptions(rest);
1951
+ if (!name) throw new Error("Пример: iola plugins install my-plugin --command \"iola quality\"");
1952
+ savePlugin(name, options.source || name, options.command || "");
1953
+ console.log(`Plugin установлен: ${name}`);
1954
+ return;
1955
+ }
1956
+ if (action === "run") {
1957
+ const plugin = getPlugin(name);
1958
+ if (!plugin.command) throw new Error("У plugin нет command.");
1959
+ await main(splitCommandLine(plugin.command));
1960
+ return;
1961
+ }
1962
+ if (action === "remove" || action === "delete") {
1963
+ deletePlugin(name);
1964
+ console.log(`Plugin удален: ${name}`);
1965
+ return;
1966
+ }
1967
+ throw new Error("Команды plugins: list, install NAME --command CMD, run NAME, remove NAME.");
1968
+ }
1969
+
1793
1970
  async function handleWorkspace(args) {
1794
1971
  const [action = "status", nameOrPath] = args;
1795
1972
  const config = await loadConfig();
@@ -2309,7 +2486,13 @@ async function handleMcp(args) {
2309
2486
  return;
2310
2487
  }
2311
2488
 
2312
- throw new Error("Команды mcp: status, list, install codex, remove codex.");
2489
+ if (action === "serve") {
2490
+ const config = await loadConfig();
2491
+ await startMcpServer(config.daemon?.host || "127.0.0.1", Number(config.daemon?.port || DAEMON_PORT) + 1);
2492
+ return;
2493
+ }
2494
+
2495
+ throw new Error("Команды mcp: status, list, install codex, remove codex, serve.");
2313
2496
  }
2314
2497
 
2315
2498
  async function handleCache(args) {
@@ -3195,6 +3378,39 @@ function initDatabase() {
3195
3378
  path TEXT NOT NULL,
3196
3379
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
3197
3380
  );
3381
+ CREATE TABLE IF NOT EXISTS pending_changes (
3382
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3383
+ kind TEXT NOT NULL,
3384
+ target TEXT NOT NULL,
3385
+ before_text TEXT,
3386
+ after_text TEXT NOT NULL,
3387
+ status TEXT NOT NULL DEFAULT 'pending',
3388
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3389
+ applied_at TEXT
3390
+ );
3391
+ CREATE TABLE IF NOT EXISTS custom_records (
3392
+ dataset TEXT NOT NULL,
3393
+ record_key TEXT NOT NULL,
3394
+ record_json TEXT NOT NULL,
3395
+ searchable_text TEXT NOT NULL,
3396
+ imported_at TEXT NOT NULL DEFAULT (datetime('now')),
3397
+ PRIMARY KEY(dataset, record_key)
3398
+ );
3399
+ CREATE VIRTUAL TABLE IF NOT EXISTS custom_records_fts USING fts5(dataset, record_key, searchable_text);
3400
+ CREATE TABLE IF NOT EXISTS doc_index (
3401
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3402
+ file TEXT NOT NULL,
3403
+ title TEXT,
3404
+ content TEXT NOT NULL,
3405
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
3406
+ );
3407
+ CREATE VIRTUAL TABLE IF NOT EXISTS doc_index_fts USING fts5(file, title, content);
3408
+ CREATE TABLE IF NOT EXISTS plugins (
3409
+ name TEXT PRIMARY KEY,
3410
+ source TEXT NOT NULL,
3411
+ command TEXT,
3412
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
3413
+ );
3198
3414
  `);
3199
3415
  rebuildFtsIfEmpty(db);
3200
3416
  db.prepare(`
@@ -3249,6 +3465,8 @@ function getDbStatus() {
3249
3465
  const cron = db.prepare("SELECT COUNT(*) AS count FROM cron_jobs").get();
3250
3466
  const tasks = db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE status != 'done'").get();
3251
3467
  const artifacts = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get();
3468
+ const docs = db.prepare("SELECT COUNT(*) AS count FROM doc_index").get();
3469
+ const custom = db.prepare("SELECT COUNT(*) AS count FROM custom_records").get();
3252
3470
  return {
3253
3471
  status: "ok",
3254
3472
  file: DB_FILE,
@@ -3262,6 +3480,8 @@ function getDbStatus() {
3262
3480
  cron_jobs: cron?.count ?? 0,
3263
3481
  open_tasks: tasks?.count ?? 0,
3264
3482
  artifacts: artifacts?.count ?? 0,
3483
+ indexed_docs: docs?.count ?? 0,
3484
+ custom_records: custom?.count ?? 0,
3265
3485
  };
3266
3486
  } finally {
3267
3487
  db.close();
@@ -5055,17 +5275,29 @@ async function setupClient(args) {
5055
5275
  console.log("Codex MCP и skill установлены.");
5056
5276
  }
5057
5277
 
5278
+ async function onboard(args = []) {
5279
+ const options = parseOptions(args);
5280
+ showBanner();
5281
+ initDatabase();
5282
+ await handleConfig(["validate"]);
5283
+ await doctor(["--summary"]);
5284
+ if (options.yes || await confirm("Инициализировать workspace? [Y/n] ")) await handleWorkspace(["init"]);
5285
+ if (options.yes || await confirm("Применить policy analyst? [Y/n] ")) await handlePolicy(["use", "analyst"]);
5286
+ if (options.yes || await confirm("Настроить AI сейчас? [y/N] ")) await aiSetup([]);
5287
+ console.log("Onboard завершен.");
5288
+ }
5289
+
5058
5290
  function parseOptions(args) {
5059
5291
  const result = { _: [] };
5060
5292
 
5061
5293
  for (let index = 0; index < args.length; index += 1) {
5062
5294
  const arg = args[index];
5063
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
5295
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || 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") {
5064
5296
  result[arg.slice(2)] = true;
5065
5297
  } else if (arg === "--check" || arg === "--upgrade-node") {
5066
5298
  result.check = true;
5067
5299
  result[arg.slice(2)] = true;
5068
- } 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 === "--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") {
5300
+ } 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 === "--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") {
5069
5301
  result[arg.slice(2)] = args[index + 1];
5070
5302
  index += 1;
5071
5303
  } else {
@@ -5432,6 +5664,34 @@ async function startDaemon(host, port) {
5432
5664
  await new Promise(() => {});
5433
5665
  }
5434
5666
 
5667
+ async function startMcpServer(host, port) {
5668
+ const server = createServer(async (req, res) => {
5669
+ try {
5670
+ res.setHeader("content-type", "application/json; charset=utf-8");
5671
+ if (req.method !== "POST") {
5672
+ res.end(JSON.stringify({ name: "iola-local-mcp", tools: ["status", "search", "card", "quality", "files.search", "index.search"] }));
5673
+ return;
5674
+ }
5675
+ const payload = JSON.parse(await readRequestBody(req) || "{}");
5676
+ const method = payload.method === "tools/call" ? payload.params?.name : payload.method;
5677
+ const args = payload.params?.arguments || payload.params || {};
5678
+ let result;
5679
+ if (method === "index.search") result = searchDocs(args.query || "", Number(args.limit || 20));
5680
+ else result = await executeRpc(method, { ...args, _: [] });
5681
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id || null, result }));
5682
+ } catch (error) {
5683
+ res.statusCode = 500;
5684
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { message: error instanceof Error ? error.message : String(error) } }));
5685
+ }
5686
+ });
5687
+ await new Promise((resolve, reject) => {
5688
+ server.once("error", reject);
5689
+ server.listen(port, host, resolve);
5690
+ });
5691
+ console.log(`iola local MCP запущен: http://${host}:${port}`);
5692
+ await new Promise(() => {});
5693
+ }
5694
+
5435
5695
  function readRequestBody(req) {
5436
5696
  return new Promise((resolve, reject) => {
5437
5697
  let body = "";
@@ -5575,7 +5835,7 @@ async function filesRead(target, options = {}) {
5575
5835
  if (!info.isFile()) throw new Error(`Это не файл: ${target}`);
5576
5836
  const maxBytes = Number(options.maxBytes || config.files?.maxReadBytes || 200000);
5577
5837
  if (info.size > maxBytes) throw new Error(`Файл слишком большой: ${info.size} байт. Лимит: ${maxBytes}`);
5578
- return readFile(resolved, "utf8");
5838
+ return extractReadableText(resolved);
5579
5839
  }
5580
5840
 
5581
5841
  async function filesSearch(query, options = {}) {
@@ -5624,6 +5884,150 @@ async function filesPatch(target, search, replace) {
5624
5884
  return { path: relative, replacements };
5625
5885
  }
5626
5886
 
5887
+ async function extractReadableText(file) {
5888
+ const ext = path.extname(file).toLocaleLowerCase("ru-RU");
5889
+ if (ext === ".docx") return extractDocxText(await readFile(file));
5890
+ if (ext === ".xlsx") return extractXlsxText(await readFile(file));
5891
+ if (ext === ".pptx") return extractPptxText(await readFile(file));
5892
+ if (ext === ".pdf") return extractPdfText(await readFile(file));
5893
+ return readFile(file, "utf8");
5894
+ }
5895
+
5896
+ function extractDocxText(buffer) {
5897
+ const entries = readZipEntries(buffer);
5898
+ const documentXml = entries.get("word/document.xml") || "";
5899
+ const footnotes = [...entries.entries()].filter(([name]) => name.startsWith("word/") && /footnotes|endnotes|comments/.test(name)).map(([, text]) => text).join("\n");
5900
+ return xmlToText(`${documentXml}\n${footnotes}`);
5901
+ }
5902
+
5903
+ function extractXlsxText(buffer) {
5904
+ const entries = readZipEntries(buffer);
5905
+ const sharedStrings = parseSharedStrings(entries.get("xl/sharedStrings.xml") || "");
5906
+ const chunks = [];
5907
+ for (const [name, xml] of entries.entries()) {
5908
+ if (!/^xl\/worksheets\/sheet\d+\.xml$/i.test(name)) continue;
5909
+ chunks.push(name);
5910
+ const resolved = xml.replace(/<c[^>]*t="s"[^>]*>[\s\S]*?<v>(\d+)<\/v>[\s\S]*?<\/c>/g, (_, index) => ` ${sharedStrings[Number(index)] || ""} `);
5911
+ chunks.push(xmlToText(resolved));
5912
+ }
5913
+ return normalizeExtractedText(chunks.join("\n"));
5914
+ }
5915
+
5916
+ function extractPptxText(buffer) {
5917
+ const entries = readZipEntries(buffer);
5918
+ const slides = [...entries.entries()]
5919
+ .filter(([name]) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
5920
+ .sort(([left], [right]) => left.localeCompare(right, undefined, { numeric: true }));
5921
+ return normalizeExtractedText(slides.map(([name, xml]) => `${name}\n${xmlToText(xml)}`).join("\n\n"));
5922
+ }
5923
+
5924
+ function extractPdfText(buffer) {
5925
+ const latin = buffer.toString("latin1");
5926
+ const chunks = [];
5927
+ const streamPattern = /<<(?:.|\r|\n)*?>>\s*stream\r?\n([\s\S]*?)\r?\nendstream/g;
5928
+ let match;
5929
+ while ((match = streamPattern.exec(latin))) {
5930
+ const dictionary = latin.slice(Math.max(0, match.index - 500), match.index + 500);
5931
+ let data = Buffer.from(match[1], "latin1");
5932
+ if (/FlateDecode/.test(dictionary)) {
5933
+ try {
5934
+ data = inflateSync(data);
5935
+ } catch {
5936
+ try {
5937
+ data = inflateRawSync(data);
5938
+ } catch {
5939
+ // Leave compressed stream unreadable.
5940
+ }
5941
+ }
5942
+ }
5943
+ chunks.push(extractPdfStrings(data.toString("latin1")));
5944
+ }
5945
+ chunks.push(extractPdfStrings(latin));
5946
+ return normalizeExtractedText(chunks.join("\n"));
5947
+ }
5948
+
5949
+ function extractPdfStrings(text) {
5950
+ const strings = [];
5951
+ for (const match of text.matchAll(/\(([^()\\]*(?:\\.[^()\\]*)*)\)\s*T[jJ]?/g)) {
5952
+ strings.push(unescapePdfString(match[1]));
5953
+ }
5954
+ for (const match of text.matchAll(/\[([\s\S]*?)\]\s*TJ/g)) {
5955
+ for (const item of match[1].matchAll(/\(([^()\\]*(?:\\.[^()\\]*)*)\)/g)) {
5956
+ strings.push(unescapePdfString(item[1]));
5957
+ }
5958
+ }
5959
+ return strings.join(" ");
5960
+ }
5961
+
5962
+ function unescapePdfString(value) {
5963
+ const unescaped = value
5964
+ .replace(/\\n/g, "\n")
5965
+ .replace(/\\r/g, "\r")
5966
+ .replace(/\\t/g, "\t")
5967
+ .replace(/\\([()\\])/g, "$1")
5968
+ .replace(/\\(\d{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)));
5969
+ return decodePossiblyUtf8(unescaped);
5970
+ }
5971
+
5972
+ function decodePossiblyUtf8(value) {
5973
+ const decoded = Buffer.from(value, "latin1").toString("utf8");
5974
+ return decoded.includes("\uFFFD") ? value : decoded;
5975
+ }
5976
+
5977
+ function readZipEntries(buffer) {
5978
+ const entries = new Map();
5979
+ let offset = 0;
5980
+ while (offset < buffer.length - 30) {
5981
+ const signature = buffer.readUInt32LE(offset);
5982
+ if (signature !== 0x04034b50) {
5983
+ offset += 1;
5984
+ continue;
5985
+ }
5986
+ const method = buffer.readUInt16LE(offset + 8);
5987
+ const compressedSize = buffer.readUInt32LE(offset + 18);
5988
+ const fileNameLength = buffer.readUInt16LE(offset + 26);
5989
+ const extraLength = buffer.readUInt16LE(offset + 28);
5990
+ const nameStart = offset + 30;
5991
+ const name = buffer.subarray(nameStart, nameStart + fileNameLength).toString("utf8");
5992
+ const dataStart = nameStart + fileNameLength + extraLength;
5993
+ const dataEnd = dataStart + compressedSize;
5994
+ const compressed = buffer.subarray(dataStart, dataEnd);
5995
+ try {
5996
+ const data = method === 8 ? inflateRawSync(compressed) : compressed;
5997
+ entries.set(name.replace(/\\/g, "/"), data.toString("utf8"));
5998
+ } catch {
5999
+ // Skip unreadable ZIP entry.
6000
+ }
6001
+ offset = dataEnd;
6002
+ }
6003
+ return entries;
6004
+ }
6005
+
6006
+ function parseSharedStrings(xml) {
6007
+ return [...xml.matchAll(/<si[\s\S]*?<\/si>/g)].map((match) => xmlToText(match[0]));
6008
+ }
6009
+
6010
+ function xmlToText(xml) {
6011
+ return normalizeExtractedText(String(xml)
6012
+ .replace(/<w:tab\/>/g, "\t")
6013
+ .replace(/<w:br\/>|<a:br\/>|<\/w:p>|<\/a:p>|<\/row>/g, "\n")
6014
+ .replace(/<[^>]+>/g, " ")
6015
+ .replace(/&quot;/g, "\"")
6016
+ .replace(/&apos;/g, "'")
6017
+ .replace(/&lt;/g, "<")
6018
+ .replace(/&gt;/g, ">")
6019
+ .replace(/&amp;/g, "&"));
6020
+ }
6021
+
6022
+ function normalizeExtractedText(text) {
6023
+ return String(text)
6024
+ .replace(/\u0000/g, "")
6025
+ .replace(/[ \t]+/g, " ")
6026
+ .replace(/\s*\n\s*/g, "\n")
6027
+ .replace(/\n{3,}/g, "\n\n")
6028
+ .trim();
6029
+ }
6030
+
5627
6031
  async function maybeConfirmFileOperation(operation, target, preview) {
5628
6032
  const config = await loadConfig();
5629
6033
  const approvals = config.files?.approvals || "on-write";
@@ -5784,6 +6188,208 @@ async function restoreSnapshot(id) {
5784
6188
  await cp(row.path, row.workspace, { recursive: true, force: true });
5785
6189
  }
5786
6190
 
6191
+ async function stageFileChange(kind, target, afterText, beforeText = null) {
6192
+ const { resolved, relative } = await resolveFileTarget(target, kind === "patch" ? "edit" : "write");
6193
+ const before = beforeText ?? (existsSync(resolved) ? await readFile(resolved, "utf8").catch(() => "") : "");
6194
+ initDatabase();
6195
+ const db = openDatabase();
6196
+ try {
6197
+ const result = db.prepare("INSERT INTO pending_changes(kind, target, before_text, after_text) VALUES (?, ?, ?, ?)").run(kind, relative, before, afterText);
6198
+ return Number(result.lastInsertRowid);
6199
+ } finally {
6200
+ db.close();
6201
+ }
6202
+ }
6203
+
6204
+ function listChanges() {
6205
+ initDatabase();
6206
+ const db = openDatabase();
6207
+ try {
6208
+ return db.prepare("SELECT id, kind, target, status, created_at FROM pending_changes ORDER BY id DESC LIMIT 100").all();
6209
+ } finally {
6210
+ db.close();
6211
+ }
6212
+ }
6213
+
6214
+ function getChange(id) {
6215
+ initDatabase();
6216
+ const db = openDatabase();
6217
+ try {
6218
+ const row = db.prepare("SELECT * FROM pending_changes WHERE id = ?").get(id);
6219
+ if (!row) throw new Error(`Изменение не найдено: ${id}`);
6220
+ return row;
6221
+ } finally {
6222
+ db.close();
6223
+ }
6224
+ }
6225
+
6226
+ function updateChangeStatus(id, status) {
6227
+ initDatabase();
6228
+ const db = openDatabase();
6229
+ try {
6230
+ db.prepare("UPDATE pending_changes SET status = ?, applied_at = CASE WHEN ? = 'applied' THEN datetime('now') ELSE applied_at END WHERE id = ?").run(status, status, id);
6231
+ } finally {
6232
+ db.close();
6233
+ }
6234
+ }
6235
+
6236
+ async function applyChange(id) {
6237
+ const change = getChange(id);
6238
+ if (change.status !== "pending") throw new Error(`Изменение уже не pending: ${change.status}`);
6239
+ await filesWrite(change.target, change.after_text);
6240
+ updateChangeStatus(id, "applied");
6241
+ }
6242
+
6243
+ async function importDataFile(target, dataset) {
6244
+ const text = await filesRead(target, { maxBytes: 5_000_000 });
6245
+ const ext = path.extname(target).toLocaleLowerCase("ru-RU");
6246
+ let rows = [];
6247
+ if (ext === ".json") {
6248
+ const parsed = JSON.parse(text);
6249
+ rows = Array.isArray(parsed) ? parsed : normalizeItems(parsed);
6250
+ } else if (ext === ".csv") {
6251
+ rows = parseCsv(text);
6252
+ } else {
6253
+ throw new Error("Поддерживается импорт JSON и CSV.");
6254
+ }
6255
+ saveCustomRecords(dataset, rows);
6256
+ return rows.length;
6257
+ }
6258
+
6259
+ function parseCsv(text) {
6260
+ const lines = text.split(/\r?\n/).filter(Boolean);
6261
+ const headers = splitCsvLine(lines.shift() || "");
6262
+ return lines.map((line) => {
6263
+ const values = splitCsvLine(line);
6264
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || ""]));
6265
+ });
6266
+ }
6267
+
6268
+ function splitCsvLine(line) {
6269
+ const result = [];
6270
+ let current = "";
6271
+ let quote = false;
6272
+ for (let index = 0; index < line.length; index += 1) {
6273
+ const char = line[index];
6274
+ if (char === '"') quote = !quote;
6275
+ else if (char === "," && !quote) {
6276
+ result.push(current);
6277
+ current = "";
6278
+ } else current += char;
6279
+ }
6280
+ result.push(current);
6281
+ return result.map((value) => value.trim());
6282
+ }
6283
+
6284
+ function saveCustomRecords(dataset, rows) {
6285
+ initDatabase();
6286
+ const db = openDatabase();
6287
+ try {
6288
+ const insert = db.prepare("INSERT INTO custom_records(dataset, record_key, record_json, searchable_text) VALUES (?, ?, ?, ?) ON CONFLICT(dataset, record_key) DO UPDATE SET record_json = excluded.record_json, searchable_text = excluded.searchable_text, imported_at = datetime('now')");
6289
+ const insertFts = db.prepare("INSERT INTO custom_records_fts(dataset, record_key, searchable_text) VALUES (?, ?, ?)");
6290
+ db.prepare("DELETE FROM custom_records_fts WHERE dataset = ?").run(dataset);
6291
+ rows.forEach((row, index) => {
6292
+ const key = String(row.id || row.inn || index + 1);
6293
+ const json = JSON.stringify(row);
6294
+ const text = json.toLocaleLowerCase("ru-RU");
6295
+ insert.run(dataset, key, json, text);
6296
+ insertFts.run(dataset, key, text);
6297
+ });
6298
+ } finally {
6299
+ db.close();
6300
+ }
6301
+ }
6302
+
6303
+ async function indexFolder(target, options = {}) {
6304
+ const rows = await filesTree(target, { depth: Number(options.depth || 5), limit: Number(options.limit || 1000) });
6305
+ let count = 0;
6306
+ for (const row of rows.filter((item) => item.type === "file" && INDEXABLE_EXTENSIONS.test(item.path))) {
6307
+ try {
6308
+ const text = await filesRead(row.path, { maxBytes: 1_000_000 });
6309
+ saveIndexedDoc(row.path, path.basename(row.path), text);
6310
+ count += 1;
6311
+ } catch {
6312
+ // Skip unreadable files.
6313
+ }
6314
+ }
6315
+ return count;
6316
+ }
6317
+
6318
+ function saveIndexedDoc(file, title, content) {
6319
+ initDatabase();
6320
+ const db = openDatabase();
6321
+ try {
6322
+ const result = db.prepare("INSERT INTO doc_index(file, title, content) VALUES (?, ?, ?)").run(file, title, content);
6323
+ db.prepare("INSERT INTO doc_index_fts(rowid, file, title, content) VALUES (?, ?, ?, ?)").run(Number(result.lastInsertRowid), file, title, content);
6324
+ } finally {
6325
+ db.close();
6326
+ }
6327
+ }
6328
+
6329
+ function getIndexStatus() {
6330
+ initDatabase();
6331
+ const db = openDatabase();
6332
+ try {
6333
+ const docs = db.prepare("SELECT COUNT(*) AS count FROM doc_index").get();
6334
+ return { docs: docs?.count || 0 };
6335
+ } finally {
6336
+ db.close();
6337
+ }
6338
+ }
6339
+
6340
+ function searchDocs(query, limit = 20) {
6341
+ initDatabase();
6342
+ const db = openDatabase();
6343
+ try {
6344
+ const rows = db.prepare("SELECT file, title, snippet(doc_index_fts, 2, '[', ']', '...', 16) AS snippet FROM doc_index_fts WHERE doc_index_fts MATCH ? LIMIT ?").all(toFtsQuery(query), limit);
6345
+ return rows;
6346
+ } finally {
6347
+ db.close();
6348
+ }
6349
+ }
6350
+
6351
+ function listPlugins() {
6352
+ initDatabase();
6353
+ const db = openDatabase();
6354
+ try {
6355
+ return db.prepare("SELECT name, source, COALESCE(command, '-') AS command FROM plugins ORDER BY name").all();
6356
+ } finally {
6357
+ db.close();
6358
+ }
6359
+ }
6360
+
6361
+ function savePlugin(name, source, command) {
6362
+ initDatabase();
6363
+ const db = openDatabase();
6364
+ try {
6365
+ db.prepare("INSERT INTO plugins(name, source, command) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET source = excluded.source, command = excluded.command").run(name, source, command);
6366
+ } finally {
6367
+ db.close();
6368
+ }
6369
+ }
6370
+
6371
+ function getPlugin(name) {
6372
+ initDatabase();
6373
+ const db = openDatabase();
6374
+ try {
6375
+ const row = db.prepare("SELECT * FROM plugins WHERE name = ?").get(name);
6376
+ if (!row) throw new Error(`Plugin не найден: ${name}`);
6377
+ return row;
6378
+ } finally {
6379
+ db.close();
6380
+ }
6381
+ }
6382
+
6383
+ function deletePlugin(name) {
6384
+ initDatabase();
6385
+ const db = openDatabase();
6386
+ try {
6387
+ db.prepare("DELETE FROM plugins WHERE name = ?").run(name);
6388
+ } finally {
6389
+ db.close();
6390
+ }
6391
+ }
6392
+
5787
6393
  function unifiedPreview(before, after) {
5788
6394
  const beforeLines = before.split(/\r?\n/);
5789
6395
  const afterLines = after.split(/\r?\n/);
@@ -5831,6 +6437,9 @@ async function executeRpc(method, options = {}) {
5831
6437
  if (method === "files.search") {
5832
6438
  return filesSearch(options.query || options.search || "", options);
5833
6439
  }
6440
+ if (method === "index.search") {
6441
+ return searchDocs(options.query || options.search || "", Number(options.limit || 20));
6442
+ }
5834
6443
  throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
5835
6444
  }
5836
6445
 
package/wiki/Home.md CHANGED
@@ -31,6 +31,7 @@ iola ask "найди школу 29"
31
31
  - [Skills и toolsets](Skills-и-toolsets)
32
32
  - [Локальные файлы](Локальные-файлы)
33
33
  - [Рабочая среда агента](Рабочая-среда-агента)
34
+ - [Расширения и локальные данные](Расширения-и-локальные-данные)
34
35
  - [Daemon, RPC и cron](Daemon-RPC-и-cron)
35
36
  - [Контекст и память](Контекст-и-память)
36
37
  - [Команды](Команды)
@@ -57,6 +57,12 @@ iola artifacts list
57
57
  iola snapshot list
58
58
  iola trace last
59
59
  iola export education-contacts --format xlsx --output contacts.xlsx
60
+ iola changes list
61
+ iola import file data.csv --dataset custom
62
+ iola index folder ./docs
63
+ iola reports list
64
+ iola plugins list
65
+ iola mcp serve
60
66
  iola memory suggest
61
67
  ```
62
68
 
@@ -36,6 +36,18 @@ iola files write report.md --text "Текст отчета"
36
36
  iola files patch README.md --search old --replace new
37
37
  ```
38
38
 
39
+ Чтение и индексирование поддерживает:
40
+
41
+ - `.docx`
42
+ - `.xlsx`
43
+ - `.pptx`
44
+ - `.pdf`
45
+ - `.md`
46
+ - `.txt`
47
+ - `.csv`
48
+ - `.json`
49
+ - `.html`
50
+
39
51
  AI/tool-agent:
40
52
 
41
53
  ```bash
@@ -43,4 +55,3 @@ iola ask "найди в текущей папке упоминания школ"
43
55
  ```
44
56
 
45
57
  По умолчанию файловый режим `locked`. Запись требует включения `workspace-write` или `full-access`.
46
-
@@ -0,0 +1,65 @@
1
+ # Расширения и локальные данные
2
+
3
+ Staged changes:
4
+
5
+ ```bash
6
+ iola files write report.md --text "..." --stage
7
+ iola files patch README.md --search old --replace new --stage
8
+ iola changes list
9
+ iola changes show 1
10
+ iola changes apply 1
11
+ iola changes discard 1
12
+ ```
13
+
14
+ Импорт локальных данных:
15
+
16
+ ```bash
17
+ iola import file data.csv --dataset custom-schools
18
+ iola import file data.json --dataset custom
19
+ iola import folder ./data
20
+ ```
21
+
22
+ Индекс документов:
23
+
24
+ ```bash
25
+ iola index folder ./docs
26
+ iola index status
27
+ iola index search "школа 29"
28
+ ```
29
+
30
+ Поддерживаемые форматы для чтения и индекса:
31
+
32
+ - `.docx`
33
+ - `.xlsx`
34
+ - `.pptx`
35
+ - `.pdf`
36
+ - `.md`
37
+ - `.txt`
38
+ - `.csv`
39
+ - `.json`
40
+ - `.html`
41
+
42
+ Пакеты отчетов:
43
+
44
+ ```bash
45
+ iola reports list
46
+ iola reports run education-passport
47
+ iola reports run data-quality-pack
48
+ ```
49
+
50
+ Plugins:
51
+
52
+ ```bash
53
+ iola plugins list
54
+ iola plugins install quality --command "iola quality"
55
+ iola plugins run quality
56
+ iola plugins remove quality
57
+ ```
58
+
59
+ Локальный MCP endpoint:
60
+
61
+ ```bash
62
+ iola mcp serve
63
+ ```
64
+
65
+ По умолчанию MCP запускается на порту `daemon.port + 1`.