Пульт: выключатель инструментов, помощь с git, память прошлого ответа

Три вещи, которых не хватало по сравнению с пультом DeepShim, — теперь
здесь, и DeepShim можно закрывать.

1. Выключатель инструментов. Снят — приставка, дерево папки и напоминание
   про NEED/LIST/FIND не едут вовсе, круг ровно один: обычное "спросил
   по этим файлам, получил ответ". Замер на одной задаче: 229 символов
   вопроса против 1886. В консоли это флаг --no-tools.

2. Помощь с git: ряд всплывает, только если в папке нет репозитория или
   в .gitignore не хватает строк. Главная - backup/, куда ложатся прежние
   версии файлов перед записью. Перенесено из пульта DeepShim.

3. "Помнить прошлый ответ": галочка, по которой прошлый ответ уезжает
   вместе со следующим вопросом. Разговор из нескольких вопросов, которого
   раньше не было: стенограмма велась внутри одного вопроса и на этом всё.

Попутно два дефекта, оба найдены при проверке:

- Разбор флагов съедал аргумент после булева флага: "deepwork --apply вопрос"
  превращался в apply="вопрос", а вопрос пропадал. Молчало, пока булев флаг
  стоял последним. Та же грабля уже ловилась в ask.mjs; добавлен BOOL_FLAGS.

- Блок-запрос вплотную к прозе не распознавался. DeepSeek регулярно пишет
  "Запрашиваю поиск константы.=== FIND: MAGIC ===" одной строкой, разбор
  требовал начала строки, и круг уходил впустую. Якорь начала снят, якорь
  конца оставлен: упоминание формата посреди фразы запросом не считается.
  От запросов внутри === FILE: === бережёт вырезание этих блоков, а не якорь.

