@iola_adm/iola-cli 0.1.21 → 0.1.23
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 +17 -1
- package/package.json +2 -1
- package/skills/local-model/SKILL.md +9 -0
- package/skills/open-data/SKILL.md +10 -0
- package/skills/reports/SKILL.md +10 -0
- package/src/cli.js +1058 -18
- package/wiki/Daemon-RPC-/320/270-cron.md +36 -0
- package/wiki/Home.md +3 -0
- package/wiki/Skills-/320/270-toolsets.md +31 -0
- package/wiki//320/232/320/276/320/274/320/260/320/275/320/264/321/213.md +29 -0
- package/wiki//320/232/320/276/320/275/321/202/320/265/320/272/321/201/321/202-/320/270-/320/277/320/260/320/274/321/217/321/202/321/214.md +27 -0
- package/wiki//320/233/320/276/320/272/320/260/320/273/321/214/320/275/321/213/320/271-/320/270/320/275/321/201/321/202/321/200/321/203/320/274/320/265/320/275/321/202/320/260/320/273/321/214/320/275/321/213/320/271-/320/260/320/263/320/265/320/275/321/202.md +16 -0
package/src/cli.js
CHANGED
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { appendFile, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import readline from "node:readline/promises";
|
|
7
8
|
import { stdin as input, stdout as output } from "node:process";
|
|
8
9
|
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
9
11
|
|
|
10
12
|
const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
|
|
11
13
|
const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
|
|
12
14
|
const MIN_NODE_VERSION = "22.5.0";
|
|
13
15
|
const CONFIG_DIR = path.join(os.homedir(), ".iola");
|
|
14
16
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
17
|
+
const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
|
|
15
18
|
const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
|
|
16
19
|
const DB_FILE = path.join(CONFIG_DIR, "iola.db");
|
|
17
|
-
const DB_SCHEMA_VERSION =
|
|
20
|
+
const DB_SCHEMA_VERSION = 4;
|
|
18
21
|
const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
|
|
19
22
|
const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
|
|
23
|
+
const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BUILTIN_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
|
|
26
|
+
const USER_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
|
|
27
|
+
const PROJECT_CONTEXT_FILE = path.join(process.cwd(), "IOLA.md");
|
|
28
|
+
const PROJECT_CONTEXT_DIR_FILE = path.join(process.cwd(), ".iola", "context.md");
|
|
29
|
+
const TOOLSETS = {
|
|
30
|
+
"data-read": {
|
|
31
|
+
description: "Чтение открытых данных и локальный поиск.",
|
|
32
|
+
permissions: { externalApi: true, localTools: { search_local: true, get_card: true, run_report: true } },
|
|
33
|
+
},
|
|
34
|
+
reports: {
|
|
35
|
+
description: "Отчеты, выгрузки и сохранение view.",
|
|
36
|
+
permissions: { writeFiles: true, localTools: { export_data: true, save_view: true, run_report: true } },
|
|
37
|
+
},
|
|
38
|
+
sync: {
|
|
39
|
+
description: "Обновление локальной копии данных из публичного API.",
|
|
40
|
+
permissions: { sync: true, externalApi: true },
|
|
41
|
+
},
|
|
42
|
+
ai: {
|
|
43
|
+
description: "Внешние AI-провайдеры и Codex CLI.",
|
|
44
|
+
permissions: { externalAi: true, codex: true },
|
|
45
|
+
},
|
|
46
|
+
safe: {
|
|
47
|
+
description: "Безопасный режим: чтение данных без записи файлов и без sync.",
|
|
48
|
+
permissions: { writeFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
|
|
49
|
+
},
|
|
50
|
+
full: {
|
|
51
|
+
description: "Полный локальный режим для доверенного пользователя.",
|
|
52
|
+
permissions: {
|
|
53
|
+
writeFiles: true,
|
|
54
|
+
sync: true,
|
|
55
|
+
externalApi: true,
|
|
56
|
+
externalAi: true,
|
|
57
|
+
codex: true,
|
|
58
|
+
localTools: Object.fromEntries(LOCAL_TOOLS.map((tool) => [tool, true])),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
20
62
|
const FEATURES = {
|
|
21
63
|
"sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
|
|
22
64
|
sessions: { stage: "stable", defaultEnabled: true, description: "Сессии, resume и fork для AI-диалогов." },
|
|
@@ -74,8 +116,22 @@ const DEFAULT_AI_CONFIG = {
|
|
|
74
116
|
externalAi: true,
|
|
75
117
|
codex: true,
|
|
76
118
|
},
|
|
119
|
+
toolsets: {
|
|
120
|
+
enabled: ["data-read", "reports", "sync", "ai"],
|
|
121
|
+
},
|
|
77
122
|
memory: {
|
|
78
123
|
enabled: true,
|
|
124
|
+
suggestions: true,
|
|
125
|
+
},
|
|
126
|
+
skills: {
|
|
127
|
+
enabled: ["open-data", "reports", "local-model"],
|
|
128
|
+
},
|
|
129
|
+
daemon: {
|
|
130
|
+
host: "127.0.0.1",
|
|
131
|
+
port: DAEMON_PORT,
|
|
132
|
+
},
|
|
133
|
+
cron: {
|
|
134
|
+
enabled: true,
|
|
79
135
|
},
|
|
80
136
|
hooks: {},
|
|
81
137
|
};
|
|
@@ -154,6 +210,12 @@ const COMMANDS = new Map([
|
|
|
154
210
|
["fork", forkSession],
|
|
155
211
|
["features", handleFeatures],
|
|
156
212
|
["wiki", handleWiki],
|
|
213
|
+
["context", handleContext],
|
|
214
|
+
["skills", handleSkills],
|
|
215
|
+
["tools", handleTools],
|
|
216
|
+
["cron", handleCron],
|
|
217
|
+
["daemon", handleDaemon],
|
|
218
|
+
["rpc", handleRpc],
|
|
157
219
|
["permissions", handlePermissions],
|
|
158
220
|
["memory", handleMemory],
|
|
159
221
|
["hooks", handleHooks],
|
|
@@ -250,6 +312,12 @@ Usage:
|
|
|
250
312
|
iola fork SESSION_ID [TEXT]
|
|
251
313
|
iola features list|enable|disable
|
|
252
314
|
iola wiki [open|links]
|
|
315
|
+
iola context list|show|init
|
|
316
|
+
iola skills list|show|paths|enable|disable
|
|
317
|
+
iola tools list|toolsets|enable|disable|profile
|
|
318
|
+
iola cron list|add|delete|run|tick
|
|
319
|
+
iola daemon start|status
|
|
320
|
+
iola rpc call METHOD [ARGS] [--json]
|
|
253
321
|
iola permissions list|allow|deny
|
|
254
322
|
iola memory show|add|set|clear|export
|
|
255
323
|
iola hooks list|add|delete|run
|
|
@@ -270,6 +338,8 @@ Usage:
|
|
|
270
338
|
iola alias add NAME COMMAND
|
|
271
339
|
iola run "выгрузи школы на Петрова в csv"
|
|
272
340
|
iola config get
|
|
341
|
+
iola config validate
|
|
342
|
+
iola config schema
|
|
273
343
|
iola config set api.baseUrl URL
|
|
274
344
|
iola config set api.mcpBaseUrl URL
|
|
275
345
|
iola config reset
|
|
@@ -360,6 +430,7 @@ async function handleAgentLine(line, state) {
|
|
|
360
430
|
}
|
|
361
431
|
|
|
362
432
|
const [command, ...args] = splitCommandLine(line.slice(1));
|
|
433
|
+
state.lastCommand = { command, args };
|
|
363
434
|
|
|
364
435
|
if (command === "exit" || command === "quit") {
|
|
365
436
|
return true;
|
|
@@ -376,6 +447,40 @@ async function handleAgentLine(line, state) {
|
|
|
376
447
|
return false;
|
|
377
448
|
}
|
|
378
449
|
|
|
450
|
+
if (command === "new" || command === "reset") {
|
|
451
|
+
state.history = [];
|
|
452
|
+
console.log("Начата новая agent-сессия.");
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (command === "undo") {
|
|
457
|
+
state.history.splice(Math.max(0, state.history.length - 2), 2);
|
|
458
|
+
console.log("Последний обмен удален из agent-истории.");
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (command === "retry") {
|
|
463
|
+
const lastUser = [...state.history].reverse().find((item) => item.role === "user");
|
|
464
|
+
if (!lastUser) {
|
|
465
|
+
console.log("Нет предыдущего вопроса для повтора.");
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const answer = await aiAsk([lastUser.content], { history: state.history.slice(0, -2) });
|
|
469
|
+
state.history.push({ role: "assistant", content: answer });
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (command === "compact") {
|
|
474
|
+
state.history = compactAgentHistory(state.history);
|
|
475
|
+
console.log(`Контекст сжат. Сообщений в agent-истории: ${state.history.length}`);
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (command === "usage") {
|
|
480
|
+
printAgentUsage(state.history);
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
379
484
|
if (command === "history") {
|
|
380
485
|
if (args.length > 0) {
|
|
381
486
|
await handleHistory(args);
|
|
@@ -415,6 +520,16 @@ async function handleAgentLine(line, state) {
|
|
|
415
520
|
return false;
|
|
416
521
|
}
|
|
417
522
|
|
|
523
|
+
if (command === "context") {
|
|
524
|
+
await handleContext(args.length > 0 ? args : ["list"]);
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (command === "skills") {
|
|
529
|
+
await handleSkills(args);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
418
533
|
if (command === "permissions") {
|
|
419
534
|
await handlePermissions(args);
|
|
420
535
|
return false;
|
|
@@ -436,7 +551,22 @@ async function handleAgentLine(line, state) {
|
|
|
436
551
|
}
|
|
437
552
|
|
|
438
553
|
if (command === "tools") {
|
|
439
|
-
await
|
|
554
|
+
await handleTools(args.length > 0 ? args : ["list"]);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (command === "cron") {
|
|
559
|
+
await handleCron(args);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (command === "daemon") {
|
|
564
|
+
await handleDaemon(args);
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (command === "rpc") {
|
|
569
|
+
await handleRpc(args);
|
|
440
570
|
return false;
|
|
441
571
|
}
|
|
442
572
|
|
|
@@ -545,11 +675,16 @@ async function handleAgentLine(line, state) {
|
|
|
545
675
|
fork: ["fork", args],
|
|
546
676
|
features: ["features", args],
|
|
547
677
|
wiki: ["wiki", args],
|
|
678
|
+
context: ["context", args],
|
|
679
|
+
skills: ["skills", args],
|
|
680
|
+
cron: ["cron", args],
|
|
681
|
+
daemon: ["daemon", args],
|
|
682
|
+
rpc: ["rpc", args],
|
|
548
683
|
permissions: ["permissions", args],
|
|
549
684
|
memory: ["memory", args],
|
|
550
685
|
hooks: ["hooks", args],
|
|
551
686
|
agents: ["agents", args],
|
|
552
|
-
tools: ["
|
|
687
|
+
tools: ["tools", args],
|
|
553
688
|
mcp: ["mcp", args],
|
|
554
689
|
cache: ["cache", args],
|
|
555
690
|
sync: ["sync", args],
|
|
@@ -585,8 +720,13 @@ function printAgentHelp() {
|
|
|
585
720
|
/resume SESSION_ID
|
|
586
721
|
/features list
|
|
587
722
|
/wiki
|
|
723
|
+
/context list
|
|
724
|
+
/skills list
|
|
588
725
|
/permissions
|
|
589
726
|
/tools
|
|
727
|
+
/cron list
|
|
728
|
+
/daemon status
|
|
729
|
+
/rpc call status
|
|
590
730
|
/memory show
|
|
591
731
|
/hooks list
|
|
592
732
|
/agents list
|
|
@@ -622,6 +762,12 @@ function printAgentHelp() {
|
|
|
622
762
|
/model
|
|
623
763
|
/history
|
|
624
764
|
/history --limit 20
|
|
765
|
+
/new
|
|
766
|
+
/reset
|
|
767
|
+
/retry
|
|
768
|
+
/undo
|
|
769
|
+
/compact
|
|
770
|
+
/usage
|
|
625
771
|
/clear
|
|
626
772
|
/banner
|
|
627
773
|
/update
|
|
@@ -642,6 +788,27 @@ function printAgentHistory(history) {
|
|
|
642
788
|
}
|
|
643
789
|
}
|
|
644
790
|
|
|
791
|
+
function compactAgentHistory(history) {
|
|
792
|
+
if (history.length <= 8) return history;
|
|
793
|
+
const summary = history.slice(0, -6)
|
|
794
|
+
.map((item) => `${item.role}: ${item.content}`)
|
|
795
|
+
.join("\n")
|
|
796
|
+
.slice(0, 3000);
|
|
797
|
+
return [
|
|
798
|
+
{ role: "system", content: `Сжатая история предыдущего диалога:\n${summary}` },
|
|
799
|
+
...history.slice(-6),
|
|
800
|
+
];
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function printAgentUsage(history) {
|
|
804
|
+
const chars = history.reduce((sum, item) => sum + String(item.content || "").length, 0);
|
|
805
|
+
printKeyValue({
|
|
806
|
+
messages: history.length,
|
|
807
|
+
characters: chars,
|
|
808
|
+
approximate_tokens: Math.ceil(chars / 4),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
645
812
|
function safePrompt(rl, closed = false) {
|
|
646
813
|
if (closed) {
|
|
647
814
|
return;
|
|
@@ -740,6 +907,12 @@ async function doctor(args = []) {
|
|
|
740
907
|
nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
|
|
741
908
|
},
|
|
742
909
|
db: getDbStatus(),
|
|
910
|
+
config: {
|
|
911
|
+
file: CONFIG_FILE,
|
|
912
|
+
valid: validateConfig(config).length === 0 ? "yes" : "no",
|
|
913
|
+
errors: validateConfig(config),
|
|
914
|
+
lastGood: existsSync(LAST_GOOD_CONFIG_FILE) ? LAST_GOOD_CONFIG_FILE : "-",
|
|
915
|
+
},
|
|
743
916
|
api: {
|
|
744
917
|
baseUrl: apiBaseUrl,
|
|
745
918
|
mcpBaseUrl,
|
|
@@ -754,9 +927,31 @@ async function doctor(args = []) {
|
|
|
754
927
|
openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
|
|
755
928
|
ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
|
|
756
929
|
},
|
|
930
|
+
skills: {
|
|
931
|
+
enabled: config.skills?.enabled?.join(", ") || "-",
|
|
932
|
+
found: listSkills(config).length,
|
|
933
|
+
},
|
|
934
|
+
toolsets: {
|
|
935
|
+
enabled: config.toolsets?.enabled?.join(", ") || "-",
|
|
936
|
+
},
|
|
937
|
+
daemon: {
|
|
938
|
+
endpoint: `http://${config.daemon?.host || "127.0.0.1"}:${config.daemon?.port || DAEMON_PORT}`,
|
|
939
|
+
status: await probeEndpoint(`http://${config.daemon?.host || "127.0.0.1"}:${config.daemon?.port || DAEMON_PORT}/health`),
|
|
940
|
+
},
|
|
757
941
|
system: diagnostics,
|
|
758
942
|
};
|
|
759
943
|
|
|
944
|
+
if (options.fix) {
|
|
945
|
+
initDatabase();
|
|
946
|
+
const errors = validateConfig(config);
|
|
947
|
+
if (errors.length > 0) {
|
|
948
|
+
await writeConfig(mergeConfig(DEFAULT_AI_CONFIG, config));
|
|
949
|
+
}
|
|
950
|
+
await mkdir(USER_SKILLS_DIR, { recursive: true });
|
|
951
|
+
console.log("Автоисправление выполнено: БД и пользовательская папка skills проверены.");
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
760
955
|
if (options.json) {
|
|
761
956
|
printJson(report);
|
|
762
957
|
return;
|
|
@@ -766,6 +961,7 @@ async function doctor(args = []) {
|
|
|
766
961
|
printTable([
|
|
767
962
|
{ group: "cli", status: report.cli.nodeStatus === "ok" && report.cli.update !== "available" ? "ok" : "check" },
|
|
768
963
|
{ group: "sqlite", status: report.db.status },
|
|
964
|
+
{ group: "config", status: report.config.valid === "yes" ? "ok" : "error" },
|
|
769
965
|
{ group: "api", status: report.api.health },
|
|
770
966
|
{ group: "ai", status: report.ai.provider },
|
|
771
967
|
{ group: "ollama", status: report.ai.ollama },
|
|
@@ -782,12 +978,18 @@ async function doctor(args = []) {
|
|
|
782
978
|
console.log("SQLite");
|
|
783
979
|
printKeyValue(report.db);
|
|
784
980
|
console.log("");
|
|
981
|
+
console.log("Config");
|
|
982
|
+
printKeyValue(report.config);
|
|
983
|
+
console.log("");
|
|
785
984
|
console.log("API/MCP");
|
|
786
985
|
printKeyValue(report.api);
|
|
787
986
|
console.log("");
|
|
788
987
|
console.log("AI");
|
|
789
988
|
printKeyValue(report.ai);
|
|
790
989
|
console.log("");
|
|
990
|
+
console.log("Skills/Toolsets/Daemon");
|
|
991
|
+
printKeyValue({ ...report.skills, toolsets: report.toolsets.enabled, daemon: report.daemon.status });
|
|
992
|
+
console.log("");
|
|
791
993
|
printDiagnostics(diagnostics, recommendOllamaModel(diagnostics));
|
|
792
994
|
if (options.all) {
|
|
793
995
|
console.log("");
|
|
@@ -1046,13 +1248,29 @@ async function handleConfig(args) {
|
|
|
1046
1248
|
return;
|
|
1047
1249
|
}
|
|
1048
1250
|
|
|
1251
|
+
if (action === "validate") {
|
|
1252
|
+
const config = await loadConfig();
|
|
1253
|
+
const errors = validateConfig(config);
|
|
1254
|
+
if (errors.length > 0) {
|
|
1255
|
+
printTable(errors.map((error) => ({ error })), [["error", "Ошибка"]]);
|
|
1256
|
+
throw new Error("Конфигурация содержит ошибки.");
|
|
1257
|
+
}
|
|
1258
|
+
console.log("Конфигурация корректна.");
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (action === "schema") {
|
|
1263
|
+
printJson(configSchema());
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1049
1267
|
if (action === "reset") {
|
|
1050
1268
|
await writeConfig(DEFAULT_AI_CONFIG);
|
|
1051
1269
|
console.log(`Конфигурация сброшена: ${CONFIG_FILE}`);
|
|
1052
1270
|
return;
|
|
1053
1271
|
}
|
|
1054
1272
|
|
|
1055
|
-
throw new Error("Команды config: get, set, reset.");
|
|
1273
|
+
throw new Error("Команды config: get, set, validate, schema, reset.");
|
|
1056
1274
|
}
|
|
1057
1275
|
|
|
1058
1276
|
async function handleDb(args) {
|
|
@@ -1091,6 +1309,15 @@ async function handleHistory(args) {
|
|
|
1091
1309
|
const [action] = args;
|
|
1092
1310
|
const options = parseOptions(args);
|
|
1093
1311
|
|
|
1312
|
+
if (action === "search") {
|
|
1313
|
+
const query = options._.slice(1).join(" ").trim() || options.query || options.search;
|
|
1314
|
+
if (!query) throw new Error('Пример: iola history search "Петрова"');
|
|
1315
|
+
const rows = searchHistory(query, Number(options.limit || 20));
|
|
1316
|
+
if (options.json) printJson(rows);
|
|
1317
|
+
else printTable(rows, [["id", "ID"], ["created_at", "Дата"], ["profile", "Профиль"], ["question", "Вопрос"], ["answer", "Ответ"]]);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1094
1321
|
if (action === "clear") {
|
|
1095
1322
|
clearHistory();
|
|
1096
1323
|
console.log("История очищена.");
|
|
@@ -1123,6 +1350,24 @@ async function handleSessions(args) {
|
|
|
1123
1350
|
}
|
|
1124
1351
|
|
|
1125
1352
|
const options = parseOptions(args);
|
|
1353
|
+
|
|
1354
|
+
if (action === "search") {
|
|
1355
|
+
const query = options._.slice(1).join(" ").trim() || options.query || options.search;
|
|
1356
|
+
if (!query) throw new Error('Пример: iola sessions search "Петрова"');
|
|
1357
|
+
const rows = searchSessions(query, Number(options.limit || 20));
|
|
1358
|
+
if (options.json) printJson(rows);
|
|
1359
|
+
else printTable(rows, [["session_id", "Сессия"], ["message_id", "Сообщ."], ["role", "Роль"], ["content", "Текст"]]);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (action === "compact") {
|
|
1364
|
+
const sessionId = Number(args[1]);
|
|
1365
|
+
if (!sessionId) throw new Error("Пример: iola sessions compact 1");
|
|
1366
|
+
const result = compactSessionInDb(sessionId);
|
|
1367
|
+
printKeyValue(result);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1126
1371
|
const rows = listSessions(Number(options.limit || 20));
|
|
1127
1372
|
|
|
1128
1373
|
if (options.json) {
|
|
@@ -1207,6 +1452,9 @@ async function handleWiki(args) {
|
|
|
1207
1452
|
["Первый запуск", `${base}/Первый-запуск`],
|
|
1208
1453
|
["AI-профили", `${base}/AI-профили`],
|
|
1209
1454
|
["Локальный инструментальный агент", `${base}/Локальный-инструментальный-агент`],
|
|
1455
|
+
["Skills и toolsets", `${base}/Skills-и-toolsets`],
|
|
1456
|
+
["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
|
|
1457
|
+
["Контекст и память", `${base}/Контекст-и-память`],
|
|
1210
1458
|
["Команды", `${base}/Команды`],
|
|
1211
1459
|
["Решение проблем", `${base}/Решение-проблем`],
|
|
1212
1460
|
].map(([title, url]) => ({ title, url }));
|
|
@@ -1227,6 +1475,229 @@ async function handleWiki(args) {
|
|
|
1227
1475
|
throw new Error("Команды wiki: links, open.");
|
|
1228
1476
|
}
|
|
1229
1477
|
|
|
1478
|
+
async function handleContext(args) {
|
|
1479
|
+
const [action = "list"] = args;
|
|
1480
|
+
const files = await listContextFiles();
|
|
1481
|
+
|
|
1482
|
+
if (action === "list" || action === "ls") {
|
|
1483
|
+
printTable(files, [
|
|
1484
|
+
["scope", "Область"],
|
|
1485
|
+
["file", "Файл"],
|
|
1486
|
+
["exists", "Есть"],
|
|
1487
|
+
["size", "Размер"],
|
|
1488
|
+
]);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (action === "show") {
|
|
1493
|
+
const text = await buildProjectContextText();
|
|
1494
|
+
console.log(text || "Контекстные файлы не найдены.");
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (action === "init") {
|
|
1499
|
+
await mkdir(path.dirname(PROJECT_CONTEXT_DIR_FILE), { recursive: true });
|
|
1500
|
+
if (!existsSync(PROJECT_CONTEXT_FILE)) {
|
|
1501
|
+
await writeFile(PROJECT_CONTEXT_FILE, [
|
|
1502
|
+
"# Контекст iola",
|
|
1503
|
+
"",
|
|
1504
|
+
"Проект работает с открытыми данными городского округа \"Город Йошкар-Ола\".",
|
|
1505
|
+
"Ответы должны опираться на публичный API, локальную SQLite-БД и подключенные skills.",
|
|
1506
|
+
"",
|
|
1507
|
+
].join("\n"), "utf8");
|
|
1508
|
+
}
|
|
1509
|
+
if (!existsSync(PROJECT_CONTEXT_DIR_FILE)) {
|
|
1510
|
+
await writeFile(PROJECT_CONTEXT_DIR_FILE, [
|
|
1511
|
+
"# Рабочий контекст",
|
|
1512
|
+
"",
|
|
1513
|
+
"- Основные слои первого релиза: школы и детские сады.",
|
|
1514
|
+
"- Не выдумывать сведения, которых нет в источниках данных.",
|
|
1515
|
+
"",
|
|
1516
|
+
].join("\n"), "utf8");
|
|
1517
|
+
}
|
|
1518
|
+
console.log(`Контекст создан: ${PROJECT_CONTEXT_FILE}`);
|
|
1519
|
+
console.log(`Контекст создан: ${PROJECT_CONTEXT_DIR_FILE}`);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
throw new Error("Команды context: list, show, init.");
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function handleSkills(args) {
|
|
1527
|
+
const [action = "list", name] = args;
|
|
1528
|
+
const config = await loadConfig();
|
|
1529
|
+
|
|
1530
|
+
if (action === "list" || action === "ls") {
|
|
1531
|
+
const rows = listSkills(config).map((skill) => ({
|
|
1532
|
+
enabled: isSkillEnabled(config, skill.name) ? "yes" : "no",
|
|
1533
|
+
name: skill.name,
|
|
1534
|
+
source: skill.source,
|
|
1535
|
+
description: skill.description,
|
|
1536
|
+
file: skill.file,
|
|
1537
|
+
}));
|
|
1538
|
+
printTable(rows, [
|
|
1539
|
+
["enabled", "Вкл"],
|
|
1540
|
+
["name", "Skill"],
|
|
1541
|
+
["source", "Источник"],
|
|
1542
|
+
["description", "Описание"],
|
|
1543
|
+
["file", "Файл"],
|
|
1544
|
+
]);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (action === "paths") {
|
|
1549
|
+
printTable(skillRoots().map((root) => ({ root, exists: existsSync(root) ? "yes" : "no" })), [
|
|
1550
|
+
["root", "Папка"],
|
|
1551
|
+
["exists", "Есть"],
|
|
1552
|
+
]);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
if (action === "show") {
|
|
1557
|
+
const skill = findSkill(name, config);
|
|
1558
|
+
if (!skill) throw new Error(`Skill не найден: ${name}`);
|
|
1559
|
+
console.log(await readFile(skill.file, "utf8"));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (action === "enable" || action === "disable") {
|
|
1564
|
+
if (!name) throw new Error("Имя skill обязательно.");
|
|
1565
|
+
const enabled = new Set(config.skills?.enabled || []);
|
|
1566
|
+
if (action === "enable") enabled.add(name);
|
|
1567
|
+
else enabled.delete(name);
|
|
1568
|
+
await saveConfig({ skills: { ...(config.skills || {}), enabled: [...enabled] } });
|
|
1569
|
+
console.log(`${name}: ${action === "enable" ? "enabled" : "disabled"}`);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
throw new Error("Команды skills: list, paths, show NAME, enable NAME, disable NAME.");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
async function handleTools(args) {
|
|
1577
|
+
const [action = "list", name] = args;
|
|
1578
|
+
const config = await loadConfig();
|
|
1579
|
+
|
|
1580
|
+
if (action === "list" || action === "ls") {
|
|
1581
|
+
await handlePermissions(["tools"]);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (action === "toolsets") {
|
|
1586
|
+
const enabled = new Set(config.toolsets?.enabled || []);
|
|
1587
|
+
printTable(Object.entries(TOOLSETS).map(([toolset, meta]) => ({
|
|
1588
|
+
enabled: enabled.has(toolset) ? "yes" : "no",
|
|
1589
|
+
toolset,
|
|
1590
|
+
description: meta.description,
|
|
1591
|
+
})), [
|
|
1592
|
+
["enabled", "Вкл"],
|
|
1593
|
+
["toolset", "Toolset"],
|
|
1594
|
+
["description", "Описание"],
|
|
1595
|
+
]);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (action === "enable" || action === "disable") {
|
|
1600
|
+
if (!TOOLSETS[name]) throw new Error(`Toolset неизвестен. Доступно: ${Object.keys(TOOLSETS).join(", ")}`);
|
|
1601
|
+
const enabled = new Set(config.toolsets?.enabled || []);
|
|
1602
|
+
if (action === "enable") enabled.add(name);
|
|
1603
|
+
else enabled.delete(name);
|
|
1604
|
+
await saveConfig({ toolsets: { ...(config.toolsets || {}), enabled: [...enabled] } });
|
|
1605
|
+
console.log(`${name}: ${action === "enable" ? "enabled" : "disabled"}`);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (action === "profile") {
|
|
1610
|
+
if (!TOOLSETS[name]) throw new Error(`Профиль неизвестен. Доступно: ${Object.keys(TOOLSETS).join(", ")}`);
|
|
1611
|
+
const permissions = applyToolsetPermissions(DEFAULT_AI_CONFIG.permissions, [name]);
|
|
1612
|
+
await saveConfig({ toolsets: { enabled: [name] }, permissions });
|
|
1613
|
+
console.log(`Toolset-профиль применен: ${name}`);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
throw new Error("Команды tools: list, toolsets, enable NAME, disable NAME, profile NAME.");
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
async function handleCron(args) {
|
|
1621
|
+
const [action = "list", ...rest] = args;
|
|
1622
|
+
const options = parseOptions(rest);
|
|
1623
|
+
|
|
1624
|
+
if (action === "list" || action === "ls") {
|
|
1625
|
+
const rows = listCronJobs();
|
|
1626
|
+
if (options.json) printJson(rows);
|
|
1627
|
+
else printTable(rows, [["id", "ID"], ["enabled", "Вкл"], ["schedule_text", "Расписание"], ["command", "Команда"], ["last_run_at", "Последний запуск"]]);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (action === "add") {
|
|
1632
|
+
const text = rest.join(" ").trim();
|
|
1633
|
+
const separator = text.includes(" -- ") ? " -- " : " :: ";
|
|
1634
|
+
const [scheduleText, command] = text.split(separator).map((part) => part?.trim());
|
|
1635
|
+
if (!scheduleText || !command) {
|
|
1636
|
+
throw new Error('Пример: iola cron add "каждый день 09:00 -- quality"');
|
|
1637
|
+
}
|
|
1638
|
+
const id = addCronJob(scheduleText, command);
|
|
1639
|
+
console.log(`Cron-задача добавлена: ${id}`);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if (action === "delete" || action === "remove" || action === "rm") {
|
|
1644
|
+
const id = Number(rest[0]);
|
|
1645
|
+
if (!id) throw new Error("Пример: iola cron delete 1");
|
|
1646
|
+
deleteCronJob(id);
|
|
1647
|
+
console.log(`Cron-задача удалена: ${id}`);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (action === "run") {
|
|
1652
|
+
const id = Number(rest[0]);
|
|
1653
|
+
if (!id) throw new Error("Пример: iola cron run 1");
|
|
1654
|
+
await runCronJob(id);
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (action === "tick") {
|
|
1659
|
+
const rows = dueCronJobs();
|
|
1660
|
+
for (const row of rows) await runCronJob(row.id);
|
|
1661
|
+
console.log(`Выполнено cron-задач: ${rows.length}`);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
throw new Error('Команды cron: list, add "каждый день 09:00 -- quality", delete ID, run ID, tick.');
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function handleDaemon(args) {
|
|
1669
|
+
const [action = "status"] = args;
|
|
1670
|
+
const config = await loadConfig();
|
|
1671
|
+
const host = config.daemon?.host || "127.0.0.1";
|
|
1672
|
+
const port = Number(config.daemon?.port || DAEMON_PORT);
|
|
1673
|
+
|
|
1674
|
+
if (action === "status") {
|
|
1675
|
+
try {
|
|
1676
|
+
const payload = await fetchJson(`http://${host}:${port}/health`);
|
|
1677
|
+
printKeyValue(payload);
|
|
1678
|
+
} catch {
|
|
1679
|
+
printKeyValue({ status: "stopped", endpoint: `http://${host}:${port}` });
|
|
1680
|
+
}
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
if (action === "start" || action === "run") {
|
|
1685
|
+
await startDaemon(host, port);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
throw new Error("Команды daemon: status, start.");
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function handleRpc(args) {
|
|
1693
|
+
const [action = "call", method, ...rest] = args;
|
|
1694
|
+
if (action !== "call" || !method) {
|
|
1695
|
+
throw new Error("Пример: iola rpc call search --query Петрова --dataset schools");
|
|
1696
|
+
}
|
|
1697
|
+
const result = await executeRpc(method, parseOptions(rest));
|
|
1698
|
+
printJson(result);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1230
1701
|
async function openUrl(url) {
|
|
1231
1702
|
if (process.platform === "win32") {
|
|
1232
1703
|
await runCommand("rundll32", ["url.dll,FileProtocolHandler", url], { inherit: false });
|
|
@@ -1316,6 +1787,29 @@ async function handleMemory(args) {
|
|
|
1316
1787
|
return;
|
|
1317
1788
|
}
|
|
1318
1789
|
|
|
1790
|
+
if (action === "suggest" || action === "suggestions") {
|
|
1791
|
+
const rows = listMemorySuggestions(rest[0] || "pending");
|
|
1792
|
+
if (options.json) printJson(rows);
|
|
1793
|
+
else printTable(rows, [["id", "ID"], ["status", "Статус"], ["content", "Предложение"], ["reason", "Причина"], ["created_at", "Дата"]]);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (action === "approve") {
|
|
1798
|
+
const id = Number(rest[0]);
|
|
1799
|
+
if (!id) throw new Error("Пример: iola memory approve 1");
|
|
1800
|
+
const memoryId = approveMemorySuggestion(id);
|
|
1801
|
+
console.log(`Предложение принято. Память сохранена: ${memoryId}`);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
if (action === "reject") {
|
|
1806
|
+
const id = Number(rest[0]);
|
|
1807
|
+
if (!id) throw new Error("Пример: iola memory reject 1");
|
|
1808
|
+
resolveMemorySuggestion(id, "rejected");
|
|
1809
|
+
console.log(`Предложение отклонено: ${id}`);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1319
1813
|
if (action === "delete" || action === "remove" || action === "rm") {
|
|
1320
1814
|
const id = rest[0];
|
|
1321
1815
|
if (!id) throw new Error("Пример: iola memory delete 1");
|
|
@@ -1338,7 +1832,7 @@ async function handleMemory(args) {
|
|
|
1338
1832
|
return;
|
|
1339
1833
|
}
|
|
1340
1834
|
|
|
1341
|
-
throw new Error("Команды memory: show, add TEXT, delete ID, clear, export [FILE].");
|
|
1835
|
+
throw new Error("Команды memory: show, add TEXT, suggest, approve ID, reject ID, delete ID, clear, export [FILE].");
|
|
1342
1836
|
}
|
|
1343
1837
|
|
|
1344
1838
|
async function handleHooks(args) {
|
|
@@ -2185,6 +2679,8 @@ function initDatabase() {
|
|
|
2185
2679
|
error TEXT
|
|
2186
2680
|
);
|
|
2187
2681
|
CREATE INDEX IF NOT EXISTS idx_ask_history_created_at ON ask_history(created_at DESC);
|
|
2682
|
+
DROP TABLE IF EXISTS ask_history_fts;
|
|
2683
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ask_history_fts USING fts5(question, answer);
|
|
2188
2684
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
2189
2685
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2190
2686
|
parent_id INTEGER,
|
|
@@ -2206,6 +2702,8 @@ function initDatabase() {
|
|
|
2206
2702
|
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
2207
2703
|
);
|
|
2208
2704
|
CREATE INDEX IF NOT EXISTS idx_session_messages_session_id ON session_messages(session_id, id);
|
|
2705
|
+
DROP TABLE IF EXISTS session_messages_fts;
|
|
2706
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS session_messages_fts USING fts5(session_id UNINDEXED, role, content);
|
|
2209
2707
|
CREATE TABLE IF NOT EXISTS feature_flags (
|
|
2210
2708
|
name TEXT PRIMARY KEY,
|
|
2211
2709
|
enabled INTEGER NOT NULL,
|
|
@@ -2265,7 +2763,27 @@ function initDatabase() {
|
|
|
2265
2763
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2266
2764
|
);
|
|
2267
2765
|
CREATE INDEX IF NOT EXISTS idx_memory_created_at ON memory(created_at DESC);
|
|
2766
|
+
CREATE TABLE IF NOT EXISTS memory_suggestions (
|
|
2767
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2768
|
+
scope TEXT NOT NULL DEFAULT 'user',
|
|
2769
|
+
content TEXT NOT NULL,
|
|
2770
|
+
reason TEXT,
|
|
2771
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
2772
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2773
|
+
resolved_at TEXT
|
|
2774
|
+
);
|
|
2775
|
+
CREATE INDEX IF NOT EXISTS idx_memory_suggestions_status ON memory_suggestions(status, created_at DESC);
|
|
2776
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
2777
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2778
|
+
schedule_text TEXT NOT NULL,
|
|
2779
|
+
command TEXT NOT NULL,
|
|
2780
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
2781
|
+
last_run_at TEXT,
|
|
2782
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2783
|
+
);
|
|
2784
|
+
CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled ON cron_jobs(enabled, last_run_at);
|
|
2268
2785
|
`);
|
|
2786
|
+
rebuildFtsIfEmpty(db);
|
|
2269
2787
|
db.prepare(`
|
|
2270
2788
|
INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
2271
2789
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
@@ -2283,6 +2801,26 @@ function mkdirSyncSafe(directory) {
|
|
|
2283
2801
|
}
|
|
2284
2802
|
}
|
|
2285
2803
|
|
|
2804
|
+
function rebuildFtsIfEmpty(db) {
|
|
2805
|
+
try {
|
|
2806
|
+
const askCount = db.prepare("SELECT COUNT(*) AS count FROM ask_history_fts").get()?.count || 0;
|
|
2807
|
+
if (askCount === 0) {
|
|
2808
|
+
const rows = db.prepare("SELECT id, question, answer FROM ask_history ORDER BY id ASC").all();
|
|
2809
|
+
const insert = db.prepare("INSERT INTO ask_history_fts(rowid, question, answer) VALUES (?, ?, ?)");
|
|
2810
|
+
for (const row of rows) insert.run(row.id, row.question || "", row.answer || "");
|
|
2811
|
+
}
|
|
2812
|
+
const sessionCount = db.prepare("SELECT COUNT(*) AS count FROM session_messages_fts").get()?.count || 0;
|
|
2813
|
+
if (sessionCount === 0) {
|
|
2814
|
+
const rows = db.prepare("SELECT id, session_id, role, content FROM session_messages ORDER BY id ASC").all();
|
|
2815
|
+
const insert = db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)");
|
|
2816
|
+
for (const row of rows) insert.run(row.id, row.session_id, row.role || "", row.content || "");
|
|
2817
|
+
}
|
|
2818
|
+
} catch {
|
|
2819
|
+
// FTS rebuild is best-effort and must not block startup.
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
|
|
2286
2824
|
function getDbStatus() {
|
|
2287
2825
|
try {
|
|
2288
2826
|
initDatabase();
|
|
@@ -2294,6 +2832,8 @@ function getDbStatus() {
|
|
|
2294
2832
|
const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
|
|
2295
2833
|
const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
|
|
2296
2834
|
const memory = db.prepare("SELECT COUNT(*) AS count FROM memory").get();
|
|
2835
|
+
const memorySuggestions = db.prepare("SELECT COUNT(*) AS count FROM memory_suggestions WHERE status = 'pending'").get();
|
|
2836
|
+
const cron = db.prepare("SELECT COUNT(*) AS count FROM cron_jobs").get();
|
|
2297
2837
|
return {
|
|
2298
2838
|
status: "ok",
|
|
2299
2839
|
file: DB_FILE,
|
|
@@ -2303,6 +2843,8 @@ function getDbStatus() {
|
|
|
2303
2843
|
local_records: local?.count ?? 0,
|
|
2304
2844
|
cache: cache?.count ?? 0,
|
|
2305
2845
|
memory: memory?.count ?? 0,
|
|
2846
|
+
memory_suggestions: memorySuggestions?.count ?? 0,
|
|
2847
|
+
cron_jobs: cron?.count ?? 0,
|
|
2306
2848
|
};
|
|
2307
2849
|
} finally {
|
|
2308
2850
|
db.close();
|
|
@@ -2323,7 +2865,7 @@ function recordAskHistory({ question, answer, providerConfig, dataContext, error
|
|
|
2323
2865
|
initDatabase();
|
|
2324
2866
|
const db = openDatabase();
|
|
2325
2867
|
try {
|
|
2326
|
-
db.prepare(`
|
|
2868
|
+
const result = db.prepare(`
|
|
2327
2869
|
INSERT INTO ask_history(profile, provider, model, question, answer, context_json, error)
|
|
2328
2870
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2329
2871
|
`).run(
|
|
@@ -2335,6 +2877,7 @@ function recordAskHistory({ question, answer, providerConfig, dataContext, error
|
|
|
2335
2877
|
JSON.stringify(dataContext),
|
|
2336
2878
|
error || "",
|
|
2337
2879
|
);
|
|
2880
|
+
db.prepare("INSERT INTO ask_history_fts(rowid, question, answer) VALUES (?, ?, ?)").run(Number(result.lastInsertRowid), question, answer || error || "");
|
|
2338
2881
|
} finally {
|
|
2339
2882
|
db.close();
|
|
2340
2883
|
}
|
|
@@ -2362,7 +2905,7 @@ function clearHistory() {
|
|
|
2362
2905
|
initDatabase();
|
|
2363
2906
|
const db = openDatabase();
|
|
2364
2907
|
try {
|
|
2365
|
-
db.exec("DELETE FROM ask_history");
|
|
2908
|
+
db.exec("DELETE FROM ask_history; DELETE FROM ask_history_fts;");
|
|
2366
2909
|
} finally {
|
|
2367
2910
|
db.close();
|
|
2368
2911
|
}
|
|
@@ -2372,7 +2915,7 @@ function clearSessions() {
|
|
|
2372
2915
|
initDatabase();
|
|
2373
2916
|
const db = openDatabase();
|
|
2374
2917
|
try {
|
|
2375
|
-
db.exec("DELETE FROM session_messages; DELETE FROM sessions;");
|
|
2918
|
+
db.exec("DELETE FROM session_messages; DELETE FROM session_messages_fts; DELETE FROM sessions;");
|
|
2376
2919
|
} finally {
|
|
2377
2920
|
db.close();
|
|
2378
2921
|
}
|
|
@@ -2405,10 +2948,15 @@ function appendSessionExchange(sessionId, question, answer, dataContext, error)
|
|
|
2405
2948
|
}
|
|
2406
2949
|
const db = openDatabase();
|
|
2407
2950
|
try {
|
|
2408
|
-
db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'user', ?, ?)")
|
|
2951
|
+
const userResult = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'user', ?, ?)")
|
|
2409
2952
|
.run(sessionId, question, JSON.stringify(dataContext));
|
|
2410
|
-
|
|
2411
|
-
|
|
2953
|
+
const assistantContent = error || answer || "";
|
|
2954
|
+
const assistantResult = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, 'assistant', ?, ?)")
|
|
2955
|
+
.run(sessionId, assistantContent, JSON.stringify({ error: error || "" }));
|
|
2956
|
+
db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)")
|
|
2957
|
+
.run(Number(userResult.lastInsertRowid), sessionId, "user", question);
|
|
2958
|
+
db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)")
|
|
2959
|
+
.run(Number(assistantResult.lastInsertRowid), sessionId, "assistant", assistantContent);
|
|
2412
2960
|
db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
|
|
2413
2961
|
} finally {
|
|
2414
2962
|
db.close();
|
|
@@ -2491,6 +3039,78 @@ function forkSessionInDb(sessionId) {
|
|
|
2491
3039
|
}
|
|
2492
3040
|
}
|
|
2493
3041
|
|
|
3042
|
+
function searchHistory(query, limit = 20) {
|
|
3043
|
+
initDatabase();
|
|
3044
|
+
const db = openDatabase();
|
|
3045
|
+
try {
|
|
3046
|
+
const ftsQuery = toFtsQuery(query);
|
|
3047
|
+
return db.prepare(`
|
|
3048
|
+
SELECT h.id, h.created_at, h.profile, h.provider, h.model, h.question, h.answer
|
|
3049
|
+
FROM ask_history_fts f
|
|
3050
|
+
JOIN ask_history h ON h.id = f.rowid
|
|
3051
|
+
WHERE ask_history_fts MATCH ?
|
|
3052
|
+
ORDER BY rank
|
|
3053
|
+
LIMIT ?
|
|
3054
|
+
`).all(ftsQuery, limit);
|
|
3055
|
+
} finally {
|
|
3056
|
+
db.close();
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
function searchSessions(query, limit = 20) {
|
|
3061
|
+
initDatabase();
|
|
3062
|
+
const db = openDatabase();
|
|
3063
|
+
try {
|
|
3064
|
+
const ftsQuery = toFtsQuery(query);
|
|
3065
|
+
return db.prepare(`
|
|
3066
|
+
SELECT f.session_id, f.rowid AS message_id, f.role, f.content
|
|
3067
|
+
FROM session_messages_fts f
|
|
3068
|
+
WHERE session_messages_fts MATCH ?
|
|
3069
|
+
ORDER BY rank
|
|
3070
|
+
LIMIT ?
|
|
3071
|
+
`).all(ftsQuery, limit);
|
|
3072
|
+
} finally {
|
|
3073
|
+
db.close();
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
function compactSessionInDb(sessionId) {
|
|
3078
|
+
const messages = getSessionAiHistory(sessionId);
|
|
3079
|
+
if (messages.length <= 8) {
|
|
3080
|
+
return { session_id: sessionId, before: messages.length, after: messages.length, status: "skip" };
|
|
3081
|
+
}
|
|
3082
|
+
const keep = messages.slice(-6);
|
|
3083
|
+
const summary = messages.slice(0, -6)
|
|
3084
|
+
.map((message) => `${message.role}: ${message.content}`)
|
|
3085
|
+
.join("\n")
|
|
3086
|
+
.slice(0, 4000);
|
|
3087
|
+
const compacted = [
|
|
3088
|
+
{ role: "system", content: `Сжатая история предыдущей части сессии:\n${summary}` },
|
|
3089
|
+
...keep,
|
|
3090
|
+
];
|
|
3091
|
+
initDatabase();
|
|
3092
|
+
const db = openDatabase();
|
|
3093
|
+
try {
|
|
3094
|
+
db.prepare("DELETE FROM session_messages WHERE session_id = ?").run(sessionId);
|
|
3095
|
+
db.prepare("DELETE FROM session_messages_fts WHERE session_id = ?").run(sessionId);
|
|
3096
|
+
const insert = db.prepare("INSERT INTO session_messages(session_id, role, content, context_json) VALUES (?, ?, ?, ?)");
|
|
3097
|
+
const insertFts = db.prepare("INSERT INTO session_messages_fts(rowid, session_id, role, content) VALUES (?, ?, ?, ?)");
|
|
3098
|
+
for (const message of compacted) {
|
|
3099
|
+
const result = insert.run(sessionId, message.role, message.content, "{}");
|
|
3100
|
+
insertFts.run(Number(result.lastInsertRowid), sessionId, message.role, message.content);
|
|
3101
|
+
}
|
|
3102
|
+
db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
|
|
3103
|
+
return { session_id: sessionId, before: messages.length, after: compacted.length, status: "ok" };
|
|
3104
|
+
} finally {
|
|
3105
|
+
db.close();
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function toFtsQuery(query) {
|
|
3110
|
+
const terms = String(query).split(/\s+/).map((term) => term.replace(/["*]/g, "").trim()).filter(Boolean);
|
|
3111
|
+
return terms.length > 0 ? terms.map((term) => `"${term}"`).join(" OR ") : '""';
|
|
3112
|
+
}
|
|
3113
|
+
|
|
2494
3114
|
function listFeatures() {
|
|
2495
3115
|
initDatabase();
|
|
2496
3116
|
const db = openDatabase();
|
|
@@ -2895,11 +3515,160 @@ function clearMemory() {
|
|
|
2895
3515
|
}
|
|
2896
3516
|
}
|
|
2897
3517
|
|
|
3518
|
+
function listCronJobs() {
|
|
3519
|
+
initDatabase();
|
|
3520
|
+
const db = openDatabase();
|
|
3521
|
+
try {
|
|
3522
|
+
return db.prepare("SELECT id, schedule_text, command, enabled, COALESCE(last_run_at, '-') AS last_run_at, created_at FROM cron_jobs ORDER BY id DESC").all()
|
|
3523
|
+
.map((row) => ({ ...row, enabled: row.enabled ? "yes" : "no" }));
|
|
3524
|
+
} finally {
|
|
3525
|
+
db.close();
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
function addCronJob(scheduleText, command) {
|
|
3530
|
+
initDatabase();
|
|
3531
|
+
const db = openDatabase();
|
|
3532
|
+
try {
|
|
3533
|
+
const result = db.prepare("INSERT INTO cron_jobs(schedule_text, command) VALUES (?, ?)").run(scheduleText, command);
|
|
3534
|
+
return Number(result.lastInsertRowid);
|
|
3535
|
+
} finally {
|
|
3536
|
+
db.close();
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function deleteCronJob(id) {
|
|
3541
|
+
initDatabase();
|
|
3542
|
+
const db = openDatabase();
|
|
3543
|
+
try {
|
|
3544
|
+
db.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id);
|
|
3545
|
+
} finally {
|
|
3546
|
+
db.close();
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function dueCronJobs() {
|
|
3551
|
+
initDatabase();
|
|
3552
|
+
const db = openDatabase();
|
|
3553
|
+
try {
|
|
3554
|
+
return db.prepare("SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY id ASC").all()
|
|
3555
|
+
.filter((job) => isCronDue(job));
|
|
3556
|
+
} finally {
|
|
3557
|
+
db.close();
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
function isCronDue(job) {
|
|
3562
|
+
const normalized = job.schedule_text.toLocaleLowerCase("ru-RU");
|
|
3563
|
+
const lastRun = job.last_run_at ? new Date(`${job.last_run_at}Z`) : null;
|
|
3564
|
+
const now = new Date();
|
|
3565
|
+
if (lastRun && now.getTime() - lastRun.getTime() < 60_000) return false;
|
|
3566
|
+
if (normalized.includes("каждый час") || normalized.includes("hourly")) {
|
|
3567
|
+
return !lastRun || now.getTime() - lastRun.getTime() >= 60 * 60 * 1000;
|
|
3568
|
+
}
|
|
3569
|
+
if (normalized.includes("каждый день") || normalized.includes("daily")) {
|
|
3570
|
+
return !lastRun || now.toISOString().slice(0, 10) !== lastRun.toISOString().slice(0, 10);
|
|
3571
|
+
}
|
|
3572
|
+
if (normalized.includes("каждую неделю") || normalized.includes("weekly")) {
|
|
3573
|
+
return !lastRun || now.getTime() - lastRun.getTime() >= 7 * 24 * 60 * 60 * 1000;
|
|
3574
|
+
}
|
|
3575
|
+
return !lastRun;
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
async function runCronJob(id) {
|
|
3579
|
+
initDatabase();
|
|
3580
|
+
const db = openDatabase();
|
|
3581
|
+
let job;
|
|
3582
|
+
try {
|
|
3583
|
+
job = db.prepare("SELECT * FROM cron_jobs WHERE id = ?").get(id);
|
|
3584
|
+
} finally {
|
|
3585
|
+
db.close();
|
|
3586
|
+
}
|
|
3587
|
+
if (!job) throw new Error(`Cron-задача не найдена: ${id}`);
|
|
3588
|
+
console.log(`> iola ${job.command}`);
|
|
3589
|
+
await main(splitCommandLine(job.command));
|
|
3590
|
+
const updateDb = openDatabase();
|
|
3591
|
+
try {
|
|
3592
|
+
updateDb.prepare("UPDATE cron_jobs SET last_run_at = datetime('now') WHERE id = ?").run(id);
|
|
3593
|
+
} finally {
|
|
3594
|
+
updateDb.close();
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
|
|
2898
3598
|
function buildMemoryText(limit = 20) {
|
|
2899
3599
|
const rows = listMemory(limit).reverse();
|
|
2900
3600
|
return rows.map((row) => `- ${row.content}`).join("\n");
|
|
2901
3601
|
}
|
|
2902
3602
|
|
|
3603
|
+
function addMemorySuggestion(content, reason, scope = "user") {
|
|
3604
|
+
initDatabase();
|
|
3605
|
+
const db = openDatabase();
|
|
3606
|
+
try {
|
|
3607
|
+
const existing = db.prepare("SELECT id FROM memory_suggestions WHERE status = 'pending' AND content = ?").get(content);
|
|
3608
|
+
if (existing) return Number(existing.id);
|
|
3609
|
+
const result = db.prepare("INSERT INTO memory_suggestions(scope, content, reason) VALUES (?, ?, ?)").run(scope, content, reason || "");
|
|
3610
|
+
return Number(result.lastInsertRowid);
|
|
3611
|
+
} finally {
|
|
3612
|
+
db.close();
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
function listMemorySuggestions(status = "pending") {
|
|
3617
|
+
initDatabase();
|
|
3618
|
+
const db = openDatabase();
|
|
3619
|
+
try {
|
|
3620
|
+
if (status === "all") {
|
|
3621
|
+
return db.prepare("SELECT id, scope, content, reason, status, created_at FROM memory_suggestions ORDER BY id DESC LIMIT 100").all();
|
|
3622
|
+
}
|
|
3623
|
+
return db.prepare("SELECT id, scope, content, reason, status, created_at FROM memory_suggestions WHERE status = ? ORDER BY id DESC LIMIT 100").all(status);
|
|
3624
|
+
} finally {
|
|
3625
|
+
db.close();
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
function approveMemorySuggestion(id) {
|
|
3630
|
+
initDatabase();
|
|
3631
|
+
const db = openDatabase();
|
|
3632
|
+
try {
|
|
3633
|
+
const row = db.prepare("SELECT * FROM memory_suggestions WHERE id = ?").get(id);
|
|
3634
|
+
if (!row) throw new Error(`Предложение памяти не найдено: ${id}`);
|
|
3635
|
+
const result = db.prepare("INSERT INTO memory(scope, content) VALUES (?, ?)").run(row.scope || "user", row.content);
|
|
3636
|
+
db.prepare("UPDATE memory_suggestions SET status = 'approved', resolved_at = datetime('now') WHERE id = ?").run(id);
|
|
3637
|
+
return Number(result.lastInsertRowid);
|
|
3638
|
+
} finally {
|
|
3639
|
+
db.close();
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
function resolveMemorySuggestion(id, status) {
|
|
3644
|
+
initDatabase();
|
|
3645
|
+
const db = openDatabase();
|
|
3646
|
+
try {
|
|
3647
|
+
db.prepare("UPDATE memory_suggestions SET status = ?, resolved_at = datetime('now') WHERE id = ?").run(status, id);
|
|
3648
|
+
} finally {
|
|
3649
|
+
db.close();
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
async function maybeSuggestMemory(question, answer, providerConfig) {
|
|
3654
|
+
const config = await loadConfig();
|
|
3655
|
+
if (config.memory?.suggestions === false) return;
|
|
3656
|
+
const normalized = `${question}\n${answer}`.toLocaleLowerCase("ru-RU");
|
|
3657
|
+
const suggestions = [];
|
|
3658
|
+
if (normalized.includes("кратко") || normalized.includes("коротко")) {
|
|
3659
|
+
suggestions.push(["Пользователь предпочитает краткие ответы.", "В запросе или ответе упоминался краткий формат."]);
|
|
3660
|
+
}
|
|
3661
|
+
if (normalized.includes("word") || normalized.includes("docx")) {
|
|
3662
|
+
suggestions.push(["Пользователь часто работает с документами Word/DOCX.", "В сессии упоминался формат Word/DOCX."]);
|
|
3663
|
+
}
|
|
3664
|
+
if (providerConfig?.name) {
|
|
3665
|
+
suggestions.push([`Последний активный AI-профиль: ${providerConfig.name}.`, "Зафиксирован используемый профиль AI."]);
|
|
3666
|
+
}
|
|
3667
|
+
for (const [content, reason] of suggestions.slice(0, 2)) {
|
|
3668
|
+
addMemorySuggestion(content, reason);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
2903
3672
|
function listAliases() {
|
|
2904
3673
|
initDatabase();
|
|
2905
3674
|
const db = openDatabase();
|
|
@@ -3075,7 +3844,7 @@ async function aiAsk(args, context = {}) {
|
|
|
3075
3844
|
const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
|
|
3076
3845
|
const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
|
|
3077
3846
|
const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
|
|
3078
|
-
const messages = buildAiMessages(question, dataContext, history, options);
|
|
3847
|
+
const messages = await buildAiMessages(question, dataContext, history, options, config);
|
|
3079
3848
|
let answer = "";
|
|
3080
3849
|
let errorMessage = "";
|
|
3081
3850
|
|
|
@@ -3095,6 +3864,7 @@ async function aiAsk(args, context = {}) {
|
|
|
3095
3864
|
recordAskHistory({ question, answer, providerConfig, dataContext, error: "", sessionId });
|
|
3096
3865
|
appendSessionExchange(sessionId, question, answer, dataContext, "");
|
|
3097
3866
|
}
|
|
3867
|
+
await maybeSuggestMemory(question, answer, providerConfig);
|
|
3098
3868
|
|
|
3099
3869
|
emitEvent(options, "answer", { length: answer.length, sessionId });
|
|
3100
3870
|
|
|
@@ -3300,7 +4070,7 @@ async function runHooks(event, payload = {}) {
|
|
|
3300
4070
|
|
|
3301
4071
|
async function assertPermission(name) {
|
|
3302
4072
|
const config = await loadConfig();
|
|
3303
|
-
const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
|
|
4073
|
+
const permissions = applyToolsetPermissions(config.permissions || DEFAULT_AI_CONFIG.permissions, config.toolsets?.enabled || []);
|
|
3304
4074
|
if (LOCAL_TOOLS.includes(name)) {
|
|
3305
4075
|
if (permissions.localTools?.[name] === false) {
|
|
3306
4076
|
throw new Error(`Tool запрещен политикой permissions: ${name}`);
|
|
@@ -3312,6 +4082,23 @@ async function assertPermission(name) {
|
|
|
3312
4082
|
}
|
|
3313
4083
|
}
|
|
3314
4084
|
|
|
4085
|
+
function applyToolsetPermissions(basePermissions, enabledToolsets) {
|
|
4086
|
+
const next = {
|
|
4087
|
+
...basePermissions,
|
|
4088
|
+
localTools: { ...(basePermissions.localTools || {}) },
|
|
4089
|
+
};
|
|
4090
|
+
for (const name of enabledToolsets || []) {
|
|
4091
|
+
const toolset = TOOLSETS[name];
|
|
4092
|
+
if (!toolset) continue;
|
|
4093
|
+
Object.assign(next, toolset.permissions || {});
|
|
4094
|
+
next.localTools = {
|
|
4095
|
+
...(next.localTools || {}),
|
|
4096
|
+
...(toolset.permissions?.localTools || {}),
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
return next;
|
|
4100
|
+
}
|
|
4101
|
+
|
|
3315
4102
|
function emitEvent(options, type, data) {
|
|
3316
4103
|
if (!options.events) {
|
|
3317
4104
|
return;
|
|
@@ -3442,9 +4229,11 @@ function scoreItem(item, terms, patterns, layer) {
|
|
|
3442
4229
|
return score;
|
|
3443
4230
|
}
|
|
3444
4231
|
|
|
3445
|
-
function buildAiMessages(question, dataContext, history, options = {}) {
|
|
4232
|
+
async function buildAiMessages(question, dataContext, history, options = {}, config = DEFAULT_AI_CONFIG) {
|
|
3446
4233
|
const sourceLines = buildSourceLines(dataContext);
|
|
3447
4234
|
const memoryText = options.bare ? "" : buildMemoryText();
|
|
4235
|
+
const projectContext = options.bare ? "" : await buildProjectContextText();
|
|
4236
|
+
const skillsText = options.bare ? "" : await buildSkillsText(config);
|
|
3448
4237
|
const system = [
|
|
3449
4238
|
"Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
|
|
3450
4239
|
"Отвечай на русском языке.",
|
|
@@ -3455,6 +4244,8 @@ function buildAiMessages(question, dataContext, history, options = {}) {
|
|
|
3455
4244
|
options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
|
|
3456
4245
|
options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
|
|
3457
4246
|
memoryText ? `Учитывай пользовательскую память:\n${memoryText}` : "",
|
|
4247
|
+
projectContext ? `Учитывай локальный контекст проекта:\n${projectContext}` : "",
|
|
4248
|
+
skillsText ? `Подключенные skills:\n${skillsText}` : "",
|
|
3458
4249
|
"Отвечай кратко и по делу.",
|
|
3459
4250
|
].filter(Boolean).join(" ");
|
|
3460
4251
|
const contextText = JSON.stringify(dataContext, null, 2);
|
|
@@ -3793,12 +4584,12 @@ function parseOptions(args) {
|
|
|
3793
4584
|
|
|
3794
4585
|
for (let index = 0; index < args.length; index += 1) {
|
|
3795
4586
|
const arg = args[index];
|
|
3796
|
-
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") {
|
|
4587
|
+
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" || arg === "--fix") {
|
|
3797
4588
|
result[arg.slice(2)] = true;
|
|
3798
4589
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
3799
4590
|
result.check = true;
|
|
3800
4591
|
result[arg.slice(2)] = true;
|
|
3801
|
-
} 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") {
|
|
4592
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || 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") {
|
|
3802
4593
|
result[arg.slice(2)] = args[index + 1];
|
|
3803
4594
|
index += 1;
|
|
3804
4595
|
} else {
|
|
@@ -3949,6 +4740,101 @@ async function getLocalDiagnostics() {
|
|
|
3949
4740
|
};
|
|
3950
4741
|
}
|
|
3951
4742
|
|
|
4743
|
+
async function listContextFiles() {
|
|
4744
|
+
const files = [
|
|
4745
|
+
{ scope: "project", file: PROJECT_CONTEXT_FILE },
|
|
4746
|
+
{ scope: "project-dir", file: PROJECT_CONTEXT_DIR_FILE },
|
|
4747
|
+
];
|
|
4748
|
+
const rows = [];
|
|
4749
|
+
for (const item of files) {
|
|
4750
|
+
try {
|
|
4751
|
+
const info = await stat(item.file);
|
|
4752
|
+
rows.push({ ...item, exists: "yes", size: info.size });
|
|
4753
|
+
} catch {
|
|
4754
|
+
rows.push({ ...item, exists: "no", size: "-" });
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
return rows;
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
async function buildProjectContextText() {
|
|
4761
|
+
const chunks = [];
|
|
4762
|
+
for (const item of await listContextFiles()) {
|
|
4763
|
+
if (item.exists !== "yes") continue;
|
|
4764
|
+
const text = await readFile(item.file, "utf8");
|
|
4765
|
+
chunks.push(`# ${item.scope}: ${item.file}\n${text.trim()}`);
|
|
4766
|
+
}
|
|
4767
|
+
return chunks.join("\n\n");
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
function skillRoots() {
|
|
4771
|
+
return [BUILTIN_SKILLS_DIR, USER_SKILLS_DIR, path.join(process.cwd(), ".iola", "skills")];
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
function listSkills(config = DEFAULT_AI_CONFIG) {
|
|
4775
|
+
const rows = [];
|
|
4776
|
+
for (const root of skillRoots()) {
|
|
4777
|
+
if (!existsSync(root)) continue;
|
|
4778
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
4779
|
+
const file = entry.isDirectory() ? path.join(root, entry.name, "SKILL.md") : entry.name.endsWith(".md") ? path.join(root, entry.name) : null;
|
|
4780
|
+
if (!file || !existsSync(file)) continue;
|
|
4781
|
+
const meta = readSkillMeta(file);
|
|
4782
|
+
rows.push({
|
|
4783
|
+
name: meta.name || path.basename(entry.name, ".md"),
|
|
4784
|
+
description: meta.description || "-",
|
|
4785
|
+
source: root === BUILTIN_SKILLS_DIR ? "builtin" : root === USER_SKILLS_DIR ? "user" : "project",
|
|
4786
|
+
file,
|
|
4787
|
+
enabled: isSkillEnabled(config, meta.name || path.basename(entry.name, ".md")),
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
return rows.sort((left, right) => left.name.localeCompare(right.name));
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
function findSkill(name, config) {
|
|
4795
|
+
if (!name) return null;
|
|
4796
|
+
return listSkills(config).find((skill) => skill.name === name);
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
function readSkillMeta(file) {
|
|
4800
|
+
try {
|
|
4801
|
+
const text = readFileSyncUtf8(file);
|
|
4802
|
+
const frontmatter = text.match(/^---\n([\s\S]*?)\n---/);
|
|
4803
|
+
const meta = {};
|
|
4804
|
+
if (frontmatter) {
|
|
4805
|
+
for (const line of frontmatter[1].split(/\r?\n/)) {
|
|
4806
|
+
const [key, ...parts] = line.split(":");
|
|
4807
|
+
if (key && parts.length > 0) meta[key.trim()] = parts.join(":").trim().replace(/^["']|["']$/g, "");
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
return meta;
|
|
4811
|
+
} catch {
|
|
4812
|
+
return {};
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
function readFileSyncUtf8(file) {
|
|
4817
|
+
return readFileSync(file, "utf8");
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4820
|
+
function isSkillEnabled(config, name) {
|
|
4821
|
+
return (config.skills?.enabled || []).includes(name);
|
|
4822
|
+
}
|
|
4823
|
+
|
|
4824
|
+
async function buildSkillsText(config) {
|
|
4825
|
+
const chunks = [];
|
|
4826
|
+
for (const skill of listSkills(config)) {
|
|
4827
|
+
if (!skill.enabled) continue;
|
|
4828
|
+
const text = await readFile(skill.file, "utf8");
|
|
4829
|
+
chunks.push(`## Skill: ${skill.name}\n${stripFrontmatter(text).trim()}`);
|
|
4830
|
+
}
|
|
4831
|
+
return chunks.join("\n\n").slice(0, 12000);
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
function stripFrontmatter(text) {
|
|
4835
|
+
return String(text).replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
4836
|
+
}
|
|
4837
|
+
|
|
3952
4838
|
async function getNvidiaGpu() {
|
|
3953
4839
|
try {
|
|
3954
4840
|
const { stdout } = await runCommand("nvidia-smi", [
|
|
@@ -4029,6 +4915,90 @@ async function probeEndpoint(url) {
|
|
|
4029
4915
|
}
|
|
4030
4916
|
}
|
|
4031
4917
|
|
|
4918
|
+
async function startDaemon(host, port) {
|
|
4919
|
+
const server = createServer(async (req, res) => {
|
|
4920
|
+
try {
|
|
4921
|
+
const url = new URL(req.url || "/", `http://${host}:${port}`);
|
|
4922
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
4923
|
+
|
|
4924
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
4925
|
+
res.end(JSON.stringify({ status: "running", endpoint: `http://${host}:${port}`, db: getDbStatus().status }));
|
|
4926
|
+
return;
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
4930
|
+
res.end(JSON.stringify({ status: "running", db: getDbStatus(), sync: getSyncStatus() }));
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
if (req.method === "POST" && url.pathname === "/rpc") {
|
|
4935
|
+
const body = await readRequestBody(req);
|
|
4936
|
+
const payload = body ? JSON.parse(body) : {};
|
|
4937
|
+
const result = await executeRpc(payload.method, { ...(payload.params || {}), _: [] });
|
|
4938
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
4939
|
+
return;
|
|
4940
|
+
}
|
|
4941
|
+
|
|
4942
|
+
res.statusCode = 404;
|
|
4943
|
+
res.end(JSON.stringify({ ok: false, error: "not found" }));
|
|
4944
|
+
} catch (error) {
|
|
4945
|
+
res.statusCode = 500;
|
|
4946
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
4947
|
+
}
|
|
4948
|
+
});
|
|
4949
|
+
|
|
4950
|
+
await new Promise((resolve, reject) => {
|
|
4951
|
+
server.once("error", reject);
|
|
4952
|
+
server.listen(port, host, resolve);
|
|
4953
|
+
});
|
|
4954
|
+
console.log(`iola daemon запущен: http://${host}:${port}`);
|
|
4955
|
+
console.log("Остановить: Ctrl+C");
|
|
4956
|
+
await new Promise(() => {});
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4959
|
+
function readRequestBody(req) {
|
|
4960
|
+
return new Promise((resolve, reject) => {
|
|
4961
|
+
let body = "";
|
|
4962
|
+
req.setEncoding("utf8");
|
|
4963
|
+
req.on("data", (chunk) => {
|
|
4964
|
+
body += chunk;
|
|
4965
|
+
if (body.length > 1024 * 1024) {
|
|
4966
|
+
reject(new Error("request body too large"));
|
|
4967
|
+
req.destroy();
|
|
4968
|
+
}
|
|
4969
|
+
});
|
|
4970
|
+
req.on("end", () => resolve(body));
|
|
4971
|
+
req.on("error", reject);
|
|
4972
|
+
});
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
async function executeRpc(method, options = {}) {
|
|
4976
|
+
if (method === "status") {
|
|
4977
|
+
return { db: getDbStatus(), sync: getSyncStatus(), activeProfile: getActiveProfileName(await loadConfig()) };
|
|
4978
|
+
}
|
|
4979
|
+
if (method === "search") {
|
|
4980
|
+
await ensureLocalData();
|
|
4981
|
+
return searchLocalRecords(options.query || options.search || options._?.join(" ") || "", {
|
|
4982
|
+
dataset: options.dataset || "all",
|
|
4983
|
+
limit: Number(options.limit || 20),
|
|
4984
|
+
fts: options.fts !== false,
|
|
4985
|
+
});
|
|
4986
|
+
}
|
|
4987
|
+
if (method === "card") {
|
|
4988
|
+
await ensureLocalData();
|
|
4989
|
+
return findCard(options.query || options.search || options._?.join(" ") || "");
|
|
4990
|
+
}
|
|
4991
|
+
if (method === "quality") {
|
|
4992
|
+
await ensureLocalData();
|
|
4993
|
+
return runQuality(options.scope || "all");
|
|
4994
|
+
}
|
|
4995
|
+
if (method === "sync") {
|
|
4996
|
+
await assertPermission("sync");
|
|
4997
|
+
return syncDataset(options.dataset || "schools");
|
|
4998
|
+
}
|
|
4999
|
+
throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
|
|
5000
|
+
}
|
|
5001
|
+
|
|
4032
5002
|
async function getLatestNpmVersion(packageName) {
|
|
4033
5003
|
try {
|
|
4034
5004
|
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
|
|
@@ -4141,7 +5111,14 @@ async function saveConfig(value) {
|
|
|
4141
5111
|
}
|
|
4142
5112
|
|
|
4143
5113
|
async function writeConfig(value) {
|
|
5114
|
+
const errors = validateConfig(value);
|
|
5115
|
+
if (errors.length > 0) {
|
|
5116
|
+
throw new Error(`Конфигурация не сохранена: ${errors.join("; ")}`);
|
|
5117
|
+
}
|
|
4144
5118
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
5119
|
+
if (existsSync(CONFIG_FILE)) {
|
|
5120
|
+
await copyFile(CONFIG_FILE, LAST_GOOD_CONFIG_FILE).catch(() => {});
|
|
5121
|
+
}
|
|
4145
5122
|
await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
4146
5123
|
}
|
|
4147
5124
|
|
|
@@ -4170,6 +5147,69 @@ function mergeConfig(base, override) {
|
|
|
4170
5147
|
...(override.ai?.profiles || {}),
|
|
4171
5148
|
},
|
|
4172
5149
|
},
|
|
5150
|
+
permissions: {
|
|
5151
|
+
...base.permissions,
|
|
5152
|
+
...(override.permissions || {}),
|
|
5153
|
+
localTools: {
|
|
5154
|
+
...(base.permissions?.localTools || {}),
|
|
5155
|
+
...(override.permissions?.localTools || {}),
|
|
5156
|
+
},
|
|
5157
|
+
},
|
|
5158
|
+
memory: {
|
|
5159
|
+
...base.memory,
|
|
5160
|
+
...(override.memory || {}),
|
|
5161
|
+
},
|
|
5162
|
+
skills: {
|
|
5163
|
+
...base.skills,
|
|
5164
|
+
...(override.skills || {}),
|
|
5165
|
+
},
|
|
5166
|
+
toolsets: {
|
|
5167
|
+
...base.toolsets,
|
|
5168
|
+
...(override.toolsets || {}),
|
|
5169
|
+
},
|
|
5170
|
+
daemon: {
|
|
5171
|
+
...base.daemon,
|
|
5172
|
+
...(override.daemon || {}),
|
|
5173
|
+
},
|
|
5174
|
+
cron: {
|
|
5175
|
+
...base.cron,
|
|
5176
|
+
...(override.cron || {}),
|
|
5177
|
+
},
|
|
5178
|
+
};
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
function validateConfig(config) {
|
|
5182
|
+
const errors = [];
|
|
5183
|
+
if (!config || typeof config !== "object") errors.push("config must be object");
|
|
5184
|
+
if (!config.api?.baseUrl) errors.push("api.baseUrl обязателен");
|
|
5185
|
+
if (!config.api?.mcpBaseUrl) errors.push("api.mcpBaseUrl обязателен");
|
|
5186
|
+
if (!config.ai?.profiles || typeof config.ai.profiles !== "object") errors.push("ai.profiles обязателен");
|
|
5187
|
+
if (config.ai?.activeProfile && !config.ai.profiles?.[config.ai.activeProfile]) errors.push(`ai.activeProfile не найден в profiles: ${config.ai.activeProfile}`);
|
|
5188
|
+
for (const [name, profile] of Object.entries(config.ai?.profiles || {})) {
|
|
5189
|
+
if (!["ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
|
|
5190
|
+
if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
|
|
5191
|
+
}
|
|
5192
|
+
for (const tool of Object.keys(config.permissions?.localTools || {})) {
|
|
5193
|
+
if (!LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
|
|
5194
|
+
}
|
|
5195
|
+
for (const toolset of config.toolsets?.enabled || []) {
|
|
5196
|
+
if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
|
|
5197
|
+
}
|
|
5198
|
+
return errors;
|
|
5199
|
+
}
|
|
5200
|
+
|
|
5201
|
+
function configSchema() {
|
|
5202
|
+
return {
|
|
5203
|
+
type: "object",
|
|
5204
|
+
required: ["api", "ai"],
|
|
5205
|
+
properties: {
|
|
5206
|
+
api: { required: ["baseUrl", "mcpBaseUrl"] },
|
|
5207
|
+
ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
|
|
5208
|
+
permissions: { localTools: LOCAL_TOOLS, runtime: ["writeFiles", "sync", "externalApi", "externalAi", "codex"] },
|
|
5209
|
+
toolsets: { available: Object.keys(TOOLSETS) },
|
|
5210
|
+
skills: { enabled: "array of skill names" },
|
|
5211
|
+
daemon: { host: "127.0.0.1", port: DAEMON_PORT },
|
|
5212
|
+
},
|
|
4173
5213
|
};
|
|
4174
5214
|
}
|
|
4175
5215
|
|