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 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
@@ -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 = /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(rawResponse) ||
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 };