Проверено вживую: без инструментов - 229 символов, один круг, верный ответ;
с инструментами - FIND распознан, два круга, верный ответ; git - repo:false
и пять недостающих строк, после кнопки repo:true и missing пуст; память -
"удвой это число" вернуло 80834 против бессмыслицы без галочки.
--selftest дополнен двумя случаями на разбор запросов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 14:13:57 +03:00
co-authored by Claude Opus 5
parent f34e07e807
commit 263ad14810
3 changed files with 223 additions and 28 deletions
+128 -26
View File
@@ -18,6 +18,13 @@ const HERE = dirname(fileURLToPath(import.meta.url));
// ------------------------------------------------------------------ аргументы
// Флаги без значения. Без этого списка разбор съедал следующий аргумент:
// `deepwork --apply "вопрос"` превращался в apply="вопрос", а сам вопрос
// пропадал. Молчит это до тех пор, пока булев флаг стоит последним, — а
// стоило поставить его перед вопросом, и команда падала на пустом вопросе.
// Та же грабля уже ловилась в ask.mjs проекта DeepShim.
const BOOL_FLAGS = new Set(['apply', 'check', 'selftest', 'ui', 'no-open', 'no-tools']);
const rawArgs = process.argv.slice(2);
const positional = [];
const flags = {};
@@ -26,9 +33,11 @@ for (let i = 0; i < rawArgs.length; i++) {
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
const name = a.slice(2);
const next = rawArgs[i + 1];
if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++; }
else flags[a.slice(2)] = true;
if (!BOOL_FLAGS.has(name) && next !== undefined && !next.startsWith('--')) {
flags[name] = next; i++;
} else flags[name] = true;
} else positional.push(a);
}
@@ -44,6 +53,8 @@ deepwork — задать DeepSeek вопрос и дать ему дочита
--steps N сколько кругов позволить (по умолчанию 4)
--apply применить пришедшие блоки === FILE: === на диск
(прежняя версия уезжает в backup/)
--no-tools не давать модели инструменты: один круг, только
отмеченные файлы. Так работает пульт «с галочками»
--chat роль think (по умолчанию) | search | vision
--timeout сек сколько секунд ждать ответа на один шаг
--check проверить, что сервис на месте и вкладка жива
@@ -166,7 +177,14 @@ function parsePatches(answer) {
function parseRequests(answer) {
const text = String(answer || '').replace(FILE_BLOCK_RE, '');
const out = [];
const re = /^=== (NEED|LIST|FIND):\s*(.+?)\s*===\s*$/gm;
// Блок не обязан начинать строку: DeepSeek регулярно дописывает его вплотную
// к прозе — «Запрашиваю поиск константы.=== FIND: MAGIC ===». С якорем ^ такой
// запрос не виделся, и круг уходил впустую (замер 26.08.2026).
// Конец строки якорем ОСТАЁТСЯ: иначе упоминание формата посреди фразы
// («верни === NEED: файл === и я пришлю») стало бы настоящим запросом.
// От запросов внутри блоков === FILE: === бережёт не якорь, а вырезание
// этих блоков строкой выше.
const re = /=== (NEED|LIST|FIND):\s*(.+?)\s*===\s*$/gm;
let m;
while ((m = re.exec(text))) out.push({ kind: m[1], arg: m[2] });
return out;
@@ -291,33 +309,49 @@ function isServerBusy(text) {
// ------------------------------------------------------------------ цикл
async function buildFirst(root, files, question) {
let head = '';
try {
const raw = await readFile(join(HERE, 'agent-prompt.md'), 'utf8');
head = (raw.split(/^---$/m)[1] ?? '').trim();
} catch { /* нет файла — поедет без приставки, инструменты просто не всплывут */ }
// tools=false — режим «просто ответь по этим файлам»: ни приставки, ни дерева,
// ни напоминания про NEED/LIST/FIND. Тогда и круг ровно один. Инструменты
// хороши, когда неизвестно, что читать; когда файлы выбраны руками, они только
// тратят время и подбивают модель ходить по папке без нужды.
async function buildFirst(root, files, question, { tools = true, context = '' } = {}) {
const parts = [];
if (tools) {
let head = '';
try {
const raw = await readFile(join(HERE, 'agent-prompt.md'), 'utf8');
head = (raw.split(/^---$/m)[1] ?? '').trim();
} catch { /* нет файла — поедет без приставки, инструменты просто не всплывут */ }
// Дерево папки едет сразу. Первый живой прогон без него: DeepSeek угадывал
// имена (server.js, bridge.js — таких нет), потом просил LIST *.js, потом
// LIST **/* — два круга из четырёх ушли на выяснение, что вообще лежит в
// папке. Дерево этого проекта — 600 символов против двух минут ожидания.
const tree = (await listFiles(root)).slice(0, LIST_PATHS);
parts.push(head, '',
'=== RESULT: LIST **/* ===',
fence(tree.map(f => `${f.path} ${f.size} б`).join('\n')),
'');
}
// Дерево папки едет сразу. Первый живой прогон без него: DeepSeek угадывал
// имена (server.js, bridge.js — таких нет), потом просил LIST *.js, потом
// LIST **/* — два круга из четырёх ушли на выяснение, что вообще лежит в
// папке. Дерево этого проекта — 600 символов против двух минут ожидания.
const tree = (await listFiles(root)).slice(0, LIST_PATHS);
const parts = [head, '',
'=== RESULT: LIST **/* ===',
fence(tree.map(f => `${f.path} ${f.size} б`).join('\n')),
'',
];
for (const rel of files) {
const full = resolveInside(root, rel);
if (!full) throw new Error(`файл вне рабочей папки: ${rel}`);
parts.push(`=== FILE: ${rel} ===`, fence(await readFile(full, 'utf8')), FILE_CLOSE, '');
}
// Прошлый ответ, если человек попросил его сохранить. Метки те же, что
// у стенограммы кругов: формат один — модели проще.
if (String(context).trim()) {
parts.push('=== ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ ===', String(context).trim(), '=== END ===', '');
}
// Напоминание повторяется ВПЛОТНУЮ к вопросу. Первый живой прогон: приставка
// ушла в начало сообщения, между ней и вопросом легли 22 КБ файла, и DeepSeek
// инструменты проигнорировал — сам написал «точный код в другом файле» и
// выдал три догадки вместо запроса. Инструкция в хвосте весит больше.
parts.push(TAIL_REMINDER, '', 'ВОПРОС:', question.trim());
if (tools) parts.push(TAIL_REMINDER, '');
parts.push('ВОПРОС:', question.trim());
return parts.join('\n');
}
@@ -357,13 +391,18 @@ function growTranscript(prev, answer, next) {
next].join('\n');
}
async function runAgent(cfg, root, question, files, log) {
let text = await buildFirst(root, files, question);
async function runAgent(cfg, root, question, files, log, { tools = true, context = '' } = {}) {
let text = await buildFirst(root, files, question, { tools, context });
let answer = '';
let lastSig = '';
for (let n = 1; n <= cfg.steps; n++) {
// Без инструментов круг ровно один: запрашивать модели нечем, а блок NEED,
// случайно попавший в текст ответа, запускать круг не должен.
const steps = tools ? cfg.steps : 1;
for (let n = 1; n <= steps; n++) {
answer = await callDeepseek(cfg, text, { n, log });
if (!tools) break;
const requests = parseRequests(answer);
if (!requests.length) { log(`шаг ${n}: запросов нет, готово`); break; }
@@ -373,7 +412,7 @@ async function runAgent(cfg, root, question, files, log) {
if (sig === lastSig) { log(`шаг ${n}: тот же запрос повторно — петля, выхожу`); break; }
lastSig = sig;
if (n === cfg.steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; }
if (n === steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; }
log(`шаг ${n}: запрошено ${requests.length}`);
const result = await runRequests(root, requests, log);
@@ -401,6 +440,54 @@ async function applyPatch(root, rel, content) {
return { ok: true, backup };
}
// --------------------------------------------------------------- помощь с git
// Строки, без которых правки из ответа однажды уедут в коммит. Главная —
// backup/: туда ложатся прежние версии файлов перед записью.
const GITIGNORE_LINES = ['backup/', 'node_modules/', '*.log', 'Thumbs.db', '.DS_Store'];
async function gitState(root) {
let repo = false;
try { repo = (await stat(join(root, '.git'))).isDirectory(); } catch { /* нет */ }
// Репозиторий может быть заведён давно, а backup/ в .gitignore не попасть —
// тогда кнопка всё ещё нужна, но уже для дополнения.
let text = '';
try { text = await readFile(join(root, '.gitignore'), 'utf8'); } catch { /* нет файла */ }
const have = new Set(text.split(/\r?\n/).map(l => l.trim()));
return { repo, missing: GITIGNORE_LINES.filter(l => !have.has(l)) };
}
async function gitInit(root) {
const { execFile } = await import('node:child_process');
const run = (cmd, args) => new Promise(resolve => {
execFile(cmd, args, { cwd: root }, (err, out, errOut) =>
resolve({ ok: !err, out: String(out || '').trim(),
err: String(errOut || err && err.message || '').trim() }));
});
const ver = await run('git', ['--version']);
if (!ver.ok) return { ok: false, reason: 'git не найден в PATH' };
const { repo } = await gitState(root);
if (!repo) {
const init = await run('git', ['init']);
if (!init.ok) return { ok: false, reason: init.err || 'git init не выполнился' };
}
// .gitignore дополняем, а не перезаписываем: в папке мог быть свой.
const file = join(root, '.gitignore');
let text = '';
try { text = await readFile(file, 'utf8'); } catch { /* заведём новый */ }
const have = new Set(text.split(/\r?\n/).map(l => l.trim()));
const missing = GITIGNORE_LINES.filter(l => !have.has(l));
if (missing.length) {
const head = text && !text.endsWith('\n') ? '\n' : '';
const note = text ? '\n# добавлено пультом\n' : '# .gitignore заведён пультом\n';
await writeFile(file, text + head + note + missing.join('\n') + '\n', 'utf8');
}
return { ok: true, added: missing, wasRepo: repo };
}
// ------------------------------------------------------------------ пульт
// Ход работы копится здесь, а страница забирает его опросом раз в 2 секунды.
@@ -451,6 +538,12 @@ async function serve(cfg) {
} catch { return json(res, 200, { service: false, deepseek: false }); }
}
if (req.method === 'GET' && url.pathname === '/api/git')
return json(res, 200, await gitState(root));
if (req.method === 'POST' && url.pathname === '/api/git/init')
return json(res, 200, await gitInit(root));
if (req.method === 'GET' && url.pathname === '/api/progress')
return json(res, 200, { ...progress, seconds: progress.busy ? Math.round((Date.now() - progress.started) / 1000) : 0 });
@@ -463,7 +556,8 @@ async function serve(cfg) {
try {
const run = { ...cfg, steps: Number.parseInt(body.steps, 10) || cfg.steps };
const answer = await runAgent(run, root, String(body.question), body.files || [],
m => progress.steps.push(m));
m => progress.steps.push(m),
{ tools: body.tools !== false, context: String(body.context || '') });
return json(res, 200, { answer, steps: progress.steps, patches: parsePatches(answer) });
} finally { progress.busy = false; }
}
@@ -523,6 +617,13 @@ async function selftest() {
const req = parseRequests(sample);
assert.deepEqual(req.map(r => r.kind), ['NEED', 'LIST', 'FIND']);
// Блок вплотную к прозе — тоже запрос: так DeepSeek отвечает регулярно.
assert.deepEqual(
parseRequests('Мне нужны данные.=== FIND: MAGIC ===').map(r => r.arg), ['MAGIC']);
// А упоминание формата ПОСРЕДИ фразы запросом быть не должно.
assert.deepEqual(
parseRequests('верни === NEED: файл === и я пришлю его тебе'), []);
assert.equal(req[0].arg, 'ask.mjs, ui.html');
const pat = parsePatches(sample);
@@ -582,7 +683,8 @@ if (flags.ui || positional[0] === 'ui') {
try {
console.log(`папка: ${root}`);
const answer = await runAgent(cfg, root, question, files, log);
const answer = await runAgent(cfg, root, question, files, log,
{ tools: !flags['no-tools'] });
console.log('\n----- ответ DeepSeek -----');
console.log(answer);