zames_pro 2.5.1 → 2.6.0
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 +32 -0
- package/dist/agent-loop.js +145 -6
- package/dist/browser.js +1 -1
- package/dist/config-menu.js +231 -0
- package/dist/config.js +143 -0
- package/dist/i18n.js +276 -0
- package/dist/index.js +415 -189
- package/dist/input.js +72 -7
- package/dist/spinner.js +11 -37
- package/dist/system-prompt.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,38 @@ zames --help
|
|
|
60
60
|
Global config: `~/.zames/config.json`
|
|
61
61
|
Local (per project): `.zamesrc.json`
|
|
62
62
|
|
|
63
|
+
You can view and change settings without leaving the agent — use the
|
|
64
|
+
`/config` command. Run it without arguments to open an interactive menu
|
|
65
|
+
(↑/↓ to move, Enter to change, `d` to reset, `q` to quit). Booleans and enums
|
|
66
|
+
toggle in place; numbers and strings open an input prompt.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
/config interactive settings menu
|
|
70
|
+
/config list print all editable settings
|
|
71
|
+
/config get <path> show a setting
|
|
72
|
+
/config set <path> <value> change a setting
|
|
73
|
+
/config reset <path> reset a setting to its default
|
|
74
|
+
/config path show config file paths
|
|
75
|
+
/config lang <ru|en> switch interface and agent language
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
/config set maxIterations 20
|
|
82
|
+
/config set confirmation.bash false
|
|
83
|
+
/config lang en
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Changes are written to the project `.zamesrc.json` and applied right away
|
|
87
|
+
(where possible without a restart).
|
|
88
|
+
|
|
89
|
+
### Language
|
|
90
|
+
|
|
91
|
+
`/config lang ru` or `/config lang en` switches both the interface language
|
|
92
|
+
(help, messages, spinner) and the language the agent answers you in. The
|
|
93
|
+
locale lives in `ui.locale` in the config file.
|
|
94
|
+
|
|
63
95
|
Agent data is stored in `~/.zames`: browser profile, logs, undo history, self-review snapshots.
|
|
64
96
|
|
|
65
97
|
## License
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildSystemPrompt } from './system-prompt.js';
|
|
2
2
|
import { getGitContext, formatGitContext } from './gitTools.js';
|
|
3
3
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
4
|
-
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, }) {
|
|
4
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, locale = 'ru', }) {
|
|
5
5
|
if (freshChat) {
|
|
6
6
|
await browser.newChat();
|
|
7
7
|
transcript?.log('new_chat');
|
|
@@ -35,6 +35,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
35
35
|
workdir,
|
|
36
36
|
tools,
|
|
37
37
|
gitContext: gitText,
|
|
38
|
+
locale,
|
|
38
39
|
});
|
|
39
40
|
transcript?.log('system_prompt', {
|
|
40
41
|
length: systemPrompt.length,
|
|
@@ -90,10 +91,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
90
91
|
// только явный JSON, но и XML/DSML-формы, «грязные» варианты и
|
|
91
92
|
// незакрытые фрагменты: если такой ответ молча принять за финальный,
|
|
92
93
|
// агент встанет, хотя модель пыталась позвать инструмент.
|
|
93
|
-
const looksLikeToolCall =
|
|
94
|
-
/\{\s*"?(tool|name|args)"?\s*:/.test(rawResponse) ||
|
|
95
|
-
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(rawResponse) ||
|
|
96
|
-
/^\s*\[?\s*\{[^}]*$/.test(rawResponse.trim());
|
|
94
|
+
const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
|
|
97
95
|
if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
98
96
|
malformedRetries++;
|
|
99
97
|
transcript?.log('malformed_toolcall', {
|
|
@@ -245,6 +243,31 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
245
243
|
}
|
|
246
244
|
return 'Достигнут лимит итераций.';
|
|
247
245
|
}
|
|
246
|
+
// Ответ похож на вызов инструмента, но parseToolCall() его не распознал.
|
|
247
|
+
// Используется как страховка от «агент вызвал инструмент и остановился»:
|
|
248
|
+
// в этом случае runAgentLoop просит модель переотправить вызов, а не
|
|
249
|
+
// завершает задачу. Ловим и явные форматы, и «поломанные» головы вызова
|
|
250
|
+
// (`<|tool": ...`, `**tool**:`, `tool": ...`), и обрезанные вызовы.
|
|
251
|
+
export function responseLooksLikeToolCall(rawResponse) {
|
|
252
|
+
const raw = rawResponse || '';
|
|
253
|
+
return (
|
|
254
|
+
// Явные маркеры форматов tool-call: JSON-ключ "tool", XML/DSML-теги,
|
|
255
|
+
// function_call и т.п.
|
|
256
|
+
/("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
|
|
257
|
+
// «tool» без открывающей кавычки/скобки, с мусорным префиксом
|
|
258
|
+
// (`<|tool":`, `**tool**:`, `- tool:`): ключ вызова, а не проза.
|
|
259
|
+
/(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
|
|
260
|
+
// Ключи в одинарных кавычках или без кавычек: {'tool': 'Read', ...}.
|
|
261
|
+
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
262
|
+
// Обрезанный вызов: начинается как JSON-объект, но не закрыт, и в нём
|
|
263
|
+
// есть ключ аргумента (args/command/path/...). Требуем именно открывающую
|
|
264
|
+
// скобку в начале (после пробелов/префикса), чтобы не ловить обычную
|
|
265
|
+
// прозу с двоеточиями вроде «path: ...».
|
|
266
|
+
/^\s*[\[\{]/.test(raw) &&
|
|
267
|
+
/["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
|
|
268
|
+
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
|
|
269
|
+
/^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
|
|
270
|
+
}
|
|
248
271
|
// Текст, который обещает вызов инструмента в будущем времени, но самого
|
|
249
272
|
// вызова не содержит. DeepSeek регулярно так «зависает»: пишет
|
|
250
273
|
// «Now update README to mention …», «Let me run the tests», «Сейчас проверю»
|
|
@@ -699,6 +722,81 @@ function findMatching(text, openIdx, openCh, closeCh) {
|
|
|
699
722
|
}
|
|
700
723
|
return -1;
|
|
701
724
|
}
|
|
725
|
+
// Модель иногда отдаёт вызов инструмента с ключами/строками в одинарных
|
|
726
|
+
// кавычках («{'tool': 'Read', 'args': {...}}») или с ключами без кавычек
|
|
727
|
+
// («{tool: "Read", args: {...}}»). Это не валидный JSON, и без нормализации
|
|
728
|
+
// такой ответ молча принимается за финальный — агент встаёт, не вызвав
|
|
729
|
+
// инструмент. Приводим его к двойным кавычкам.
|
|
730
|
+
function normalizePseudoJson(str) {
|
|
731
|
+
// Ключи без кавычек: {tool: ...} или , args: ... → "tool": / "args":
|
|
732
|
+
let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
733
|
+
out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
|
|
734
|
+
// Одинарные кавычки → двойные. Не трогаем содержимое уже двойных строк,
|
|
735
|
+
// идущее подряд, и экранируем случайные двойные внутри одинарных.
|
|
736
|
+
let res = '';
|
|
737
|
+
let inDouble = false;
|
|
738
|
+
let inSingle = false;
|
|
739
|
+
for (let i = 0; i < out.length; i++) {
|
|
740
|
+
const c = out[i];
|
|
741
|
+
if (c === '\\' && (inDouble || inSingle)) {
|
|
742
|
+
res += c;
|
|
743
|
+
if (i + 1 < out.length) {
|
|
744
|
+
res += out[i + 1];
|
|
745
|
+
i++;
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (c === '"' && !inSingle) {
|
|
750
|
+
inDouble = !inDouble;
|
|
751
|
+
res += c;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (c === "'" && !inDouble) {
|
|
755
|
+
if (!inSingle) {
|
|
756
|
+
inSingle = true;
|
|
757
|
+
res += '"';
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
inSingle = false;
|
|
761
|
+
res += '"';
|
|
762
|
+
}
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (inSingle && c === '"') {
|
|
766
|
+
res += '\\"';
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
res += c;
|
|
770
|
+
}
|
|
771
|
+
return res;
|
|
772
|
+
}
|
|
773
|
+
// Модель иногда портит начало вызова: `<|tool": "Bash", "args": {...}`,
|
|
774
|
+
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. В таких ответах
|
|
775
|
+
// нет открывающей `{`, а ключ `tool` лишился первой кавычки. Если такой
|
|
776
|
+
// ответ принять за финальный, агент молча встанет (частая «остановка»).
|
|
777
|
+
// Восстанавливаем: срезаем мусорный префикс до слова tool, добавляем `{` и
|
|
778
|
+
// доводим кавычки ключа до парных.
|
|
779
|
+
function repairToolCallPreamble(text) {
|
|
780
|
+
const t = (text || '').trim();
|
|
781
|
+
const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
|
|
782
|
+
if (!m || m.index === undefined)
|
|
783
|
+
return null;
|
|
784
|
+
// Начало ищем с первой кавычки/скобки вокруг ключа, иначе — с слова tool.
|
|
785
|
+
let start = m.index;
|
|
786
|
+
const brace = t.indexOf('{', Math.max(0, start - 1));
|
|
787
|
+
if (brace !== -1 && brace < start)
|
|
788
|
+
start = brace;
|
|
789
|
+
let frag = t.slice(start);
|
|
790
|
+
// Если фрагмент не начинается с `{` — добавляем его.
|
|
791
|
+
if (!frag.startsWith('{')) {
|
|
792
|
+
// Ключ мог потерять открывающую кавычку: tool": → "tool":.
|
|
793
|
+
// Срезаем ведущий мусор до слова tool и нормализуем кавычки ключа.
|
|
794
|
+
frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
|
|
795
|
+
frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
|
|
796
|
+
frag = '{' + frag;
|
|
797
|
+
}
|
|
798
|
+
return frag;
|
|
799
|
+
}
|
|
702
800
|
export function parseToolCall(text) {
|
|
703
801
|
if (!text || typeof text !== 'string')
|
|
704
802
|
return null;
|
|
@@ -731,8 +829,24 @@ export function parseToolCall(text) {
|
|
|
731
829
|
if (ctrlArr)
|
|
732
830
|
return ctrlArr;
|
|
733
831
|
}
|
|
832
|
+
// Псевдо-JSON (одинарные кавычки / ключи без кавычек) — нормализуем и
|
|
833
|
+
// пробуем распарсить как обычный вызов, прежде чем идти в permissive.
|
|
834
|
+
if (/['"]?tool['"]?\s*:/.test(cleaned)) {
|
|
835
|
+
const norm = normalizePseudoJson(cleaned);
|
|
836
|
+
if (norm !== cleaned) {
|
|
837
|
+
for (const raw of extractJsonObjects(norm)) {
|
|
838
|
+
const a = tryParseArray(raw);
|
|
839
|
+
if (a)
|
|
840
|
+
return a;
|
|
841
|
+
const o = tryParse(raw);
|
|
842
|
+
if (o)
|
|
843
|
+
return o;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
734
847
|
const permissive = parseToolCallPermissive(cleaned) ||
|
|
735
|
-
parseToolCallPermissive(repairRawControlChars(cleaned))
|
|
848
|
+
parseToolCallPermissive(repairRawControlChars(cleaned)) ||
|
|
849
|
+
parseToolCallPermissive(normalizePseudoJson(cleaned));
|
|
736
850
|
if (permissive)
|
|
737
851
|
return { ...permissive, _permissive: true };
|
|
738
852
|
const toolIdx = cleaned.search(/["']?tool["']?\s:/);
|
|
@@ -747,6 +861,31 @@ export function parseToolCall(text) {
|
|
|
747
861
|
const xmlCalls = parseXmlToolCalls(cleaned);
|
|
748
862
|
if (xmlCalls)
|
|
749
863
|
return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
|
|
864
|
+
// Последняя попытка: «починить» испорченную голову вызова (`<|tool": ...`,
|
|
865
|
+
// `tool": ...`, `**tool**: ...`, `- tool: ...`). Делаем это ТОЛЬКО как
|
|
866
|
+
// fallback, после обычного разбора — иначе легко испортить валидный JSON
|
|
867
|
+
// (например, массив вызовов начинается с `[`, внутри которого `{"tool":`).
|
|
868
|
+
const preamble = repairToolCallPreamble(cleaned);
|
|
869
|
+
if (preamble && preamble !== cleaned) {
|
|
870
|
+
const reps = [
|
|
871
|
+
preamble,
|
|
872
|
+
repairRawControlChars(preamble),
|
|
873
|
+
normalizePseudoJson(preamble),
|
|
874
|
+
];
|
|
875
|
+
for (const rep of reps) {
|
|
876
|
+
for (const raw of extractJsonObjects(rep)) {
|
|
877
|
+
const a = tryParseArray(raw);
|
|
878
|
+
if (a)
|
|
879
|
+
return a;
|
|
880
|
+
const o = tryParse(raw);
|
|
881
|
+
if (o)
|
|
882
|
+
return o;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const perm = parseToolCallPermissive(preamble);
|
|
886
|
+
if (perm)
|
|
887
|
+
return { ...perm, _permissive: true };
|
|
888
|
+
}
|
|
750
889
|
return null;
|
|
751
890
|
}
|
|
752
891
|
function extractPreToolText(text) {
|
package/dist/browser.js
CHANGED
|
@@ -506,7 +506,7 @@ export class DeepSeekBrowser {
|
|
|
506
506
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
507
507
|
if (gap <= 0)
|
|
508
508
|
return;
|
|
509
|
-
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед
|
|
509
|
+
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
|
|
510
510
|
await this.page.waitForTimeout(gap);
|
|
511
511
|
}
|
|
512
512
|
async _askOnce(prompt, { timeout, agent }) {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Интерактивное меню настроек для команды /config.
|
|
2
|
+
//
|
|
3
|
+
// Меню рисует список параметров, сгруппированных по секциям, и позволяет
|
|
4
|
+
// менять их стрелками: boolean/enum переключаются на месте, number/string
|
|
5
|
+
// запрашивают ввод. Все строки берутся из i18n (cfg.*), поэтому меню
|
|
6
|
+
// полностью локализовано.
|
|
7
|
+
//
|
|
8
|
+
// Модуль не знает про config.ts напрямую (кроме типов схемы): чтение и
|
|
9
|
+
// запись значений делает вызывающий через колбэки. Так меню легко тестировать
|
|
10
|
+
// и переиспользовать.
|
|
11
|
+
import { theme } from './theme.js';
|
|
12
|
+
const ESC = String.fromCharCode(27);
|
|
13
|
+
// Убираем ANSI-последовательности, чтобы посчитать видимую длину строки.
|
|
14
|
+
function stripAnsi(s) {
|
|
15
|
+
return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
16
|
+
}
|
|
17
|
+
function displayValue(field, value, t) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return t('cfg.menu.default');
|
|
20
|
+
if (typeof value === 'boolean')
|
|
21
|
+
return value ? t('common.on') : t('common.off');
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
function groupLabel(field, t) {
|
|
25
|
+
return t(field.groupKey);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Показать интерактивное меню. Возвращает промис, который резолвится, когда
|
|
29
|
+
* пользователь вышел (q/Esc/Ctrl+C). В не-TTY бросает ошибку — вызывающий
|
|
30
|
+
* должен показать текстовый список.
|
|
31
|
+
*/
|
|
32
|
+
export function runConfigMenu(opts) {
|
|
33
|
+
const input = opts.input || process.stdin;
|
|
34
|
+
const output = opts.output || process.stdout;
|
|
35
|
+
const { fields, t, get, set, reset } = opts;
|
|
36
|
+
if (!input.isTTY || !input.setRawMode) {
|
|
37
|
+
return Promise.reject(new Error(t('cfg.menu.notty')));
|
|
38
|
+
}
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
let editing = false;
|
|
41
|
+
let editBuf = '';
|
|
42
|
+
let message = null;
|
|
43
|
+
let exited = false;
|
|
44
|
+
let resolveDone = null;
|
|
45
|
+
const wasRaw = input.isRaw;
|
|
46
|
+
input.setRawMode(true);
|
|
47
|
+
input.resume();
|
|
48
|
+
output.write(ESC + '[?25l'); // прячем курсор на время отрисовки
|
|
49
|
+
// Отрисовка меню: строки идут сверху вниз, курсор возвращаем в начало
|
|
50
|
+
// блока через относительные перемещения, чтобы не плодить пустые строки.
|
|
51
|
+
let lastRows = 0;
|
|
52
|
+
const render = () => {
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(theme.bold(t('cfg.menu.title')));
|
|
55
|
+
let lastGroup = '';
|
|
56
|
+
for (let i = 0; i < fields.length; i++) {
|
|
57
|
+
const f = fields[i];
|
|
58
|
+
const g = groupLabel(f, t);
|
|
59
|
+
if (g !== lastGroup) {
|
|
60
|
+
lines.push(theme.system(' ' + g));
|
|
61
|
+
lastGroup = g;
|
|
62
|
+
}
|
|
63
|
+
const selected = i === cursor;
|
|
64
|
+
const marker = selected ? theme.prompt('\u276f ') : ' ';
|
|
65
|
+
const label = selected ? theme.prompt(f.labelKey ? t(f.labelKey) : f.path) : t(f.labelKey || f.path);
|
|
66
|
+
const val = displayValue(f, get(f.path), t);
|
|
67
|
+
const valText = selected ? theme.assistant(val) : theme.dim(val);
|
|
68
|
+
const pathText = selected ? theme.dim(' ' + f.path) : '';
|
|
69
|
+
lines.push(marker + label + theme.dim(' = ') + valText + pathText);
|
|
70
|
+
}
|
|
71
|
+
if (editing) {
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push(theme.user(t('cfg.menu.edit_hint')));
|
|
74
|
+
lines.push(theme.prompt('\u276f ') + editBuf);
|
|
75
|
+
}
|
|
76
|
+
else if (message) {
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(theme.assistant(message));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(theme.dim(t('cfg.menu.hint')));
|
|
83
|
+
}
|
|
84
|
+
// Стираем прошлый блок и печатаем новый. Считаем ВИЗУАЛЬНЫЕ строки
|
|
85
|
+
// (с учётом переноса по ширине терминала), иначе на узких терминалах
|
|
86
|
+
// остаётся «мусор» из нестёртых хвостов.
|
|
87
|
+
const cols = output.columns || 80;
|
|
88
|
+
const rowsOf = (s) => {
|
|
89
|
+
const len = stripAnsi(s).length;
|
|
90
|
+
return Math.max(1, Math.ceil(len / Math.max(1, cols)));
|
|
91
|
+
};
|
|
92
|
+
if (lastRows > 0) {
|
|
93
|
+
output.write(ESC + '[' + lastRows + 'A');
|
|
94
|
+
for (let i = 0; i < lastRows; i++) {
|
|
95
|
+
output.write(ESC + '[2K' + ESC + '[1B');
|
|
96
|
+
}
|
|
97
|
+
output.write(ESC + '[' + lastRows + 'A');
|
|
98
|
+
}
|
|
99
|
+
output.write(lines.join('\n') + '\n');
|
|
100
|
+
lastRows = lines.reduce((n, l) => n + rowsOf(l), 0);
|
|
101
|
+
};
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (exited)
|
|
104
|
+
return;
|
|
105
|
+
exited = true;
|
|
106
|
+
input.removeListener('data', onData);
|
|
107
|
+
if (input.setRawMode)
|
|
108
|
+
input.setRawMode(wasRaw || false);
|
|
109
|
+
output.write(ESC + '[?25h'); // возвращаем курсор
|
|
110
|
+
if (resolveDone)
|
|
111
|
+
resolveDone();
|
|
112
|
+
};
|
|
113
|
+
const commitEdit = () => {
|
|
114
|
+
const f = fields[cursor];
|
|
115
|
+
const raw = editBuf.trim();
|
|
116
|
+
editing = false;
|
|
117
|
+
editBuf = '';
|
|
118
|
+
if (!raw) {
|
|
119
|
+
message = null;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
set(f, raw);
|
|
124
|
+
message = t('cfg.menu.saved', {
|
|
125
|
+
v: f.path,
|
|
126
|
+
value: displayValue(f, get(f.path), t),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
message = String(e.message || e);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const toggle = () => {
|
|
134
|
+
const f = fields[cursor];
|
|
135
|
+
if (f.type === 'boolean') {
|
|
136
|
+
const cur = get(f.path);
|
|
137
|
+
set(f, cur === true ? 'false' : 'true');
|
|
138
|
+
message = null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (f.type === 'enum' && f.values && f.values.length) {
|
|
142
|
+
const cur = String(get(f.path) ?? '');
|
|
143
|
+
const idx = f.values.indexOf(cur);
|
|
144
|
+
const next = f.values[(idx + 1) % f.values.length];
|
|
145
|
+
set(f, next);
|
|
146
|
+
message = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// number/string — запрашиваем ввод.
|
|
150
|
+
editing = true;
|
|
151
|
+
editBuf = get(f.path) === undefined ? '' : String(get(f.path));
|
|
152
|
+
};
|
|
153
|
+
const onData = (buf) => {
|
|
154
|
+
const s = buf.toString('utf-8');
|
|
155
|
+
if (editing) {
|
|
156
|
+
// Обрабатываем ввод посимвольно: терминал может прислать несколько
|
|
157
|
+
// байт сразу (вставка, авто-повтор), Enter — 0x0d/0x0a, backspace — 0x7f.
|
|
158
|
+
for (const ch of s) {
|
|
159
|
+
if (ch === '\r' || ch === '\n') {
|
|
160
|
+
commitEdit();
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (ch === ESC) {
|
|
164
|
+
editing = false;
|
|
165
|
+
editBuf = '';
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (ch === '\x03') {
|
|
169
|
+
cleanup();
|
|
170
|
+
output.write('\n');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
174
|
+
editBuf = editBuf.slice(0, -1);
|
|
175
|
+
}
|
|
176
|
+
else if (ch >= ' ') {
|
|
177
|
+
editBuf += ch;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Терминал может прислать несколько клавиш одним пакетом (быстрый набор,
|
|
184
|
+
// авто-повтор, автоматизация). Разбираем буфер по токенам: сначала
|
|
185
|
+
// escape-последовательности (стрелки), затем одиночные символы.
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < s.length) {
|
|
188
|
+
if (s.startsWith('\x1b[A', i)) {
|
|
189
|
+
cursor = (cursor - 1 + fields.length) % fields.length;
|
|
190
|
+
message = null;
|
|
191
|
+
i += 3;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (s.startsWith('\x1b[B', i)) {
|
|
195
|
+
cursor = (cursor + 1) % fields.length;
|
|
196
|
+
message = null;
|
|
197
|
+
i += 3;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const ch = s[i];
|
|
201
|
+
if (ch === 'q' || ch === 'Q' || ch === '\x03' || ch === ESC) {
|
|
202
|
+
cleanup();
|
|
203
|
+
output.write('\n');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (ch === 'k') {
|
|
207
|
+
cursor = (cursor - 1 + fields.length) % fields.length;
|
|
208
|
+
message = null;
|
|
209
|
+
}
|
|
210
|
+
else if (ch === 'j') {
|
|
211
|
+
cursor = (cursor + 1) % fields.length;
|
|
212
|
+
message = null;
|
|
213
|
+
}
|
|
214
|
+
else if (ch === '\r' || ch === '\n' || ch === ' ') {
|
|
215
|
+
toggle();
|
|
216
|
+
}
|
|
217
|
+
else if (ch === 'd' || ch === 'D') {
|
|
218
|
+
const f = fields[cursor];
|
|
219
|
+
reset(f);
|
|
220
|
+
message = t('cfg.reset', { v: f.path });
|
|
221
|
+
}
|
|
222
|
+
i++;
|
|
223
|
+
}
|
|
224
|
+
render();
|
|
225
|
+
};
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
resolveDone = resolve;
|
|
228
|
+
input.on('data', onData);
|
|
229
|
+
render();
|
|
230
|
+
});
|
|
231
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { DEFAULT_LOCALE } from './i18n.js';
|
|
4
5
|
const ZAMES_HOME = path.join(os.homedir(), '.zames');
|
|
5
6
|
const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
|
|
6
7
|
const PROJECT_CONFIG = path.join(process.cwd(), '.zamesrc.json');
|
|
@@ -41,6 +42,9 @@ export const DEFAULTS = {
|
|
|
41
42
|
rateLimitWaitMs: 300000,
|
|
42
43
|
maxRateLimitRetries: 6,
|
|
43
44
|
},
|
|
45
|
+
ui: {
|
|
46
|
+
locale: DEFAULT_LOCALE,
|
|
47
|
+
},
|
|
44
48
|
};
|
|
45
49
|
export function loadConfig() {
|
|
46
50
|
const merged = JSON.parse(JSON.stringify(DEFAULTS));
|
|
@@ -71,5 +75,144 @@ function deepMerge(target, source) {
|
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
}
|
|
78
|
+
export const CONFIG_SCHEMA = [
|
|
79
|
+
{ path: 'ui.locale', type: 'enum', values: ['ru', 'en'], labelKey: 'cfg.f.ui_locale', groupKey: 'cfg.group.ui' },
|
|
80
|
+
{ path: 'maxIterations', type: 'number', min: 1, max: 1000, labelKey: 'cfg.f.maxIterations', groupKey: 'cfg.group.agent' },
|
|
81
|
+
{ path: 'headless', type: 'boolean', labelKey: 'cfg.f.headless', groupKey: 'cfg.group.agent' },
|
|
82
|
+
{ path: 'debug', type: 'boolean', labelKey: 'cfg.f.debug', groupKey: 'cfg.group.agent' },
|
|
83
|
+
{ path: 'hotReload', type: 'boolean', labelKey: 'cfg.f.hotReload', groupKey: 'cfg.group.agent' },
|
|
84
|
+
{ path: 'confirmation.write', type: 'boolean', labelKey: 'cfg.f.confirmation_write', groupKey: 'cfg.group.confirmation' },
|
|
85
|
+
{ path: 'confirmation.edit', type: 'boolean', labelKey: 'cfg.f.confirmation_edit', groupKey: 'cfg.group.confirmation' },
|
|
86
|
+
{ path: 'confirmation.bash', type: 'boolean', labelKey: 'cfg.f.confirmation_bash', groupKey: 'cfg.group.confirmation' },
|
|
87
|
+
{ path: 'undo.enabled', type: 'boolean', labelKey: 'cfg.f.undo_enabled', groupKey: 'cfg.group.undo' },
|
|
88
|
+
{ path: 'undo.maxBackups', type: 'number', min: 1, max: 100000, labelKey: 'cfg.f.undo_maxBackups', groupKey: 'cfg.group.undo' },
|
|
89
|
+
{ path: 'transcript.enabled', type: 'boolean', labelKey: 'cfg.f.transcript_enabled', groupKey: 'cfg.group.transcript' },
|
|
90
|
+
{ path: 'browser.answerTimeoutMs', type: 'number', min: 1000, max: 3600000, labelKey: 'cfg.f.browser_answerTimeoutMs', groupKey: 'cfg.group.browser' },
|
|
91
|
+
{ path: 'browser.askRetries', type: 'number', min: 1, max: 100, labelKey: 'cfg.f.browser_askRetries', groupKey: 'cfg.group.browser' },
|
|
92
|
+
{ path: 'browser.stabilityChecks', type: 'number', min: 1, max: 100, labelKey: 'cfg.f.browser_stabilityChecks', groupKey: 'cfg.group.browser' },
|
|
93
|
+
{ path: 'browser.stabilityDelayMs', type: 'number', min: 0, max: 60000, labelKey: 'cfg.f.browser_stabilityDelayMs', groupKey: 'cfg.group.browser' },
|
|
94
|
+
{ path: 'browser.minSendIntervalMs', type: 'number', min: 0, max: 600000, labelKey: 'cfg.f.browser_minSendIntervalMs', groupKey: 'cfg.group.browser' },
|
|
95
|
+
{ path: 'browser.rateLimitWaitMs', type: 'number', min: 0, max: 3600000, labelKey: 'cfg.f.browser_rateLimitWaitMs', groupKey: 'cfg.group.browser' },
|
|
96
|
+
{ path: 'browser.maxRateLimitRetries', type: 'number', min: 0, max: 100, labelKey: 'cfg.f.browser_maxRateLimitRetries', groupKey: 'cfg.group.browser' },
|
|
97
|
+
];
|
|
98
|
+
export function getConfigField(path) {
|
|
99
|
+
return CONFIG_SCHEMA.find((f) => f.path === path);
|
|
100
|
+
}
|
|
101
|
+
export function getByPath(obj, path) {
|
|
102
|
+
const parts = path.split('.');
|
|
103
|
+
let cur = obj;
|
|
104
|
+
for (const p of parts) {
|
|
105
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
cur = cur[p];
|
|
109
|
+
}
|
|
110
|
+
return cur;
|
|
111
|
+
}
|
|
112
|
+
export function setByPath(obj, path, value) {
|
|
113
|
+
const parts = path.split('.');
|
|
114
|
+
let cur = obj;
|
|
115
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
116
|
+
const p = parts[i];
|
|
117
|
+
if (typeof cur[p] !== 'object' || cur[p] === null)
|
|
118
|
+
cur[p] = {};
|
|
119
|
+
cur = cur[p];
|
|
120
|
+
}
|
|
121
|
+
cur[parts[parts.length - 1]] = value;
|
|
122
|
+
}
|
|
123
|
+
function parseConfigValue(field, raw) {
|
|
124
|
+
switch (field.type) {
|
|
125
|
+
case 'boolean': {
|
|
126
|
+
const s = raw.trim().toLowerCase();
|
|
127
|
+
if (['true', '1', 'yes', 'y', 'on', 'да', 'вкл'].includes(s))
|
|
128
|
+
return true;
|
|
129
|
+
if (['false', '0', 'no', 'n', 'off', 'нет', 'выкл'].includes(s))
|
|
130
|
+
return false;
|
|
131
|
+
throw new Error('boolean');
|
|
132
|
+
}
|
|
133
|
+
case 'number': {
|
|
134
|
+
const n = Number(raw);
|
|
135
|
+
if (!Number.isFinite(n))
|
|
136
|
+
throw new Error('number');
|
|
137
|
+
if (field.min !== undefined && n < field.min)
|
|
138
|
+
throw new Error('number');
|
|
139
|
+
if (field.max !== undefined && n > field.max)
|
|
140
|
+
throw new Error('number');
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
case 'enum': {
|
|
144
|
+
const s = raw.trim().toLowerCase();
|
|
145
|
+
if (field.values && !field.values.includes(s))
|
|
146
|
+
throw new Error('enum');
|
|
147
|
+
return s;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return raw;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function validateConfigValue(field, raw) {
|
|
154
|
+
return parseConfigValue(field, raw);
|
|
155
|
+
}
|
|
156
|
+
export function configPathFor(scope) {
|
|
157
|
+
return scope === 'project' ? PROJECT_CONFIG : HOME_CONFIG;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Прочитать только тот файл конфига, что указан (без слияния с дефолтами),
|
|
161
|
+
* чтобы запись не «замораживала» все дефолты в файле пользователя.
|
|
162
|
+
*/
|
|
163
|
+
export function readConfigFile(file) {
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
166
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
167
|
+
return data;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
const err = e;
|
|
172
|
+
if (err.code !== 'ENOENT') {
|
|
173
|
+
throw new Error(`не удалось прочитать ${file}: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Записать значение настройки в файл. Возвращает итоговый объект, который
|
|
180
|
+
* лёг в файл. Значение сперва валидируется по схеме.
|
|
181
|
+
*/
|
|
182
|
+
export function writeConfigValue(scope, key, raw) {
|
|
183
|
+
const field = getConfigField(key);
|
|
184
|
+
if (!field)
|
|
185
|
+
throw new Error(`unknown setting: ${key}`);
|
|
186
|
+
const value = parseConfigValue(field, raw);
|
|
187
|
+
const file = configPathFor(scope);
|
|
188
|
+
const data = readConfigFile(file);
|
|
189
|
+
setByPath(data, key, value);
|
|
190
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
191
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
192
|
+
return { value, file };
|
|
193
|
+
}
|
|
194
|
+
/** Сбросить настройку к дефолту (удалить ключ из файла). */
|
|
195
|
+
export function resetConfigValue(scope, key) {
|
|
196
|
+
const field = getConfigField(key);
|
|
197
|
+
if (!field)
|
|
198
|
+
throw new Error(`unknown setting: ${key}`);
|
|
199
|
+
const file = configPathFor(scope);
|
|
200
|
+
const data = readConfigFile(file);
|
|
201
|
+
const parts = key.split('.');
|
|
202
|
+
let cur = data;
|
|
203
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
204
|
+
const p = parts[i];
|
|
205
|
+
if (!cur || typeof cur[p] !== 'object' || cur[p] === null) {
|
|
206
|
+
cur = undefined;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
cur = cur[p];
|
|
210
|
+
}
|
|
211
|
+
if (cur)
|
|
212
|
+
delete cur[parts[parts.length - 1]];
|
|
213
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
214
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
215
|
+
return file;
|
|
216
|
+
}
|
|
74
217
|
export const CONFIG_PATHS = { HOME_CONFIG, PROJECT_CONFIG };
|
|
75
218
|
export { ZAMES_HOME };
|