zames_pro 1.1.1 → 1.2.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 +5 -0
- package/package.json +1 -1
- package/src/agent-loop.js +28 -0
- package/src/browser.js +22 -0
- package/src/config.js +1 -0
- package/src/index.js +300 -60
- package/src/input.js +447 -0
- package/src/spinner.js +60 -2
- package/src/system-prompt.js +39 -5
- package/src/theme.js +1 -0
- package/src/xml-toolcall.js +69 -0
package/README.md
CHANGED
|
@@ -29,6 +29,11 @@ zames
|
|
|
29
29
|
|
|
30
30
|
The agent works inside the directory it was started in and cannot leave it (sandbox).
|
|
31
31
|
|
|
32
|
+
While the agent is working you can keep typing: press Enter to queue a message
|
|
33
|
+
(it is sent right after the current task, in the same chat), or Esc / Ctrl+C to
|
|
34
|
+
abort the current generation. This mirrors typing during generation on the
|
|
35
|
+
DeepSeek website.
|
|
36
|
+
|
|
32
37
|
### Options
|
|
33
38
|
|
|
34
39
|
```
|
package/package.json
CHANGED
package/src/agent-loop.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildSystemPrompt } from './system-prompt.js'
|
|
2
2
|
import { getGitContext, formatGitContext } from './gitTools.js'
|
|
3
|
+
import { parseXmlToolCalls } from './xml-toolcall.js'
|
|
3
4
|
|
|
4
5
|
export async function runAgentLoop({
|
|
5
6
|
browser,
|
|
@@ -47,6 +48,12 @@ export async function runAgentLoop({
|
|
|
47
48
|
let message = task
|
|
48
49
|
transcript?.log('task', { task })
|
|
49
50
|
|
|
51
|
+
// Счётчик «ответ похож на tool-call, но не распознан». Чтобы модель,
|
|
52
|
+
// написавшая битый JSON/XML, не останавливала агента молча, мы просим
|
|
53
|
+
// её переотправить вызов. Ограничиваем число таких попыток.
|
|
54
|
+
let malformedRetries = 0
|
|
55
|
+
const MAX_MALFORMED_RETRIES = 3
|
|
56
|
+
|
|
50
57
|
for (let i = 0; i < maxIterations; i++) {
|
|
51
58
|
onThinking()
|
|
52
59
|
const rawResponse = await browser.ask(message)
|
|
@@ -69,6 +76,23 @@ export async function runAgentLoop({
|
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
if (!parsed) {
|
|
79
|
+
// Ответ не распознан как tool-call. Если он ПОХОЖ на попытку вызова
|
|
80
|
+
// (есть tool/invoke/parameter, но JSON/XML битый) — это почти всегда
|
|
81
|
+
// ошибка формата. Не считаем её финальным ответом (иначе агент молча
|
|
82
|
+
// остановится), а просим модель переотправить вызов корректно.
|
|
83
|
+
const looksLikeToolCall = /("tool"\s*:|\binvoke\b|\bparameter\b)/i.test(rawResponse)
|
|
84
|
+
if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
85
|
+
malformedRetries++
|
|
86
|
+
transcript?.log("malformed_toolcall", { attempt: malformedRetries, response: rawResponse })
|
|
87
|
+
if (debugLog) {
|
|
88
|
+
console.error("внимание: ответ похож на tool-call, но не распознан (попытка " + malformedRetries + "/" + MAX_MALFORMED_RETRIES + ")")
|
|
89
|
+
}
|
|
90
|
+
message = "Твой предыдущий ответ не распознан как вызов инструмента. " +
|
|
91
|
+
"Ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после. " +
|
|
92
|
+
"Например: " + String.fromCharCode(39) + "tool" + String.fromCharCode(39) + ": ..., args: {...}"
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
onAssistantMessage(rawResponse)
|
|
73
97
|
transcript?.log('assistant_final', { message: rawResponse })
|
|
74
98
|
return rawResponse
|
|
@@ -531,6 +555,10 @@ function parseToolCall(text) {
|
|
|
531
555
|
if (tailPermissive) return { ...tailPermissive, _permissive: true }
|
|
532
556
|
}
|
|
533
557
|
|
|
558
|
+
// Последний fallback: модель ответила XML/DSML-блоком вместо JSON.
|
|
559
|
+
const xmlCalls = parseXmlToolCalls(cleaned)
|
|
560
|
+
if (xmlCalls) return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls]
|
|
561
|
+
|
|
534
562
|
return null
|
|
535
563
|
}
|
|
536
564
|
|
package/src/browser.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path'
|
|
|
3
3
|
import os from 'os'
|
|
4
4
|
import fs from 'fs/promises'
|
|
5
5
|
import { execSync } from 'child_process'
|
|
6
|
+
import { theme } from './theme.js'
|
|
6
7
|
|
|
7
8
|
const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile')
|
|
8
9
|
const CHAT_URL = 'https://chat.deepseek.com/'
|
|
@@ -78,6 +79,7 @@ export class DeepSeekBrowser {
|
|
|
78
79
|
askRetries = 3,
|
|
79
80
|
stabilityChecks = 3,
|
|
80
81
|
stabilityDelayMs = 1000,
|
|
82
|
+
minSendIntervalMs = 25000,
|
|
81
83
|
} = {}) {
|
|
82
84
|
this.headless = headless
|
|
83
85
|
this.debug = debug
|
|
@@ -86,6 +88,10 @@ export class DeepSeekBrowser {
|
|
|
86
88
|
this.askRetries = askRetries
|
|
87
89
|
this.stabilityChecks = stabilityChecks
|
|
88
90
|
this.stabilityDelayMs = stabilityDelayMs
|
|
91
|
+
// Минимальный интервал между отправками в чат: DeepSeek ограничивает
|
|
92
|
+
// частоту («Messages too frequent. Try again later.»).
|
|
93
|
+
this.minSendIntervalMs = minSendIntervalMs
|
|
94
|
+
this._lastSentAt = 0
|
|
89
95
|
this.context = null
|
|
90
96
|
this.page = null
|
|
91
97
|
}
|
|
@@ -418,6 +424,20 @@ export class DeepSeekBrowser {
|
|
|
418
424
|
}
|
|
419
425
|
}
|
|
420
426
|
|
|
427
|
+
// Ждём, пока с прошлой отправки пройдёт minSendIntervalMs. Защита от
|
|
428
|
+
// «Messages too frequent. Try again later.» на [chat.deepseek.com](https://chat.deepseek.com/).
|
|
429
|
+
async _waitForSendSlot() {
|
|
430
|
+
if (!this._lastSentAt) return // первая отправка — пауза не нужна
|
|
431
|
+
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt)
|
|
432
|
+
if (gap <= 0) return
|
|
433
|
+
console.error(
|
|
434
|
+
theme.warn(
|
|
435
|
+
`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой (лимит частоты DeepSeek)...`,
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
await this.page.waitForTimeout(gap)
|
|
439
|
+
}
|
|
440
|
+
|
|
421
441
|
async _askOnce(prompt, { timeout }) {
|
|
422
442
|
const input = await this._findVisible(INPUT_SELECTORS, 10_000)
|
|
423
443
|
if (!input) {
|
|
@@ -428,6 +448,7 @@ export class DeepSeekBrowser {
|
|
|
428
448
|
|
|
429
449
|
const beforeText = await this._readLastAnswerTextClean().catch(() => '')
|
|
430
450
|
|
|
451
|
+
await this._waitForSendSlot()
|
|
431
452
|
await this._setInputText(input, prompt)
|
|
432
453
|
await this.page.waitForTimeout(200)
|
|
433
454
|
|
|
@@ -440,6 +461,7 @@ export class DeepSeekBrowser {
|
|
|
440
461
|
} catch {
|
|
441
462
|
await this.page.keyboard.press('Enter')
|
|
442
463
|
}
|
|
464
|
+
this._lastSentAt = Date.now()
|
|
443
465
|
|
|
444
466
|
const startDeadline = Date.now() + 15_000
|
|
445
467
|
let started = false
|
package/src/config.js
CHANGED
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { DeepSeekBrowser } from './browser.js'
|
|
|
8
8
|
import { createTools } from './tools.js'
|
|
9
9
|
import { runAgentLoop } from './agent-loop.js'
|
|
10
10
|
import { createSpinner } from './spinner.js'
|
|
11
|
+
import { LineEditor } from './input.js'
|
|
11
12
|
import { loadConfig, CONFIG_PATHS, ZAMES_HOME } from './config.js'
|
|
12
13
|
import { Transcript } from './transcript.js'
|
|
13
14
|
import { UndoStore } from './undo.js'
|
|
@@ -200,6 +201,10 @@ ${theme.bold('Опции CLI:')}
|
|
|
200
201
|
--version, -v показать версию
|
|
201
202
|
--help, -h эта справка
|
|
202
203
|
|
|
204
|
+
${theme.bold('Пока агент работает:')}
|
|
205
|
+
печать + Enter поставить сообщение в очередь (уйдёт после текущей задачи)
|
|
206
|
+
Esc, Ctrl+C прервать текущую генерацию
|
|
207
|
+
|
|
203
208
|
${theme.bold('Обычные команды:')}
|
|
204
209
|
/new, /clear новый чат (сброс контекста)
|
|
205
210
|
/chats список последних чатов DeepSeek
|
|
@@ -464,29 +469,143 @@ async function promptOnce(question) {
|
|
|
464
469
|
}
|
|
465
470
|
|
|
466
471
|
// Слежение за клавиатурой во время работы агента.
|
|
467
|
-
//
|
|
468
|
-
|
|
472
|
+
//
|
|
473
|
+
// Терминал остаётся живым, пока агент думает:
|
|
474
|
+
// - Esc (или Ctrl+C) — прервать текущую генерацию (клик Stop в браузере);
|
|
475
|
+
// - набор текста + Enter — поставить сообщение в очередь; оно уйдёт
|
|
476
|
+
// агенту сразу после того, как текущая задача завершится
|
|
477
|
+
// (как «отправить во время генерации» в веб-версии DeepSeek).
|
|
478
|
+
//
|
|
479
|
+
// Ввод буферизуется без построчного редактора: набранный текст отображается
|
|
480
|
+
// в строке спиннера через onChange -> ui.setPending(). Enter отправляет буфер
|
|
481
|
+
// в onQueue, пустой Enter игнорируется. Поддержаны Backspace, Ctrl+U, Esc-
|
|
482
|
+
// последовательности (стрелки/Home/End/Delete игнорируются) и bracketed paste.
|
|
483
|
+
function watchInput({ onEscape, onChange, onQueue } = {}) {
|
|
469
484
|
const stdin = process.stdin
|
|
470
|
-
if (!stdin.isTTY) return () => {}
|
|
485
|
+
if (!stdin.isTTY || !stdin.setRawMode) return () => {}
|
|
486
|
+
|
|
487
|
+
// Управляющие символы собираем из кодов: в этом файле нельзя писать
|
|
488
|
+
// «сырые» ESC/CR/LF в строковых литералах (см. AGENTS.md).
|
|
489
|
+
const ESC = String.fromCharCode(27)
|
|
490
|
+
const CSI = String.fromCharCode(91)
|
|
491
|
+
const CR = String.fromCharCode(13)
|
|
492
|
+
const LF = String.fromCharCode(10)
|
|
493
|
+
|
|
471
494
|
const wasRaw = stdin.isRaw
|
|
472
|
-
|
|
495
|
+
stdin.setRawMode(true)
|
|
473
496
|
stdin.resume()
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
497
|
+
process.stdout.write(ESC + '[?2004h')
|
|
498
|
+
|
|
499
|
+
let buf = ''
|
|
500
|
+
let inPaste = false
|
|
501
|
+
const PASTE_START = ESC + '[200~'
|
|
502
|
+
const PASTE_END = ESC + '[201~'
|
|
503
|
+
const CSI_RE = new RegExp('^' + CSI + '[0-9;]*[A-Za-z~]')
|
|
504
|
+
|
|
505
|
+
const emitChange = () => {
|
|
506
|
+
if (onChange) onChange(buf)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Вставленный текст: переводы строк внутри многострочной вставки
|
|
510
|
+
// трактуем как пробелы — сообщение уходит одной строкой.
|
|
511
|
+
const insert = (text) => {
|
|
512
|
+
buf += text
|
|
513
|
+
.split(CR + LF).join(' ')
|
|
514
|
+
.split(CR).join(' ')
|
|
515
|
+
.split(LF).join(' ')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function onData(data) {
|
|
519
|
+
let s = data.toString('utf-8')
|
|
520
|
+
|
|
521
|
+
// Одиночный Esc — прервать генерацию. Стрелки приходят целым чанком
|
|
522
|
+
// и сюда не попадают.
|
|
523
|
+
if (!inPaste && s === ESC) {
|
|
524
|
+
if (onEscape) onEscape()
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
while (s.length) {
|
|
529
|
+
if (inPaste) {
|
|
530
|
+
const end = s.indexOf(PASTE_END)
|
|
531
|
+
if (end === -1) {
|
|
532
|
+
insert(s)
|
|
533
|
+
s = ''
|
|
534
|
+
} else {
|
|
535
|
+
insert(s.slice(0, end))
|
|
536
|
+
s = s.slice(end + PASTE_END.length)
|
|
537
|
+
inPaste = false
|
|
538
|
+
}
|
|
539
|
+
emitChange()
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const start = s.indexOf(PASTE_START)
|
|
544
|
+
if (start !== -1) {
|
|
545
|
+
const before = s.slice(0, start)
|
|
546
|
+
s = s.slice(start + PASTE_START.length)
|
|
547
|
+
inPaste = true
|
|
548
|
+
if (before) {
|
|
549
|
+
insert(before)
|
|
550
|
+
emitChange()
|
|
551
|
+
}
|
|
552
|
+
continue
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const ch = s[0]
|
|
556
|
+
const code = s.charCodeAt(0)
|
|
557
|
+
s = s.slice(1)
|
|
558
|
+
|
|
559
|
+
if (ch === CR || ch === LF) {
|
|
560
|
+
const text = buf.trim()
|
|
561
|
+
buf = ''
|
|
562
|
+
emitChange()
|
|
563
|
+
if (text && onQueue) onQueue(text)
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
if (code === 3) {
|
|
567
|
+
// Ctrl+C — как Esc: прервать генерацию.
|
|
568
|
+
if (onEscape) onEscape()
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
if (code === 21) {
|
|
572
|
+
// Ctrl+U — очистить набранное.
|
|
573
|
+
buf = ''
|
|
574
|
+
emitChange()
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
if (code === 127 || code === 8) {
|
|
578
|
+
// Backspace.
|
|
579
|
+
if (buf) {
|
|
580
|
+
buf = buf.slice(0, -1)
|
|
581
|
+
emitChange()
|
|
582
|
+
}
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
if (ch === ESC) {
|
|
586
|
+
// Escape-последовательность (стрелки, Home/End, Delete) — пропускаем.
|
|
587
|
+
const m = s.match(CSI_RE)
|
|
588
|
+
if (m) s = s.slice(m[0].length)
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if (code < 32) continue // прочие управляющие — игнорируем
|
|
592
|
+
|
|
593
|
+
buf += ch
|
|
594
|
+
emitChange()
|
|
477
595
|
}
|
|
478
596
|
}
|
|
597
|
+
|
|
598
|
+
const handler = (data) => onData(data)
|
|
479
599
|
stdin.on('data', handler)
|
|
480
600
|
return () => {
|
|
481
601
|
stdin.removeListener('data', handler)
|
|
602
|
+
process.stdout.write(ESC + '[?2004l')
|
|
482
603
|
if (stdin.setRawMode) stdin.setRawMode(wasRaw || false)
|
|
483
604
|
}
|
|
484
605
|
}
|
|
485
606
|
|
|
486
607
|
// ---------- workdir resolution ----------
|
|
487
608
|
|
|
488
|
-
// ---------- workdir resolution ----------
|
|
489
|
-
|
|
490
609
|
// Агент работает в директории, из которой его запустили (process.cwd()).
|
|
491
610
|
// Это корень sandbox: инструменты не могут выходить выше него.
|
|
492
611
|
async function resolveWorkdir() {
|
|
@@ -502,47 +621,75 @@ async function resolveWorkdir() {
|
|
|
502
621
|
// ---------- task runner ----------
|
|
503
622
|
|
|
504
623
|
async function runTask(browser, tools, taskText, workdir, opts) {
|
|
505
|
-
const { transcript, freshChat, sendSystemPrompt } = opts
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const stopWatching =
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
624
|
+
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor } = opts
|
|
625
|
+
|
|
626
|
+
// В TTY-режиме UI — это LineEditor: он владеет вводом (очередь, Esc,
|
|
627
|
+
// Ctrl+C) и рисует статус НАД постоянной строкой ввода. В не-TTY режиме
|
|
628
|
+
// (пайпы) — обычный спиннер + watchInput.
|
|
629
|
+
const ui = editor || mod.createSpinner()
|
|
630
|
+
const stopWatching = editor
|
|
631
|
+
? () => {}
|
|
632
|
+
: watchInput({
|
|
633
|
+
onEscape: () => {
|
|
634
|
+
ui.stop()
|
|
635
|
+
console.error(theme.warn('⏹ Esc — прерываю генерацию...'))
|
|
636
|
+
browser.stopGeneration().catch(() => {})
|
|
637
|
+
},
|
|
638
|
+
onChange: (text) => ui.setPending(text),
|
|
639
|
+
onQueue: (text) => {
|
|
640
|
+
queue.push(text)
|
|
641
|
+
ui.setPending(null)
|
|
642
|
+
ui.stop()
|
|
643
|
+
console.log(
|
|
644
|
+
theme.user('📨 В очередь (' + queue.length + '): ') + theme.assistant(text),
|
|
645
|
+
)
|
|
646
|
+
ui.thinking()
|
|
647
|
+
},
|
|
648
|
+
})
|
|
518
649
|
|
|
519
650
|
try {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
651
|
+
let next = { task: taskText, freshChat, sendSystemPrompt }
|
|
652
|
+
|
|
653
|
+
// Выполняем задачу, затем — всё, что пользователь успел напечатать за
|
|
654
|
+
// время работы. Очередь может пополняться прямо во время дренажа.
|
|
655
|
+
while (true) {
|
|
656
|
+
ui.thinking()
|
|
657
|
+
await mod.runAgentLoop({
|
|
658
|
+
browser,
|
|
659
|
+
tools,
|
|
660
|
+
task: next.task,
|
|
661
|
+
workdir,
|
|
662
|
+
maxIterations: maxIter,
|
|
663
|
+
freshChat: next.freshChat,
|
|
664
|
+
sendSystemPrompt: next.sendSystemPrompt,
|
|
665
|
+
transcript,
|
|
666
|
+
onThinking: () => ui.thinking(),
|
|
667
|
+
onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
|
|
668
|
+
onToolResult: (result) => ui.toolResult(result),
|
|
669
|
+
onAssistantMessage: (msg) => ui.assistant(msg),
|
|
670
|
+
debugLog: debug,
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
if (!queue.length) break
|
|
674
|
+
|
|
675
|
+
const queued = queue.shift()
|
|
676
|
+
ui.stop()
|
|
677
|
+
if (editor) {
|
|
678
|
+
editor.printAbove(theme.user('▶ Из очереди: ') + theme.assistant(queued))
|
|
679
|
+
} else {
|
|
680
|
+
console.log(theme.user('▶ Из очереди: ') + theme.assistant(queued))
|
|
681
|
+
}
|
|
682
|
+
transcript?.log('queued_task', { task: queued })
|
|
683
|
+
next = { task: queued, freshChat: false, sendSystemPrompt: false }
|
|
684
|
+
}
|
|
538
685
|
} catch (e) {
|
|
539
686
|
ui.stop()
|
|
540
|
-
console.error(theme.error('
|
|
687
|
+
console.error(theme.error(String.fromCharCode(10) + '✖ Ошибка агента:'), e.message)
|
|
541
688
|
if (debug) console.error(e.stack)
|
|
542
689
|
transcript?.log('agent_error', { error: e.message })
|
|
543
690
|
} finally {
|
|
544
691
|
stopWatching()
|
|
545
|
-
|
|
692
|
+
ui.stop()
|
|
546
693
|
}
|
|
547
694
|
}
|
|
548
695
|
|
|
@@ -659,7 +806,12 @@ async function main() {
|
|
|
659
806
|
|
|
660
807
|
console.log(
|
|
661
808
|
theme.system(
|
|
662
|
-
'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit
|
|
809
|
+
'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
|
|
810
|
+
),
|
|
811
|
+
)
|
|
812
|
+
console.log(
|
|
813
|
+
theme.system(
|
|
814
|
+
'Пока агент работает, можно печатать следующее сообщение — оно уйдёт в очередь (Enter — отправить, Esc — прервать).',
|
|
663
815
|
),
|
|
664
816
|
)
|
|
665
817
|
|
|
@@ -669,6 +821,10 @@ async function main() {
|
|
|
669
821
|
let currentChatId = null
|
|
670
822
|
let running = true
|
|
671
823
|
|
|
824
|
+
// Сообщения, набранные пользователем, пока агент работал. runTask
|
|
825
|
+
// забирает их по одному после завершения текущей задачи.
|
|
826
|
+
const pendingQueue = []
|
|
827
|
+
|
|
672
828
|
// ---------- review mode state ----------
|
|
673
829
|
// null — обычный режим.
|
|
674
830
|
// { snapDir, snapName, originalWorkdir } — мы внутри снапшота, чат уже
|
|
@@ -709,18 +865,98 @@ async function main() {
|
|
|
709
865
|
}
|
|
710
866
|
}
|
|
711
867
|
|
|
868
|
+
// ---------- ввод: постоянная строка внизу + статус сверху ----------
|
|
869
|
+
// В TTY используем LineEditor: он владеет вводом всё время, показывает
|
|
870
|
+
// статус над строкой ввода и печатает ответы агента ВЫШЕ неё, поэтому
|
|
871
|
+
// набранный текст никогда не затирается выводом. В не-TTY (пайп) —
|
|
872
|
+
// старый promptOnce.
|
|
873
|
+
let editor = null
|
|
874
|
+
let waiter = null
|
|
875
|
+
const takeInput = () => {
|
|
876
|
+
if (pendingQueue.length) return Promise.resolve(pendingQueue.shift())
|
|
877
|
+
return new Promise((resolve) => {
|
|
878
|
+
waiter = resolve
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const buildPrompt = () => {
|
|
883
|
+
let tail
|
|
884
|
+
if (reviewMode) {
|
|
885
|
+
tail = theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
|
|
886
|
+
} else {
|
|
887
|
+
tail = theme.dir(dirLabel(currentWorkdir))
|
|
888
|
+
}
|
|
889
|
+
return theme.prompt('❯ ') + tail + theme.dim(' › ')
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
893
|
+
editor = new LineEditor({ prompt: buildPrompt() })
|
|
894
|
+
editor.onSubmit = (text) => {
|
|
895
|
+
pendingQueue.push(text)
|
|
896
|
+
if (waiter) {
|
|
897
|
+
const r = waiter
|
|
898
|
+
waiter = null
|
|
899
|
+
r(pendingQueue.shift())
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
editor.onEscape = () => {
|
|
903
|
+
if (editor.busy) {
|
|
904
|
+
editor.printAbove(theme.warn('⏹ Esc — прерываю генерацию...'))
|
|
905
|
+
browser.stopGeneration().catch(() => {})
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
editor.onCtrlC = () => {
|
|
909
|
+
if (editor.busy) {
|
|
910
|
+
editor.printAbove(theme.warn('⏹ Ctrl+C — прерываю генерацию...'))
|
|
911
|
+
browser.stopGeneration().catch(() => {})
|
|
912
|
+
} else {
|
|
913
|
+
// Не заняты — выходим. Будим takeInput(), чтобы цикл завершился.
|
|
914
|
+
running = false
|
|
915
|
+
if (waiter) {
|
|
916
|
+
const r = waiter
|
|
917
|
+
waiter = null
|
|
918
|
+
r(null)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
editor.start()
|
|
923
|
+
|
|
924
|
+
// Весь вывод команд (console.log/error) должен идти ВЫШЕ строки ввода,
|
|
925
|
+
// иначе он затирает набираемый текст. Пока редактор активен, заворачиваем
|
|
926
|
+
// оба потока в editor.printAbove.
|
|
927
|
+
const origLog = console.log.bind(console)
|
|
928
|
+
const origErr = console.error.bind(console)
|
|
929
|
+
const fmt = (a) =>
|
|
930
|
+
typeof a === 'string' ? a : (() => {
|
|
931
|
+
try {
|
|
932
|
+
return JSON.stringify(a)
|
|
933
|
+
} catch {
|
|
934
|
+
return String(a)
|
|
935
|
+
}
|
|
936
|
+
})()
|
|
937
|
+
console.log = (...a) => editor.printAbove(a.map(fmt).join(' '))
|
|
938
|
+
console.error = (...a) => editor.printAbove(a.map(fmt).join(' '))
|
|
939
|
+
// Сохраняем на случай отладки.
|
|
940
|
+
editor._origLog = origLog
|
|
941
|
+
editor._origErr = origErr
|
|
942
|
+
}
|
|
943
|
+
|
|
712
944
|
while (running) {
|
|
713
945
|
let input
|
|
714
946
|
try {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
tail =
|
|
719
|
-
theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
|
|
947
|
+
if (editor) {
|
|
948
|
+
editor.setPrompt(buildPrompt())
|
|
949
|
+
input = await takeInput()
|
|
720
950
|
} else {
|
|
721
|
-
tail
|
|
951
|
+
let tail
|
|
952
|
+
if (reviewMode) {
|
|
953
|
+
tail =
|
|
954
|
+
theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
|
|
955
|
+
} else {
|
|
956
|
+
tail = theme.dir(dirLabel(currentWorkdir))
|
|
957
|
+
}
|
|
958
|
+
input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
|
|
722
959
|
}
|
|
723
|
-
input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
|
|
724
960
|
} catch {
|
|
725
961
|
break
|
|
726
962
|
}
|
|
@@ -852,10 +1088,6 @@ async function main() {
|
|
|
852
1088
|
sendSystemPrompt: false,
|
|
853
1089
|
transcript,
|
|
854
1090
|
onThinking: () => ui.thinking(),
|
|
855
|
-
onAssistantThought: (text) => {
|
|
856
|
-
ui.stop()
|
|
857
|
-
console.log(theme.system('\n💭 ' + text.slice(0, 1200) + '\n'))
|
|
858
|
-
},
|
|
859
1091
|
onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
|
|
860
1092
|
onToolResult: (r) => ui.toolResult(r),
|
|
861
1093
|
onAssistantMessage: (m) => {
|
|
@@ -943,7 +1175,7 @@ async function main() {
|
|
|
943
1175
|
// ---------- Обычные команды ----------
|
|
944
1176
|
|
|
945
1177
|
if (lower === '/chats') {
|
|
946
|
-
const spin = mod.createSpinner()
|
|
1178
|
+
const spin = editor || mod.createSpinner()
|
|
947
1179
|
spin.thinking()
|
|
948
1180
|
try {
|
|
949
1181
|
lastChats = await browser.listChats(30)
|
|
@@ -1195,11 +1427,18 @@ async function main() {
|
|
|
1195
1427
|
transcript.log('user_task', { task: trimmed, workdir: currentWorkdir })
|
|
1196
1428
|
|
|
1197
1429
|
const tools = mod.createTools(currentWorkdir, { undo })
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1430
|
+
if (editor) editor.busy = true
|
|
1431
|
+
try {
|
|
1432
|
+
await runTask(browser, tools, trimmed, currentWorkdir, {
|
|
1433
|
+
transcript,
|
|
1434
|
+
freshChat: freshChatNext,
|
|
1435
|
+
sendSystemPrompt: sendSystemPromptNext,
|
|
1436
|
+
queue: pendingQueue,
|
|
1437
|
+
ui: editor || null,
|
|
1438
|
+
})
|
|
1439
|
+
} finally {
|
|
1440
|
+
if (editor) editor.busy = false
|
|
1441
|
+
}
|
|
1203
1442
|
|
|
1204
1443
|
freshChatNext = false
|
|
1205
1444
|
sendSystemPromptNext = false
|
|
@@ -1209,6 +1448,7 @@ async function main() {
|
|
|
1209
1448
|
await saveLastChat(currentChatId)
|
|
1210
1449
|
}
|
|
1211
1450
|
|
|
1451
|
+
if (editor) editor.dispose()
|
|
1212
1452
|
await browser.close().catch(() => {})
|
|
1213
1453
|
await mod.closeWeb().catch(() => {})
|
|
1214
1454
|
transcript.close()
|
package/src/input.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { theme } from './theme.js'
|
|
2
|
+
import { renderMarkdown } from './markdown.js'
|
|
3
|
+
import { randomThinkingPhrase, stripEllipsis } from './spinner.js'
|
|
4
|
+
|
|
5
|
+
// Постоянная строка ввода внизу терминала + область статуса/вывода над ней.
|
|
6
|
+
//
|
|
7
|
+
// Зачем свой редактор:
|
|
8
|
+
// * строка ввода видна ВСЕГДА и не затирается выводом агента;
|
|
9
|
+
// * спиннер/статус и ответы печатаются ВЫШЕ строки ввода (printAbove);
|
|
10
|
+
// * перерисовка учитывает перенос по ширине терминала, поэтому нет
|
|
11
|
+
// дублирования строк при многострочном вводе;
|
|
12
|
+
// * многострочный ввод: Enter отправляет, Ctrl+J (и Shift+Enter в
|
|
13
|
+
// терминалах с расширенным протоколом) вставляют перевод строки.
|
|
14
|
+
//
|
|
15
|
+
// Все управляющие символы собираются из кодов, чтобы в файле не было
|
|
16
|
+
// «сырых» ESC/CR/LF в строковых литералах (см. AGENTS.md).
|
|
17
|
+
|
|
18
|
+
const NL = String.fromCharCode(10)
|
|
19
|
+
const CR = String.fromCharCode(13)
|
|
20
|
+
const ESC = String.fromCharCode(27)
|
|
21
|
+
const PASTE_START = ESC + '[200~'
|
|
22
|
+
const PASTE_END = ESC + '[201~'
|
|
23
|
+
|
|
24
|
+
const DOTS = ['.', '..', '...']
|
|
25
|
+
const HINT = theme.dim(' · Enter — отправить, Ctrl+J — новая строка, Esc — стоп')
|
|
26
|
+
|
|
27
|
+
function safeJson(v) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(v)
|
|
30
|
+
} catch {
|
|
31
|
+
return String(v)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Видимая длина строки без ANSI-последовательностей.
|
|
36
|
+
export function visLen(s) {
|
|
37
|
+
s = String(s)
|
|
38
|
+
let n = 0
|
|
39
|
+
let i = 0
|
|
40
|
+
while (i < s.length) {
|
|
41
|
+
if (s.charCodeAt(i) === 27) {
|
|
42
|
+
i++
|
|
43
|
+
if (s[i] === '[') {
|
|
44
|
+
i++
|
|
45
|
+
while (i < s.length && !/[A-Za-z]/.test(s[i])) i++
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
n++
|
|
51
|
+
i++
|
|
52
|
+
}
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Раскладка ввода на визуальные строки с учётом ширины терминала.
|
|
57
|
+
// Возвращает строки (с префиксом) и позицию курсора в визуальных координатах.
|
|
58
|
+
export function layoutInput(promptStr, buf, cursor, cols) {
|
|
59
|
+
const width = Math.max(20, Number(cols) || 80)
|
|
60
|
+
const promptW = visLen(promptStr)
|
|
61
|
+
const pad = ' '.repeat(promptW)
|
|
62
|
+
const chars = Array.from(String(buf))
|
|
63
|
+
const cur = Math.max(0, Math.min(Number(cursor) || 0, chars.length))
|
|
64
|
+
const rows = []
|
|
65
|
+
let i = 0
|
|
66
|
+
while (true) {
|
|
67
|
+
const first = rows.length === 0
|
|
68
|
+
const prefix = first ? promptStr : pad
|
|
69
|
+
const avail = Math.max(1, width - promptW)
|
|
70
|
+
let text = ''
|
|
71
|
+
let count = 0
|
|
72
|
+
const start = i
|
|
73
|
+
while (i < chars.length && chars[i] !== NL && count < avail) {
|
|
74
|
+
text += chars[i]
|
|
75
|
+
i++
|
|
76
|
+
count++
|
|
77
|
+
}
|
|
78
|
+
if (i < chars.length && chars[i] === NL) {
|
|
79
|
+
i++
|
|
80
|
+
rows.push({ prefix, text, start })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
rows.push({ prefix, text, start })
|
|
84
|
+
if (i >= chars.length) break
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cursorRow = 0
|
|
88
|
+
let cursorCol = promptW
|
|
89
|
+
for (let r = 0; r < rows.length; r++) {
|
|
90
|
+
const row = rows[r]
|
|
91
|
+
const len = Array.from(row.text).length
|
|
92
|
+
const from = row.start
|
|
93
|
+
const to = row.start + len
|
|
94
|
+
if (cur >= from && cur <= to) {
|
|
95
|
+
const next = rows[r + 1]
|
|
96
|
+
if (cur === to && next && next.start === to) continue
|
|
97
|
+
cursorRow = r
|
|
98
|
+
cursorCol = promptW + (cur - from)
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { rows, cursorRow, cursorCol }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class LineEditor {
|
|
106
|
+
constructor({ prompt = '> ' } = {}) {
|
|
107
|
+
this.promptStr = prompt
|
|
108
|
+
this.buf = ''
|
|
109
|
+
this.cursor = 0
|
|
110
|
+
this.statusText = ''
|
|
111
|
+
this.pendingText = null
|
|
112
|
+
this.rendered = false
|
|
113
|
+
this.cursorRowFromTop = 0
|
|
114
|
+
this.busy = false
|
|
115
|
+
this.onSubmit = null
|
|
116
|
+
this.onEscape = null
|
|
117
|
+
this.onCtrlC = null
|
|
118
|
+
this._inPaste = false
|
|
119
|
+
this._dotTimer = null
|
|
120
|
+
this._dotPhase = 0
|
|
121
|
+
this._thinkBase = ''
|
|
122
|
+
this._wasRaw = false
|
|
123
|
+
this._onData = (b) => this._handle(b)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
start() {
|
|
127
|
+
const stdin = process.stdin
|
|
128
|
+
if (!stdin.isTTY || !stdin.setRawMode) return false
|
|
129
|
+
this._wasRaw = stdin.isRaw
|
|
130
|
+
stdin.setRawMode(true)
|
|
131
|
+
stdin.resume()
|
|
132
|
+
process.stdout.write(ESC + '[?2004h')
|
|
133
|
+
stdin.on('data', this._onData)
|
|
134
|
+
this._writeBlock()
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
dispose() {
|
|
139
|
+
const stdin = process.stdin
|
|
140
|
+
stdin.removeListener('data', this._onData)
|
|
141
|
+
process.stdout.write(ESC + '[?2004l')
|
|
142
|
+
this._stopDots()
|
|
143
|
+
if (this.rendered) this._eraseBlock()
|
|
144
|
+
if (stdin.setRawMode) stdin.setRawMode(this._wasRaw || false)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setPrompt(str) {
|
|
148
|
+
this.promptStr = str
|
|
149
|
+
this._render()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
clear() {
|
|
153
|
+
this.buf = ''
|
|
154
|
+
this.cursor = 0
|
|
155
|
+
this._render()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- отрисовка ----------
|
|
159
|
+
|
|
160
|
+
_eraseBlock() {
|
|
161
|
+
if (!this.rendered) return
|
|
162
|
+
if (this.cursorRowFromTop > 0) {
|
|
163
|
+
process.stdout.write(ESC + '[' + this.cursorRowFromTop + 'A')
|
|
164
|
+
}
|
|
165
|
+
process.stdout.write(CR + ESC + '[J')
|
|
166
|
+
this.rendered = false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_writeBlock() {
|
|
170
|
+
const cols = process.stdout.columns || 80
|
|
171
|
+
let out = ''
|
|
172
|
+
let top = 0
|
|
173
|
+
if (this.statusText) {
|
|
174
|
+
out += this.statusText + NL
|
|
175
|
+
top = 1
|
|
176
|
+
}
|
|
177
|
+
const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols)
|
|
178
|
+
out += lay.rows.map((r) => r.prefix + r.text).join(NL)
|
|
179
|
+
process.stdout.write(out)
|
|
180
|
+
const lastRow = lay.rows.length - 1
|
|
181
|
+
const up = lastRow - lay.cursorRow
|
|
182
|
+
if (up > 0) process.stdout.write(ESC + '[' + up + 'A')
|
|
183
|
+
process.stdout.write(CR)
|
|
184
|
+
if (lay.cursorCol > 0) process.stdout.write(ESC + '[' + lay.cursorCol + 'C')
|
|
185
|
+
this.rendered = true
|
|
186
|
+
this.cursorRowFromTop = top + lay.cursorRow
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_render() {
|
|
190
|
+
this._eraseBlock()
|
|
191
|
+
this._writeBlock()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Напечатать блок ВЫШЕ строки ввода и вернуть строку ввода на место.
|
|
195
|
+
printAbove(text) {
|
|
196
|
+
this._eraseBlock()
|
|
197
|
+
process.stdout.write(String(text) + NL)
|
|
198
|
+
this._writeBlock()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------- интерфейс, совместимый со спиннером ----------
|
|
202
|
+
|
|
203
|
+
setStatus(text) {
|
|
204
|
+
this.statusText = text || ''
|
|
205
|
+
this._render()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_stopDots() {
|
|
209
|
+
if (this._dotTimer) {
|
|
210
|
+
clearInterval(this._dotTimer)
|
|
211
|
+
this._dotTimer = null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_startThinking() {
|
|
216
|
+
if (this.pendingText) {
|
|
217
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
this._thinkBase = theme.brown(stripEllipsis(randomThinkingPhrase()))
|
|
221
|
+
this._dotPhase = 0
|
|
222
|
+
this.setStatus(this._thinkBase + DOTS[0] + HINT)
|
|
223
|
+
this._stopDots()
|
|
224
|
+
this._dotTimer = setInterval(() => {
|
|
225
|
+
this._dotPhase = (this._dotPhase + 1) % DOTS.length
|
|
226
|
+
this.setStatus(this._thinkBase + DOTS[this._dotPhase] + HINT)
|
|
227
|
+
}, 400)
|
|
228
|
+
if (this._dotTimer.unref) this._dotTimer.unref()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
thinking() {
|
|
232
|
+
this._startThinking()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setPending(text) {
|
|
236
|
+
this.pendingText = text && String(text).length ? String(text) : null
|
|
237
|
+
if (this.pendingText) {
|
|
238
|
+
this._stopDots()
|
|
239
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT)
|
|
240
|
+
} else {
|
|
241
|
+
this._startThinking()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stop() {
|
|
246
|
+
this._stopDots()
|
|
247
|
+
this.setStatus('')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
toolCall(name, args) {
|
|
251
|
+
const preview = safeJson(args).slice(0, 120)
|
|
252
|
+
this.printAbove(theme.tool('🔧 ' + name) + ' ' + theme.dim(preview))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
toolResult(result) {
|
|
256
|
+
const text = typeof result === 'string' ? result : safeJson(result)
|
|
257
|
+
const preview = text.slice(0, 200).split(NL).join(' ↵ ')
|
|
258
|
+
this.printAbove(theme.toolResult(' → ' + preview))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
assistant(msg) {
|
|
262
|
+
this.stop()
|
|
263
|
+
const rendered = renderMarkdown(msg)
|
|
264
|
+
this.printAbove(
|
|
265
|
+
NL + theme.assistant('● Ответ') + NL + rendered + NL + theme.dim('─'.repeat(60)),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- ввод ----------
|
|
270
|
+
|
|
271
|
+
_submit() {
|
|
272
|
+
const text = this.buf
|
|
273
|
+
if (!text.trim()) {
|
|
274
|
+
this._render()
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
this.buf = ''
|
|
278
|
+
this.cursor = 0
|
|
279
|
+
this.pendingText = null
|
|
280
|
+
this._stopDots()
|
|
281
|
+
this.statusText = ''
|
|
282
|
+
this.printAbove(theme.user('❯ ') + text)
|
|
283
|
+
if (this.onSubmit) this.onSubmit(text)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_insert(text) {
|
|
287
|
+
const chars = Array.from(this.buf)
|
|
288
|
+
const ins = Array.from(String(text))
|
|
289
|
+
const next = chars.slice(0, this.cursor).concat(ins, chars.slice(this.cursor))
|
|
290
|
+
this.buf = next.join('')
|
|
291
|
+
this.cursor += ins.length
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_backspace() {
|
|
295
|
+
if (this.cursor <= 0) return
|
|
296
|
+
const chars = Array.from(this.buf)
|
|
297
|
+
chars.splice(this.cursor - 1, 1)
|
|
298
|
+
this.buf = chars.join('')
|
|
299
|
+
this.cursor--
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_delete() {
|
|
303
|
+
const chars = Array.from(this.buf)
|
|
304
|
+
if (this.cursor >= chars.length) return
|
|
305
|
+
chars.splice(this.cursor, 1)
|
|
306
|
+
this.buf = chars.join('')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_left() {
|
|
310
|
+
if (this.cursor > 0) this.cursor--
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_right() {
|
|
314
|
+
if (this.cursor < Array.from(this.buf).length) this.cursor++
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_home() {
|
|
318
|
+
const chars = Array.from(this.buf)
|
|
319
|
+
let i = this.cursor
|
|
320
|
+
while (i > 0 && chars[i - 1] !== NL) i--
|
|
321
|
+
this.cursor = i
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_end() {
|
|
325
|
+
const chars = Array.from(this.buf)
|
|
326
|
+
let i = this.cursor
|
|
327
|
+
while (i < chars.length && chars[i] !== NL) i++
|
|
328
|
+
this.cursor = i
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
_up() {
|
|
332
|
+
const chars = Array.from(this.buf)
|
|
333
|
+
let start = this.cursor
|
|
334
|
+
while (start > 0 && chars[start - 1] !== NL) start--
|
|
335
|
+
if (start === 0) return
|
|
336
|
+
const col = this.cursor - start
|
|
337
|
+
const prevEnd = start - 1
|
|
338
|
+
let prevStart = prevEnd
|
|
339
|
+
while (prevStart > 0 && chars[prevStart - 1] !== NL) prevStart--
|
|
340
|
+
this.cursor = prevStart + Math.min(col, prevEnd - prevStart)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_down() {
|
|
344
|
+
const chars = Array.from(this.buf)
|
|
345
|
+
let end = this.cursor
|
|
346
|
+
while (end < chars.length && chars[end] !== NL) end++
|
|
347
|
+
if (end >= chars.length) return
|
|
348
|
+
let lineStart = this.cursor
|
|
349
|
+
while (lineStart > 0 && chars[lineStart - 1] !== NL) lineStart--
|
|
350
|
+
const col = this.cursor - lineStart
|
|
351
|
+
const nextStart = end + 1
|
|
352
|
+
let nextEnd = nextStart
|
|
353
|
+
while (nextEnd < chars.length && chars[nextEnd] !== NL) nextEnd++
|
|
354
|
+
this.cursor = nextStart + Math.min(col, nextEnd - nextStart)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_handle(data) {
|
|
358
|
+
let s = data.toString('utf-8')
|
|
359
|
+
|
|
360
|
+
if (!this._inPaste && s === ESC) {
|
|
361
|
+
if (this.onEscape) this.onEscape()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
while (s.length) {
|
|
366
|
+
if (this._inPaste) {
|
|
367
|
+
const end = s.indexOf(PASTE_END)
|
|
368
|
+
if (end === -1) {
|
|
369
|
+
this._insert(s)
|
|
370
|
+
s = ''
|
|
371
|
+
} else {
|
|
372
|
+
this._insert(s.slice(0, end))
|
|
373
|
+
s = s.slice(end + PASTE_END.length)
|
|
374
|
+
this._inPaste = false
|
|
375
|
+
}
|
|
376
|
+
this._render()
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const pasteAt = s.indexOf(PASTE_START)
|
|
381
|
+
if (pasteAt !== -1) {
|
|
382
|
+
const before = s.slice(0, pasteAt)
|
|
383
|
+
s = s.slice(pasteAt + PASTE_START.length)
|
|
384
|
+
this._inPaste = true
|
|
385
|
+
if (before) {
|
|
386
|
+
this._insert(before)
|
|
387
|
+
this._render()
|
|
388
|
+
}
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Shift+Enter в терминалах с расширенным протоколом и Alt+Enter.
|
|
393
|
+
if (s.startsWith(ESC + '[13;2u')) { this._insert(NL); s = s.slice(7); this._render(); continue }
|
|
394
|
+
if (s.startsWith(ESC + '[27;2;13~')) { this._insert(NL); s = s.slice(10); this._render(); continue }
|
|
395
|
+
if (s.startsWith(ESC + NL)) { this._insert(NL); s = s.slice(2); this._render(); continue }
|
|
396
|
+
|
|
397
|
+
const ch = s[0]
|
|
398
|
+
const code = s.charCodeAt(0)
|
|
399
|
+
s = s.slice(1)
|
|
400
|
+
|
|
401
|
+
if (ch === CR) { this._submit(); continue }
|
|
402
|
+
if (code === 10) { this._insert(NL); this._render(); continue }
|
|
403
|
+
if (code === 3) { if (this.onCtrlC) this.onCtrlC(); continue }
|
|
404
|
+
if (code === 4) { if (!this.buf && this.onCtrlC) this.onCtrlC(); continue }
|
|
405
|
+
if (code === 1) { this._home(); this._render(); continue }
|
|
406
|
+
if (code === 5) { this._end(); this._render(); continue }
|
|
407
|
+
if (code === 21) { this.buf = ''; this.cursor = 0; this._render(); continue }
|
|
408
|
+
if (code === 127 || code === 8) { this._backspace(); this._render(); continue }
|
|
409
|
+
|
|
410
|
+
if (code === 27) {
|
|
411
|
+
if (s.startsWith('[D')) { this._left(); s = s.slice(2); this._render(); continue }
|
|
412
|
+
if (s.startsWith('[C')) { this._right(); s = s.slice(2); this._render(); continue }
|
|
413
|
+
if (s.startsWith('[A')) { this._up(); s = s.slice(2); this._render(); continue }
|
|
414
|
+
if (s.startsWith('[B')) { this._down(); s = s.slice(2); this._render(); continue }
|
|
415
|
+
if (s.startsWith('[H') || s.startsWith('[1~')) { this._home(); s = s.slice(s.startsWith('[1~') ? 3 : 2); this._render(); continue }
|
|
416
|
+
if (s.startsWith('[F') || s.startsWith('[4~')) { this._end(); s = s.slice(s.startsWith('[4~') ? 3 : 2); this._render(); continue }
|
|
417
|
+
if (s.startsWith('[3~')) { this._delete(); s = s.slice(3); this._render(); continue }
|
|
418
|
+
let j = 0
|
|
419
|
+
while (j < s.length && !/[A-Za-z~]/.test(s[j])) j++
|
|
420
|
+
s = s.slice(j + 1)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (code < 32) continue
|
|
425
|
+
this._insert(ch)
|
|
426
|
+
this._render()
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Проверка раскладки: node src/input.js --selftest
|
|
432
|
+
export function selftest() {
|
|
433
|
+
const cases = [
|
|
434
|
+
{ p: '> ', b: 'hello', c: 5, w: 80 },
|
|
435
|
+
{ p: '> ', b: 'a' + NL + 'b', c: 3, w: 80 },
|
|
436
|
+
{ p: '> ', b: 'x'.repeat(200), c: 200, w: 40 },
|
|
437
|
+
{ p: '> ', b: '', c: 0, w: 40 },
|
|
438
|
+
]
|
|
439
|
+
for (const t of cases) {
|
|
440
|
+
const r = layoutInput(t.p, t.b, t.c, t.w)
|
|
441
|
+
console.log(JSON.stringify({ rows: r.rows.length, row: r.cursorRow, col: r.cursorCol }))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (process.argv[1] && process.argv[1].endsWith('input.js') && process.argv.includes('--selftest')) {
|
|
446
|
+
selftest()
|
|
447
|
+
}
|
package/src/spinner.js
CHANGED
|
@@ -36,12 +36,22 @@ const THINKING_PHRASES = [
|
|
|
36
36
|
'Замешиваю кал из всего подряд…',
|
|
37
37
|
]
|
|
38
38
|
|
|
39
|
-
function randomThinkingPhrase() {
|
|
39
|
+
export function randomThinkingPhrase() {
|
|
40
40
|
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)]
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Убираем завершающее многоточие из фразы — точки анимируем отдельно.
|
|
44
|
+
export function stripEllipsis(phrase) {
|
|
45
|
+
return phrase.replace(/[.…]+\s*$/, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
export function createSpinner() {
|
|
44
49
|
let spinner = null
|
|
50
|
+
let dotTimer = null
|
|
51
|
+
let dotPhase = 0
|
|
52
|
+
let pending = null
|
|
53
|
+
|
|
54
|
+
const DOTS = ['.', '..', '...']
|
|
45
55
|
|
|
46
56
|
const start = (text) => {
|
|
47
57
|
if (!spinner) spinner = ora(text).start()
|
|
@@ -49,11 +59,59 @@ export function createSpinner() {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
const stop = () => {
|
|
62
|
+
if (dotTimer) {
|
|
63
|
+
clearInterval(dotTimer)
|
|
64
|
+
dotTimer = null
|
|
65
|
+
}
|
|
52
66
|
if (spinner) spinner.stop()
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
// Подсказка в строке статуса: во время работы агента терминал живой,
|
|
70
|
+
// можно печатать следующее сообщение. Без неё это неочевидно.
|
|
71
|
+
const HINT = theme.dim(' · печатай — Enter в очередь, Esc стоп')
|
|
72
|
+
|
|
73
|
+
// Запуск анимированного статуса: коричневый текст + «бегущие» точки.
|
|
74
|
+
const startThinking = () => {
|
|
75
|
+
const base = theme.brown(stripEllipsis(randomThinkingPhrase()))
|
|
76
|
+
start(base + DOTS[0] + HINT)
|
|
77
|
+
dotPhase = 0
|
|
78
|
+
if (dotTimer) clearInterval(dotTimer)
|
|
79
|
+
dotTimer = setInterval(() => {
|
|
80
|
+
if (!spinner) return
|
|
81
|
+
dotPhase = (dotPhase + 1) % DOTS.length
|
|
82
|
+
spinner.text = base + DOTS[dotPhase] + HINT
|
|
83
|
+
}, 400)
|
|
84
|
+
if (dotTimer.unref) dotTimer.unref()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Показать набранный, но ещё не отправленный текст вместо спиннера.
|
|
88
|
+
// Позволяет печатать сообщение прямо во время работы агента.
|
|
89
|
+
const showPending = () => {
|
|
90
|
+
if (dotTimer) {
|
|
91
|
+
clearInterval(dotTimer)
|
|
92
|
+
dotTimer = null
|
|
93
|
+
}
|
|
94
|
+
if (!spinner) spinner = ora('')
|
|
95
|
+
spinner.start()
|
|
96
|
+
spinner.text = theme.prompt('✎ ') + pending + HINT
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
return {
|
|
56
|
-
thinking: () =>
|
|
100
|
+
thinking: () => {
|
|
101
|
+
if (pending) {
|
|
102
|
+
showPending()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
startThinking()
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Текст, который пользователь набирает во время работы агента.
|
|
109
|
+
// Пустая строка / null — вернуть обычный спиннер.
|
|
110
|
+
setPending: (text) => {
|
|
111
|
+
pending = text && String(text).length ? String(text) : null
|
|
112
|
+
if (pending) showPending()
|
|
113
|
+
else startThinking()
|
|
114
|
+
},
|
|
57
115
|
|
|
58
116
|
toolCall: (name, args) => {
|
|
59
117
|
stop()
|
package/src/system-prompt.js
CHANGED
|
@@ -14,6 +14,34 @@ export function buildSystemPrompt({ workdir, tools, gitContext = null }) {
|
|
|
14
14
|
|
|
15
15
|
You work in the directory: ${workdir}
|
|
16
16
|
|
|
17
|
+
## SILENT OPERATION (most important rule)
|
|
18
|
+
|
|
19
|
+
Work silently. While you are still solving the task you must output ONLY
|
|
20
|
+
tool calls — nothing else. Do NOT narrate what you are about to do, do NOT
|
|
21
|
+
summarize what you just did, do NOT think out loud, do NOT print progress
|
|
22
|
+
like "Now I will read the file...". Every extra sentence pollutes the chat
|
|
23
|
+
and the context, so keep it to zero.
|
|
24
|
+
|
|
25
|
+
The operator does NOT read free text in the chat. Anything written outside a
|
|
26
|
+
tool call is lost: the operator only sees tool calls and their results. In
|
|
27
|
+
particular, the operator reads EXACTLY ONE thing from you — the message of the
|
|
28
|
+
LAST respond tool call before you stop. Everything else is ignored.
|
|
29
|
+
|
|
30
|
+
Therefore the only way to talk to the operator is the respond tool, and it
|
|
31
|
+
must be your LAST action. Call respond in exactly two situations:
|
|
32
|
+
1. The task is done — report the result.
|
|
33
|
+
2. You cannot continue and must ask the operator a question or a decision.
|
|
34
|
+
|
|
35
|
+
Never call respond in the middle, and never split a report across several
|
|
36
|
+
messages: put the complete operator-facing text into that single final
|
|
37
|
+
respond. Do NOT end a turn with plain text, and do NOT rely on plain text to
|
|
38
|
+
ask or answer anything — if you have something to say, say it via respond and
|
|
39
|
+
stop. If you are still working, emit a tool call instead.
|
|
40
|
+
|
|
41
|
+
So the pattern is: tool call, tool call, tool call, ..., then a single final
|
|
42
|
+
respond. A bare text message without a tool call ends the task and the
|
|
43
|
+
operator will not read it, so never use plain text.\n\nNO PROSE AROUND TOOL CALLS. Each turn must contain ONLY the JSON of the tool\ncall(s) — not a single word before or after, not even a short lead-in like\n"Let me check..." or "Now I'll fix it.". The JSON must be the entire response.\n\nWRONG: "Let me read the file first." then a Read call.\nWRONG: a Read call then "I'll analyze the result next."\nRIGHT: only the JSON of the tool call, nothing else.\n\nIf you feel the urge to explain, do not: put it in the final respond when the\ntask is done (or when you must ask the operator), not between tool calls.
|
|
44
|
+
|
|
17
45
|
You have access to the following tools:
|
|
18
46
|
|
|
19
47
|
${toolDescriptions}
|
|
@@ -28,7 +56,9 @@ To call MULTIPLE tools at once (only if they are independent — e.g. reading se
|
|
|
28
56
|
|
|
29
57
|
[{"tool": "Read", "args": {"path": "a.js"}}, {"tool": "Read", "args": {"path": "b.js"}}]
|
|
30
58
|
|
|
31
|
-
After the tool result(s) come back, decide the next action
|
|
59
|
+
After the tool result(s) come back, decide the next action and immediately
|
|
60
|
+
emit the next tool call as bare JSON. Do NOT write a sentence about what
|
|
61
|
+
you learned or what you will do next — no commentary between tool calls.
|
|
32
62
|
|
|
33
63
|
To give a final answer to the user, respond with:
|
|
34
64
|
|
|
@@ -68,9 +98,9 @@ Inside that string:
|
|
|
68
98
|
- Do NOT write any text before or after the JSON. No explanations, no greetings, no plans.
|
|
69
99
|
- Do NOT wrap JSON in markdown fences.
|
|
70
100
|
- Do NOT use XML-like tags such as tool_calls, invoke, parameter, or their DSML variants.
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
for newlines.
|
|
101
|
+
- The ONLY way to reach the operator is the respond tool, as your LAST action.
|
|
102
|
+
Any other text is not read by the operator.
|
|
103
|
+
- If JSON parsing fails, the tool call will NOT run and the user will see raw text. Keep JSON valid: escape every double quote inside strings, escape backslashes, use \n for newlines.
|
|
74
104
|
|
|
75
105
|
## Rules
|
|
76
106
|
|
|
@@ -82,7 +112,11 @@ Inside that string:
|
|
|
82
112
|
- Never output anything except JSON when you want to call a tool.
|
|
83
113
|
- Your ENTIRE response must be exactly one JSON object (or array of objects).
|
|
84
114
|
|
|
85
|
-
REMINDER:
|
|
115
|
+
REMINDER: every intermediate response is exactly one JSON object or array —
|
|
116
|
+
no text, no markdown, no explanations, no progress narration. Keep working
|
|
117
|
+
with tool calls only. Talk to the operator with a single final respond call:
|
|
118
|
+
when the task is done, or when you are blocked and must ask the operator.
|
|
119
|
+
Anything you write outside that one respond is not read by the operator.
|
|
86
120
|
## Web access
|
|
87
121
|
|
|
88
122
|
- You have WebSearch and WebFetch. Use them when the answer requires current information (versions, changelogs, recent bugs, docs).
|
package/src/theme.js
CHANGED
|
@@ -28,6 +28,7 @@ export const theme = {
|
|
|
28
28
|
warn: chalk.hex('#c9a86a'), // спокойный охра
|
|
29
29
|
error: chalk.hex('#c98a80'), // приглушённый терракот
|
|
30
30
|
success: chalk.hex('#a9c08c'), // мягкий шалфейный
|
|
31
|
+
brown: chalk.hex('#a1723f'), // коричневый (статус/спиннер)
|
|
31
32
|
bold: chalk.bold,
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function unescapeXml(s) {
|
|
2
|
+
const A = String.fromCharCode(38) // ampersand
|
|
3
|
+
return String(s)
|
|
4
|
+
.split(A + "lt;").join(String.fromCharCode(60))
|
|
5
|
+
.split(A + "gt;").join(String.fromCharCode(62))
|
|
6
|
+
.split(A + "quot;").join(String.fromCharCode(34))
|
|
7
|
+
.split(A + "apos;").join(String.fromCharCode(39))
|
|
8
|
+
.split(A + "amp;").join(A)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readAttr(attrs, name) {
|
|
12
|
+
const Q = String.fromCharCode(34)
|
|
13
|
+
const re = new RegExp(name + "[ ]*=[ ]*([" + Q + "]([^" + Q + "]*)[" + Q + "])")
|
|
14
|
+
const m = attrs.match(re)
|
|
15
|
+
return m ? m[2] : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseXmlToolCalls(text) {
|
|
19
|
+
if (!text || typeof text !== "string") return null
|
|
20
|
+
if (!/<[^>]*invoke/i.test(text)) return null
|
|
21
|
+
|
|
22
|
+
const invokeRe = /<[^>]*?invoke([^>]*)>/gi
|
|
23
|
+
const calls = []
|
|
24
|
+
let m
|
|
25
|
+
while ((m = invokeRe.exec(text)) !== null) {
|
|
26
|
+
const attrs = m[1] || ""
|
|
27
|
+
const name = readAttr(attrs, "name")
|
|
28
|
+
if (!name) continue
|
|
29
|
+
|
|
30
|
+
const rest = text.slice(invokeRe.lastIndex)
|
|
31
|
+
const closeRe = /<\/[^>]*?invoke[^>]*>/i
|
|
32
|
+
const closeMatch = closeRe.exec(rest)
|
|
33
|
+
const body = closeMatch ? rest.slice(0, closeMatch.index) : rest
|
|
34
|
+
|
|
35
|
+
const args = {}
|
|
36
|
+
const paramRe = /<[^>]*?parameter([^>]*)>([\s\S]*?)<\/[^>]*?parameter[^>]*>/gi
|
|
37
|
+
let p
|
|
38
|
+
while ((p = paramRe.exec(body)) !== null) {
|
|
39
|
+
const pname = readAttr(p[1] || "", "name")
|
|
40
|
+
if (!pname) continue
|
|
41
|
+
const isString = /string[ ]*=[ ]*"?true"?/i.test(p[1] || "")
|
|
42
|
+
let value = unescapeXml(p[2])
|
|
43
|
+
if (!isString) {
|
|
44
|
+
try { value = JSON.parse(value) } catch (e) {}
|
|
45
|
+
}
|
|
46
|
+
args[pname] = value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Некоторые модели кладут весь JSON-объект аргументов в один
|
|
50
|
+
// parameter с именем args. Разворачиваем, чтобы не было
|
|
51
|
+
// {args: {args: {...}}}.
|
|
52
|
+
let finalArgs = args
|
|
53
|
+
const keys = Object.keys(args)
|
|
54
|
+
if (
|
|
55
|
+
keys.length === 1 &&
|
|
56
|
+
keys[0] === 'args' &&
|
|
57
|
+
args.args &&
|
|
58
|
+
typeof args.args === 'object' &&
|
|
59
|
+
!Array.isArray(args.args)
|
|
60
|
+
) {
|
|
61
|
+
finalArgs = args.args
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
calls.push({ tool: name, args: finalArgs })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!calls.length) return null
|
|
68
|
+
return calls.length === 1 ? calls[0] : calls
|
|
69
|
+
}
|