zames_pro 1.0.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/src/index.js ADDED
@@ -0,0 +1,792 @@
1
+ #!/usr/bin/env node
2
+ import path from 'path'
3
+ import fs from 'fs/promises'
4
+ import chalk from 'chalk'
5
+ import { fileURLToPath } from 'url'
6
+ import * as readlinePromises from 'readline/promises'
7
+
8
+ import { DeepSeekBrowser } from './browser.js'
9
+ import { createTools } from './tools.js'
10
+ import { runAgentLoop } from './agent-loop.js'
11
+ import { createSpinner } from './spinner.js'
12
+ import { loadConfig, CONFIG_PATHS, ZAMES_HOME } from './config.js'
13
+ import { Transcript } from './transcript.js'
14
+ import { UndoStore } from './undo.js'
15
+ import { selfReview, selfDiff, selfApply, selfList } from './self-review.js'
16
+ import { closeWeb } from './web.js'
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
19
+
20
+ // ---------- CLI parsing ----------
21
+
22
+ const args = process.argv.slice(2)
23
+
24
+ function getArg(flag, fallback = null) {
25
+ const i = args.indexOf(flag)
26
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback
27
+ }
28
+
29
+ function hasFlag(flag) {
30
+ return args.includes(flag)
31
+ }
32
+
33
+ function getPositional() {
34
+ const positional = []
35
+ for (let i = 0; i < args.length; i++) {
36
+ const a = args[i]
37
+ if (['--dir', '--task', '--max-iter', '--project', '--chat'].includes(a)) {
38
+ i++
39
+ continue
40
+ }
41
+ if (a.startsWith('--')) continue
42
+ positional.push(a)
43
+ }
44
+ return positional
45
+ }
46
+
47
+ const config = loadConfig()
48
+
49
+ const headless = hasFlag('--headless') || config.headless
50
+ const debug = hasFlag('--debug') || config.debug
51
+ const calibrate = hasFlag('--calibrate')
52
+ const maxIter =
53
+ Number(getArg('--max-iter', String(config.maxIterations))) ||
54
+ config.maxIterations
55
+
56
+ const positional = getPositional()
57
+ const task = getArg('--task', positional.join(' ').trim() || null)
58
+ const chatIdArg = getArg('--chat', null)
59
+
60
+
61
+ // ---------- helpers ----------
62
+
63
+ function printHelp() {
64
+ console.log(`
65
+ ${chalk.bold('zames')} — агент поверх chat.deepseek.com через Playwright
66
+
67
+ ${chalk.bold('Опции CLI:')}
68
+ --dir <path> рабочая директория агента
69
+ --task <text> задача одной строкой
70
+ --chat <id> продолжить существующий чат по id
71
+ --max-iter <n> лимит итераций (по умолчанию ${config.maxIterations})
72
+ --headless браузер без UI
73
+ --debug подробный лог
74
+ --calibrate режим калибровки селекторов
75
+ --help, -h эта справка
76
+
77
+ ${chalk.bold('Обычные команды:')}
78
+ /new, /clear новый чат (сброс контекста)
79
+ /chats список последних чатов DeepSeek
80
+ /resume <n> открыть чат №n из /chats
81
+ /chat показать текущий chat id
82
+ /cd <path> сменить рабочую директорию
83
+ /pwd текущая директория
84
+ /status состояние сессии
85
+ /undo откатить последнюю запись/правку
86
+ /undo-list список того, что можно откатить
87
+ /transcript путь к файлу транскрипта
88
+ /config показать текущий конфиг
89
+ /debug-dom сохранить HTML страницы (для отладки)
90
+ /help, help справка
91
+ /exit, /quit, exit выход
92
+
93
+ ${chalk.bold('Самообзор (отладка агента):')}
94
+ /self-review [фокус] снять снапшот src/ и запустить ревью
95
+ после этой команды ты остаёшься В СНАПШОТЕ
96
+ и можешь писать «исправь ошибки» и т.п.
97
+ /self-fix <name> [фокус] вернуться в существующий снапшот и продолжить
98
+ /self-done выйти из режима ревью (вернуться в свою папку)
99
+ /self-list список снапшотов
100
+ /self-diff <name> различия между текущим src/ и снапшотом
101
+ /self-apply <name> применить снапшот к src/ (с бэкапом)
102
+
103
+ ${chalk.bold('Файлы:')}
104
+ Логи: ${config.transcript.dir}
105
+ Undo: ~/.zames/undo
106
+ Профиль: ~/.zames/profile
107
+ Снапшоты: ~/.zames/snapshots
108
+ Конфиг: ${CONFIG_PATHS.HOME_CONFIG}
109
+ ${CONFIG_PATHS.PROJECT_CONFIG}
110
+ `)
111
+ }
112
+
113
+ function dirLabel(p) {
114
+ return path.basename(p) || p
115
+ }
116
+
117
+ async function promptOnce(question) {
118
+ process.stdin.resume()
119
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
120
+ process.stdin.setRawMode(false)
121
+ }
122
+ const rl = readlinePromises.createInterface({
123
+ input: process.stdin,
124
+ output: process.stdout,
125
+ })
126
+ try {
127
+ return await rl.question(question)
128
+ } finally {
129
+ rl.close()
130
+ }
131
+ }
132
+
133
+ // ---------- workdir resolution ----------
134
+
135
+ // ---------- workdir resolution ----------
136
+
137
+ // Агент работает в директории, из которой его запустили (process.cwd()).
138
+ // Это корень sandbox: инструменты не могут выходить выше него.
139
+ async function resolveWorkdir() {
140
+ const explicitDir = getArg('--dir', null)
141
+ const dir = explicitDir ? path.resolve(explicitDir) : process.cwd()
142
+ const stat = await fs.stat(dir).catch(() => null)
143
+ if (!stat || !stat.isDirectory()) {
144
+ throw new Error('Не директория: ' + dir)
145
+ }
146
+ return dir
147
+ }
148
+
149
+ // ---------- task runner ----------
150
+
151
+ async function runTask(browser, tools, taskText, workdir, opts) {
152
+ const { transcript, freshChat, sendSystemPrompt } = opts
153
+
154
+ const ui = createSpinner()
155
+ ui.taskHeader(taskText)
156
+ ui.thinking()
157
+
158
+ let finished = false
159
+
160
+ try {
161
+ await runAgentLoop({
162
+ browser,
163
+ tools,
164
+ task: taskText,
165
+ workdir,
166
+ maxIterations: maxIter,
167
+ freshChat,
168
+ sendSystemPrompt,
169
+ transcript,
170
+ onThinking: () => ui.thinking(),
171
+ onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
172
+ onToolResult: (result) => ui.toolResult(result),
173
+ onAssistantMessage: (msg) => {
174
+ ui.assistant(msg)
175
+ finished = true
176
+ },
177
+ })
178
+ } catch (e) {
179
+ ui.stop()
180
+ console.error(chalk.red('\n✖ Ошибка агента:'), e.message)
181
+ if (debug) console.error(e.stack)
182
+ transcript?.log('agent_error', { error: e.message })
183
+ } finally {
184
+ if (!finished) ui.stop()
185
+ }
186
+ }
187
+
188
+ // ---------- main ----------
189
+
190
+ async function main() {
191
+ if (hasFlag('--help') || hasFlag('-h')) {
192
+ printHelp()
193
+ return
194
+ }
195
+
196
+ if (calibrate) {
197
+ console.log(chalk.yellow('\n🔧 Режим калибровки селекторов\n'))
198
+ }
199
+
200
+ let currentWorkdir
201
+ try {
202
+ currentWorkdir = await resolveWorkdir()
203
+ // Корень sandbox: агент не может выходить выше директории запуска.
204
+ const sandboxRoot = currentWorkdir
205
+ } catch (e) {
206
+ console.error(
207
+ chalk.red('Не удалось определить рабочую директорию:'),
208
+ e.message,
209
+ )
210
+ process.exit(1)
211
+ }
212
+
213
+ console.log(chalk.gray(`Рабочая директория: ${currentWorkdir}`))
214
+
215
+ const transcript = new Transcript({
216
+ dir: config.transcript.dir,
217
+ enabled: config.transcript.enabled,
218
+ sessionName: dirLabel(currentWorkdir),
219
+ })
220
+ if (transcript.file) {
221
+ console.log(chalk.gray(`Транскрипт: ${transcript.file}`))
222
+ }
223
+
224
+ const undo = new UndoStore(config.undo)
225
+
226
+ const browser = new DeepSeekBrowser({
227
+ headless,
228
+ debug,
229
+ channel: config.browserChannel,
230
+ ...config.browser,
231
+ })
232
+
233
+ const bootSpinner = createSpinner()
234
+ bootSpinner.thinking()
235
+
236
+ try {
237
+ await browser.launch()
238
+ bootSpinner.stop()
239
+ await browser.waitForLogin()
240
+ } catch (e) {
241
+ bootSpinner.stop()
242
+ console.error(chalk.red('Не удалось запустить браузер:'), e.message)
243
+ if (debug) console.error(e.stack)
244
+ await browser.close().catch(() => {})
245
+ transcript.close()
246
+ process.exit(1)
247
+ }
248
+
249
+ // Разовый режим
250
+ if (task) {
251
+ const tools = createTools(currentWorkdir, { undo })
252
+
253
+ let freshChat = true
254
+ let sendSystemPrompt = true
255
+
256
+ if (chatIdArg) {
257
+ try {
258
+ console.log(chalk.gray(`Открываю чат ${chatIdArg}...`))
259
+ await browser.openChat(chatIdArg)
260
+ freshChat = false
261
+ sendSystemPrompt = true
262
+ } catch (e) {
263
+ console.error(chalk.red(`Не удалось открыть чат: ${e.message}`))
264
+ }
265
+ }
266
+
267
+ await runTask(browser, tools, task, currentWorkdir, {
268
+ transcript,
269
+ freshChat,
270
+ sendSystemPrompt,
271
+ })
272
+ await browser.close()
273
+ await closeWeb().catch(() => {})
274
+ transcript.close()
275
+ return
276
+ }
277
+
278
+ console.log(
279
+ chalk.gray(
280
+ 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.\n',
281
+ ),
282
+ )
283
+
284
+ let freshChatNext = true
285
+ let sendSystemPromptNext = true
286
+ let lastChats = []
287
+ let currentChatId = null
288
+ let running = true
289
+
290
+ // ---------- review mode state ----------
291
+ // null — обычный режим.
292
+ // { snapDir, snapName, originalWorkdir } — мы внутри снапшота, чат уже
293
+ // инициализирован review-промптом, юзер может просто писать «исправь...».
294
+ let reviewMode = null
295
+
296
+ process.on('SIGINT', async () => {
297
+ running = false
298
+ await browser.close().catch(() => {})
299
+ // Закрываем ленивый headless-браузер из web.js, иначе он останется
300
+ // висеть отдельным процессом после выхода агента.
301
+ await closeWeb().catch(() => {})
302
+ transcript.close()
303
+ console.log(chalk.gray('\nВыход.'))
304
+ process.exit(0)
305
+ })
306
+
307
+ if (chatIdArg) {
308
+ try {
309
+ console.log(chalk.gray(`Открываю чат ${chatIdArg}...`))
310
+ await browser.openChat(chatIdArg)
311
+ currentChatId = chatIdArg
312
+ freshChatNext = false
313
+ sendSystemPromptNext = true
314
+ console.log(chalk.gray(`Чат открыт: ${chatIdArg}\n`))
315
+ } catch (e) {
316
+ console.error(chalk.red(`Не удалось открыть чат: ${e.message}`))
317
+ }
318
+ }
319
+
320
+ while (running) {
321
+ let input
322
+ try {
323
+ let label
324
+ if (reviewMode) {
325
+ label = `zames[REVIEW:${reviewMode.snapName}]> `
326
+ } else if (currentChatId) {
327
+ label = `zames[${dirLabel(currentWorkdir)}|${currentChatId.slice(0, 6)}]> `
328
+ } else {
329
+ label = `zames[${dirLabel(currentWorkdir)}]> `
330
+ }
331
+ input = await promptOnce(chalk.cyan(label))
332
+ } catch {
333
+ break
334
+ }
335
+
336
+ const trimmed = (input || '').trim()
337
+ if (!trimmed) continue
338
+
339
+ const lower = trimmed.toLowerCase()
340
+
341
+ if (['/exit', '/quit', 'exit', 'quit'].includes(lower)) break
342
+
343
+ if (lower === '/help' || lower === 'help') {
344
+ printHelp()
345
+ continue
346
+ }
347
+
348
+ if (['/new', '/clear', 'new'].includes(lower)) {
349
+ console.log(chalk.gray('Создаю новый чат...'))
350
+ try {
351
+ await browser.newChat()
352
+ freshChatNext = false
353
+ sendSystemPromptNext = true
354
+ currentChatId = await browser.getCurrentChatId()
355
+ transcript.log('new_chat')
356
+ console.log(chalk.gray('Новый чат.\n'))
357
+ } catch (e) {
358
+ console.error(chalk.red('Не удалось создать новый чат:'), e.message)
359
+ }
360
+ continue
361
+ }
362
+
363
+ // ---------- Самообзор ----------
364
+
365
+ if (lower === '/self-review' || lower.startsWith('/self-review ')) {
366
+ const focus = trimmed.slice('/self-review'.length).trim()
367
+
368
+ // Запоминаем, куда вернуться
369
+ const originalWorkdir = reviewMode
370
+ ? reviewMode.originalWorkdir
371
+ : currentWorkdir
372
+
373
+ try {
374
+ const result = await selfReview({
375
+ browser,
376
+ config,
377
+ focus: focus || null,
378
+ transcript,
379
+ })
380
+
381
+ // Переходим в review-режим:
382
+ // - рабочая директория = снапшот
383
+ // - чат НЕ сбрасываем — внутри selfReview уже создан свежий чат
384
+ // и отправлен review-промпт, продолжим в нём
385
+ reviewMode = {
386
+ snapDir: result.snapDir,
387
+ snapName: path.basename(result.snapDir),
388
+ originalWorkdir,
389
+ }
390
+ currentWorkdir = result.snapDir
391
+ freshChatNext = false
392
+ sendSystemPromptNext = false
393
+ currentChatId = await browser.getCurrentChatId()
394
+
395
+ console.log(
396
+ chalk.cyan(
397
+ '\n💡 Теперь ты в режиме ревью. Просто пиши агенту, например:\n' +
398
+ ' «исправь ошибки»\n' +
399
+ ' «доработай обработку ошибок в ask()»\n' +
400
+ ' «покажи, что не так с undo»\n' +
401
+ 'Выйти: /self-done. Применить: /self-apply ' +
402
+ reviewMode.snapName +
403
+ '\n',
404
+ ),
405
+ )
406
+ } catch (e) {
407
+ console.error(chalk.red('Самообзор провалился:'), e.message)
408
+ if (debug) console.error(e.stack)
409
+ }
410
+ continue
411
+ }
412
+
413
+ if (lower === '/self-fix' || lower.startsWith('/self-fix ')) {
414
+ const rest = trimmed.slice('/self-fix'.length).trim()
415
+ if (!rest) {
416
+ console.error(chalk.red('Использование: /self-fix <name> [фокус]'))
417
+ continue
418
+ }
419
+ const sp = rest.indexOf(' ')
420
+ const name = sp === -1 ? rest : rest.slice(0, sp)
421
+ const focus = sp === -1 ? '' : rest.slice(sp + 1).trim()
422
+
423
+ const snapRoot = path.join(ZAMES_HOME, 'snapshots', name)
424
+ const stat = await fs.stat(snapRoot).catch(() => null)
425
+ if (!stat || !stat.isDirectory()) {
426
+ console.error(chalk.red(`Снапшот не найден: ${snapRoot}`))
427
+ continue
428
+ }
429
+
430
+ const originalWorkdir = reviewMode
431
+ ? reviewMode.originalWorkdir
432
+ : currentWorkdir
433
+
434
+ // Свежий чат + review-промпт на этот снапшот
435
+ try {
436
+ const { runAgentLoop: ral } = await import('./agent-loop.js')
437
+ const { buildSystemPrompt } = await import('./system-prompt.js')
438
+ const tools = createTools(snapRoot, { undo: null })
439
+
440
+ await browser.newChat()
441
+ const sysPrompt = buildSystemPrompt({
442
+ workdir: snapRoot,
443
+ tools,
444
+ })
445
+ console.log(chalk.gray('Инициализирую review-чат для снапшота...'))
446
+ await browser.ask(sysPrompt, { timeout: 60_000 })
447
+
448
+ if (focus) {
449
+ const ui = createSpinner()
450
+ ui.thinking()
451
+ await ral({
452
+ browser,
453
+ tools,
454
+ task: focus,
455
+ workdir: snapRoot,
456
+ maxIterations: maxIter,
457
+ freshChat: false,
458
+ sendSystemPrompt: false,
459
+ transcript,
460
+ onThinking: () => ui.thinking(),
461
+ onAssistantThought: (text) => {
462
+ ui.stop()
463
+ console.log(chalk.gray('\n💭 ' + text.slice(0, 1200) + '\n'))
464
+ },
465
+ onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
466
+ onToolResult: (r) => ui.toolResult(r),
467
+ onAssistantMessage: (m) => {
468
+ ui.assistant(m)
469
+ },
470
+ })
471
+ }
472
+
473
+ reviewMode = {
474
+ snapDir: snapRoot,
475
+ snapName: name,
476
+ originalWorkdir,
477
+ }
478
+ currentWorkdir = snapRoot
479
+ freshChatNext = false
480
+ sendSystemPromptNext = false
481
+ currentChatId = await browser.getCurrentChatId()
482
+
483
+ console.log(
484
+ chalk.cyan(
485
+ `\n💡 Режим ревью по снапшоту ${name}. Пиши агенту задачу или /self-done.\n`,
486
+ ),
487
+ )
488
+ } catch (e) {
489
+ console.error(chalk.red('Не удалось войти в снапшот:'), e.message)
490
+ }
491
+ continue
492
+ }
493
+
494
+ if (lower === '/self-done') {
495
+ if (!reviewMode) {
496
+ console.log(chalk.gray('Ты и так не в режиме ревью.'))
497
+ continue
498
+ }
499
+ const back = reviewMode.originalWorkdir
500
+ reviewMode = null
501
+ currentWorkdir = back
502
+ // Раз чат занят review-контекстом, для обычной работы создадим новый
503
+ freshChatNext = true
504
+ sendSystemPromptNext = true
505
+ console.log(
506
+ chalk.gray(`Вернулся в ${back}. Следующая задача начнёт новый чат.\n`),
507
+ )
508
+ continue
509
+ }
510
+
511
+ if (lower === '/self-list') {
512
+ try {
513
+ await selfList({ config })
514
+ } catch (e) {
515
+ console.error(chalk.red('Ошибка:'), e.message)
516
+ }
517
+ continue
518
+ }
519
+
520
+ if (lower === '/self-diff' || lower.startsWith('/self-diff ')) {
521
+ const name = trimmed.slice('/self-diff'.length).trim()
522
+ if (!name) {
523
+ console.error(chalk.red('Использование: /self-diff <name>'))
524
+ continue
525
+ }
526
+ try {
527
+ await selfDiff({ config, name })
528
+ } catch (e) {
529
+ console.error(chalk.red('Ошибка:'), e.message)
530
+ }
531
+ continue
532
+ }
533
+
534
+ if (lower === '/self-apply' || lower.startsWith('/self-apply ')) {
535
+ const name = trimmed.slice('/self-apply'.length).trim()
536
+ if (!name) {
537
+ console.error(chalk.red('Использование: /self-apply <name>'))
538
+ continue
539
+ }
540
+ try {
541
+ await selfApply({ config, name })
542
+ } catch (e) {
543
+ console.error(chalk.red('Ошибка:'), e.message)
544
+ }
545
+ continue
546
+ }
547
+
548
+ // ---------- Обычные команды ----------
549
+
550
+ if (lower === '/chats') {
551
+ const spin = createSpinner()
552
+ spin.thinking()
553
+ try {
554
+ lastChats = await browser.listChats(30)
555
+ spin.stop()
556
+ if (!lastChats.length) {
557
+ console.log(
558
+ chalk.gray(
559
+ 'Чатов не найдено. Возможно, сайдбар свёрнут или селекторы устарели.',
560
+ ),
561
+ )
562
+ } else {
563
+ console.log(chalk.gray('Последние чаты DeepSeek:'))
564
+ lastChats.forEach((c, i) => {
565
+ const n = String(i + 1).padStart(2, ' ')
566
+ console.log(
567
+ ` ${chalk.cyan(n)}. ${c.title} ${chalk.gray('(' + c.id.slice(0, 8) + '…)')}`,
568
+ )
569
+ })
570
+ console.log(chalk.gray('\nИспользуй /resume <n> для продолжения.\n'))
571
+ }
572
+ } catch (e) {
573
+ spin.stop()
574
+ console.error(chalk.red('Не удалось получить список:'), e.message)
575
+ }
576
+ continue
577
+ }
578
+
579
+ if (lower === '/resume' || lower.startsWith('/resume ')) {
580
+ const arg = trimmed.slice(7).trim()
581
+ if (!arg) {
582
+ console.error(
583
+ chalk.red('Использование: /resume <n> (или /chats для списка)'),
584
+ )
585
+ continue
586
+ }
587
+ const n = Number(arg)
588
+ if (!Number.isFinite(n) || n < 1) {
589
+ console.error(chalk.red('Нужен номер из /chats.'))
590
+ continue
591
+ }
592
+ if (!lastChats.length) {
593
+ console.error(chalk.red('Сначала выполни /chats.'))
594
+ continue
595
+ }
596
+ const pick = lastChats[n - 1]
597
+ if (!pick) {
598
+ console.error(chalk.red(`Нет чата №${n}. Всего: ${lastChats.length}.`))
599
+ continue
600
+ }
601
+
602
+ console.log(chalk.gray(`Открываю: ${pick.title}`))
603
+ try {
604
+ await browser.openChat(pick.id)
605
+ currentChatId = pick.id
606
+ freshChatNext = false
607
+ sendSystemPromptNext = true
608
+ transcript.log('resume_chat', { id: pick.id, title: pick.title })
609
+ console.log(
610
+ chalk.green(`Чат открыт.`) +
611
+ chalk.gray(
612
+ ' Системный промпт будет переслан на следующей задаче.\n',
613
+ ),
614
+ )
615
+ } catch (e) {
616
+ console.error(chalk.red('Не удалось открыть чат:'), e.message)
617
+ }
618
+ continue
619
+ }
620
+
621
+ if (lower === '/chat') {
622
+ if (currentChatId) {
623
+ console.log(chalk.gray(`Текущий chat id: ${currentChatId}`))
624
+ console.log(
625
+ chalk.gray(
626
+ `URL: https://chat.deepseek.com/a/chat/s/${currentChatId}`,
627
+ ),
628
+ )
629
+ } else {
630
+ const id = await browser.getCurrentChatId()
631
+ console.log(
632
+ chalk.gray(id ? `Текущий chat id: ${id}` : 'Чат ещё не создан.'),
633
+ )
634
+ }
635
+ continue
636
+ }
637
+
638
+ if (lower === '/pwd') {
639
+ console.log(chalk.gray(currentWorkdir))
640
+ continue
641
+ }
642
+
643
+ if (lower === '/status') {
644
+ console.log(chalk.gray(`Рабочая директория: ${currentWorkdir}`))
645
+ console.log(
646
+ chalk.gray(`Режим ревью: ${reviewMode ? reviewMode.snapName : 'нет'}`),
647
+ )
648
+ if (reviewMode) {
649
+ console.log(
650
+ chalk.gray(`Исходная директория: ${reviewMode.originalWorkdir}`),
651
+ )
652
+ }
653
+ console.log(chalk.gray(`Текущий чат: ${currentChatId || '(нет)'}`))
654
+ console.log(
655
+ chalk.gray(
656
+ `Fresh chat на след. задаче: ${freshChatNext ? 'да' : 'нет'}`,
657
+ ),
658
+ )
659
+ console.log(
660
+ chalk.gray(
661
+ `System prompt на след. задаче: ${sendSystemPromptNext ? 'да' : 'нет'}`,
662
+ ),
663
+ )
664
+ console.log(chalk.gray(`Лимит итераций: ${maxIter}`))
665
+ console.log(chalk.gray(`Headless: ${headless ? 'да' : 'нет'}`))
666
+ console.log(chalk.gray(`Debug: ${debug ? 'да' : 'нет'}`))
667
+ console.log(chalk.gray(`Undo: ${config.undo.enabled ? 'вкл' : 'выкл'}`))
668
+ console.log(chalk.gray(`Транскрипт: ${transcript.file || 'выкл'}`))
669
+ continue
670
+ }
671
+
672
+ if (lower === '/config') {
673
+ console.log(JSON.stringify(config, null, 2))
674
+ continue
675
+ }
676
+
677
+ if (lower === '/transcript') {
678
+ console.log(chalk.gray(transcript.file || '(выключен)'))
679
+ continue
680
+ }
681
+
682
+ if (lower === '/undo') {
683
+ const result = await undo.undoLast()
684
+ if (result.ok) {
685
+ console.log(
686
+ chalk.green(`↶ Откатили: ${result.record.originalPath}`) +
687
+ chalk.gray(
688
+ result.record.existed ? ' (восстановлено)' : ' (удалено)',
689
+ ),
690
+ )
691
+ transcript.log('undo', { path: result.record.originalPath })
692
+ } else {
693
+ console.error(chalk.red(`Не удалось откатить: ${result.reason}`))
694
+ }
695
+ continue
696
+ }
697
+
698
+ if (lower === '/undo-list' || lower === '/history') {
699
+ const list = await undo.list(10)
700
+ if (!list.length) {
701
+ console.log(chalk.gray('История пуста.'))
702
+ } else {
703
+ for (const r of list) {
704
+ const stamp = new Date(r.stamp).toLocaleString()
705
+ const flag = r.existed ? 'изменён' : 'создан'
706
+ console.log(chalk.gray(`${stamp} [${flag}] ${r.originalPath}`))
707
+ }
708
+ }
709
+ continue
710
+ }
711
+
712
+ if (lower === '/debug-dom') {
713
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
714
+ const file = path.join(config.transcript.dir, `dom-${stamp}.html`)
715
+ try {
716
+ const result = await browser.dumpDom(file)
717
+ console.log(chalk.green(`HTML сохранён: ${result.file}`))
718
+ console.log(chalk.gray('Селекторы:'))
719
+ console.log(JSON.stringify(result.selectors, null, 2))
720
+ } catch (e) {
721
+ console.error(chalk.red('Не удалось сохранить DOM:'), e.message)
722
+ }
723
+ continue
724
+ }
725
+
726
+ if (lower === '/cd' || lower.startsWith('/cd ')) {
727
+ const rawTarget = trimmed.slice(3).trim()
728
+ try {
729
+ const newDir = rawTarget
730
+ ? path.resolve(currentWorkdir, rawTarget)
731
+ : sandboxRoot
732
+ const rel = path.relative(sandboxRoot, newDir)
733
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
734
+ console.error(chalk.red('Нельзя выйти за пределы: ' + sandboxRoot))
735
+ continue
736
+ }
737
+ const stat = await fs.stat(newDir).catch(() => null)
738
+ if (!stat || !stat.isDirectory()) {
739
+ console.error(chalk.red('Не директория: ' + newDir))
740
+ continue
741
+ }
742
+ if (newDir === currentWorkdir) {
743
+ console.log(chalk.gray('Уже здесь.'))
744
+ continue
745
+ }
746
+ if (reviewMode) {
747
+ console.log(chalk.gray('Вышел из режима ревью (/cd).'))
748
+ reviewMode = null
749
+ }
750
+ currentWorkdir = newDir
751
+ freshChatNext = true
752
+ sendSystemPromptNext = true
753
+ console.log(chalk.gray('Рабочая директория: ' + newDir))
754
+ } catch (e) {
755
+ console.error(chalk.red('Не удалось перейти: ' + e.message))
756
+ }
757
+ continue
758
+ }
759
+
760
+ if (lower.startsWith('/')) {
761
+ console.error(chalk.red(`Неизвестная команда: ${trimmed}. Набери /help.`))
762
+ continue
763
+ }
764
+
765
+ // ---- Обычная задача (в том числе в review-режиме) ----
766
+
767
+ transcript.log('user_task', { task: trimmed, workdir: currentWorkdir })
768
+
769
+ const tools = createTools(currentWorkdir, { undo })
770
+ await runTask(browser, tools, trimmed, currentWorkdir, {
771
+ transcript,
772
+ freshChat: freshChatNext,
773
+ sendSystemPrompt: sendSystemPromptNext,
774
+ })
775
+
776
+ freshChatNext = false
777
+ sendSystemPromptNext = false
778
+ if (!currentChatId) {
779
+ currentChatId = await browser.getCurrentChatId()
780
+ }
781
+ }
782
+
783
+ await browser.close().catch(() => {})
784
+ await closeWeb().catch(() => {})
785
+ transcript.close()
786
+ }
787
+
788
+ main().catch((e) => {
789
+ console.error(chalk.red('Критическая ошибка:'), e.message)
790
+ if (debug) console.error(e.stack)
791
+ process.exit(1)
792
+ })