Files
deepwork/deepwork.mjs
T
Alex CubeandClaude Opus 5 263ad14810 Пульт: выключатель инструментов, помощь с 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>
2026-08-26 14:13:57 +03:00

708 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// deepwork — цикл с инструментами поверх сервиса deepseek-api.
//
// DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он
// получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND),
// программа его исполняет и досылает результат следующим сообщением в тот же
// чат. Получается обычный агентский цикл, только транспорт — текст.
//
// С DeepSeek говорим по HTTP: у веб-версии API нет, но рядом крутится сервис
// deepseek-api, который держит вкладку браузера и отдаёт наружу протокол
// Ollama. Здесь про браузер не знают вовсе.
import { readFile, writeFile, mkdir, copyFile, readdir, stat } from 'node:fs/promises';
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
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 = {};
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[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 (!BOOL_FLAGS.has(name) && next !== undefined && !next.startsWith('--')) {
flags[name] = next; i++;
} else flags[name] = true;
} else positional.push(a);
}
const USAGE = `
deepwork — задать DeepSeek вопрос и дать ему дочитать недостающее самому
node deepwork.mjs "вопрос" [--files a.js,b.js] [--steps 4] [--apply]
node deepwork.mjs ui веб-пульт: файлы галочками, ход работы виден
Работает в ТЕКУЩЕЙ папке: её файлы показывает, в неё же пишет правки.
--files a.js,b.js что положить в вопрос сразу (через запятую)
--steps N сколько кругов позволить (по умолчанию 4)
--apply применить пришедшие блоки === FILE: === на диск
(прежняя версия уезжает в backup/)
--no-tools не давать модели инструменты: один круг, только
отмеченные файлы. Так работает пульт «с галочками»
--chat роль think (по умолчанию) | search | vision
--timeout сек сколько секунд ждать ответа на один шаг
--check проверить, что сервис на месте и вкладка жива
--selftest прогнать внутренние проверки, ничего не отправляя
Инструменты, которые может вернуть DeepSeek (только чтение, только внутри
текущей папки):
=== NEED: путь, путь === прислать файлы целиком
=== LIST: **/*.mjs === список файлов по маске
=== FIND: текст === где встречается текст
Адрес сервиса берётся из config.json (ключ deepseekHost), по умолчанию —
http://localhost:11500. Сервис запускается своим start.cmd.
`.trim();
// ------------------------------------------------------------------ настройки
const CONFIG_DEFAULTS = {
// У веб-версии DeepSeek нет API. Рядом крутится отдельный сервис deepseek-api
// (git.08h.ru/alex/deepseek-api): он держит вкладку браузера и отдаёт наружу
// протокол Ollama. Запускается своим start.cmd, браузер поднимает сам.
deepseekHost: 'http://localhost:11500',
deepseekModel: 'deepseek:think', // think | search | vision — режим чата
steps: 4,
timeout: 240, // секунд на один шаг; сверх этого сервису дают ещё две минуты
// на очередь: вкладка одна на всех потребителей
uiPort: 8787, // не 8777: там живёт пульт orchestro, оба могут работать разом
};
// Нет файла — работаем на умолчаниях, это норма. А вот битый JSON молчать не
// должен: иначе правка в config.json не действует, и понять почему нельзя —
// программа просто идёт по умолчанию к соседнему мосту.
async function readConfig() {
const file = join(HERE, 'config.json');
let text;
try { text = await readFile(file, 'utf8'); } catch { return { ...CONFIG_DEFAULTS }; }
try { return { ...CONFIG_DEFAULTS, ...JSON.parse(text) }; }
catch (e) { throw new Error(`config.json не разбирается: ${e.message}\nФайл: ${file}`); }
}
// ------------------------------------------------------- работа с файлами
const SKIP_DIRS = new Set(['.git', 'node_modules', '.claude', 'dist', 'build', '__pycache__', '.venv', 'vendor', 'backup']);
const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|rar|7z|exe|dll|mp[34]|mov|woff2?|ttf|eot|lock|bak)$/i;
// Лимиты на результат инструмента: он уезжает в сообщение чата, а там предел
// длины. Лучше отдать обрезанное с пометкой, чем не отдать ничего.
const NEED_BUDGET = 200_000; // символов суммарно за шаг
const FIND_HITS = 100;
const LIST_PATHS = 300;
async function listFiles(root, rel = '', out = []) {
for (const name of await readdir(join(root, rel))) {
if (name.startsWith('.') && name !== '.gitignore') continue;
if (SKIP_DIRS.has(name)) continue;
const r = rel ? `${rel}/${name}` : name;
const st = await stat(join(root, r));
if (st.isDirectory()) { await listFiles(root, r, out); continue; }
if (SKIP_EXT.test(name)) continue;
out.push({ path: r, size: st.size });
}
return out;
}
// Путь обязан остаться внутри рабочей папки: ответ приходит из интернета,
// доверять ему нельзя. Это касается и чтения, и записи.
function resolveInside(root, rel) {
if (!rel || isAbsolute(rel)) return null;
const full = resolve(join(root, rel));
if (relative(resolve(root), full).startsWith('..')) return null;
return full;
}
// Маска из LIST — не полноценный glob, а три подстановки: ** — любые папки,
// * — часть имени, ? — один символ. Полный glob здесь не нужен, а зависимость
// ради него — тем более.
function maskToRe(mask) {
const esc = mask.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const body = esc
.replace(/\*\*\//g, '\u0000SLASHSTAR\u0000')
.replace(/\*\*/g, '\u0000STARSTAR\u0000')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\u0000SLASHSTAR\u0000/g, '(?:.*/)?')
.replace(/\u0000STARSTAR\u0000/g, '.*');
return new RegExp(`^${body}$`, 'i');
}
// ------------------------------------------------------------------ разбор
const FILE_CLOSE = '=== END FILE ===';
const FILE_BLOCK_RE = new RegExp(`^=== FILE:\\s*(.+?)\\s*===\\s*$([\\s\\S]*?)^${FILE_CLOSE}\\s*$`, 'gm');
const LANG_RE = /^(text|plain|javascript|js|jsx|typescript|ts|tsx|json|html|css|scss|python|py|bash|sh|shell|cmd|bat|powershell|ps1|diff|patch|xml|yaml|yml|sql|java|kotlin|swift|c|cpp|cs|go|rust|rs|php|ruby|rb|ini|toml|markdown|md)$/i;
// Блоки === FILE: путь === … === END FILE ===. Содержимое обязано лежать в
// тройных кавычках (иначе разметка страницы съедает отступы), а страница
// показывает блок без самих кавычек, но с подписью языка отдельной строкой —
// её тоже снимаем.
function parsePatches(answer) {
const out = [];
const re = new RegExp(FILE_BLOCK_RE.source, 'gm');
let m;
while ((m = re.exec(String(answer || '')))) {
const lines = m[2].replace(/^\n/, '').replace(/\n$/, '').split('\n');
if (/^\s*```/.test(lines[0] ?? '')) {
lines.shift();
if (/^\s*```\s*$/.test(lines[lines.length - 1] ?? '')) lines.pop();
}
if (LANG_RE.test((lines[0] ?? '').trim())) lines.shift();
out.push({ path: m[1].trim(), content: lines.join('\n') });
}
return out;
}
// Запросы-однострочники. Тела и закрывающего блока у них нет — этим они и
// отличаются от FILE. Блоки FILE вырезаются ПЕРЕД поиском: если DeepSeek
// вернул файл, внутри которого написаны такие же строки (а этот проект как раз
// про них), они не должны сойти за запрос.
function parseRequests(answer) {
const text = String(answer || '').replace(FILE_BLOCK_RE, '');
const out = [];
// Блок не обязан начинать строку: 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;
}
// ------------------------------------------------------------- исполнители
function fence(body) { return '```\n' + body + '\n```'; }
async function doNeed(root, arg, budget) {
const parts = [];
for (const rel of arg.split(',').map(s => s.trim().replace(/^["'`]|["'`]$/g, '')).filter(Boolean)) {
const full = resolveInside(root, rel);
if (!full) { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('отказано: путь ведёт за пределы рабочей папки')}`); continue; }
let text;
try { text = await readFile(full, 'utf8'); }
catch { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('файла нет по такому пути')}`); continue; }
if (text.length > budget.left) {
text = text.slice(0, Math.max(budget.left, 0)) + `\n…обрезано, всего ${text.length} символов`;
}
budget.left -= text.length;
parts.push(`=== FILE: ${rel} ===\n${fence(text)}\n${FILE_CLOSE}`);
}
return parts.join('\n\n');
}
async function doList(root, arg) {
const re = maskToRe(arg.trim() || '**/*');
const all = await listFiles(root);
const hit = all.filter(f => re.test(f.path));
const shown = hit.slice(0, LIST_PATHS);
const body = shown.map(f => `${f.path} ${f.size} б`).join('\n')
+ (hit.length > shown.length ? `\n…обрезано, всего ${hit.length}` : '')
+ (hit.length ? '' : 'ничего не подошло под маску');
return `=== RESULT: LIST ${arg} ===\n${fence(body)}`;
}
async function doFind(root, arg) {
const needle = arg.trim().toLowerCase();
const hits = [];
for (const f of await listFiles(root)) {
if (f.size > 2_000_000) continue;
let text;
try { text = await readFile(join(root, f.path), 'utf8'); } catch { continue; }
if (!text.toLowerCase().includes(needle)) continue;
text.split('\n').forEach((line, i) => {
if (hits.length >= FIND_HITS + 1) return;
if (line.toLowerCase().includes(needle)) hits.push(`${f.path}:${i + 1}: ${line.trim().slice(0, 200)}`);
});
}
const shown = hits.slice(0, FIND_HITS);
const body = shown.join('\n')
+ (hits.length > shown.length ? `\n…обрезано, показаны первые ${FIND_HITS}` : '')
+ (hits.length ? '' : 'нигде не встречается');
return `=== RESULT: FIND ${arg} ===\n${fence(body)}`;
}
async function runRequests(root, requests, log) {
const budget = { left: NEED_BUDGET };
const parts = [];
for (const r of requests) {
log(` ${r.kind}: ${r.arg}`);
if (r.kind === 'NEED') parts.push(await doNeed(root, r.arg, budget));
else if (r.kind === 'LIST') parts.push(await doList(root, r.arg));
else if (r.kind === 'FIND') parts.push(await doFind(root, r.arg));
}
return parts.filter(Boolean).join('\n\n');
}
// ------------------------------------------------------------------ DeepSeek
// Один шаг: вопрос уходит по HTTP, ответ приходит там же. Раньше здесь был
// дочерний процесс с обменом через временные файлы — разговор с вкладкой
// браузера уехал в deepseek-api, а нам он показывает протокол Ollama.
async function callDeepseek(cfg, text, { n, log }) {
log(`шаг ${n}: ${text.length} символов, спрашиваю DeepSeek…`);
const started = Date.now();
let r;
try {
r = await fetch(`${cfg.deepseekHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: cfg.deepseekModel,
stream: false, // иначе придёт NDJSON, а нам нужен объект
messages: [{ role: 'user', content: text }],
}),
// Свой срок больше сервисного: там таймаут считает БЕЗДЕЙСТВИЕ страницы,
// а сюда добавляется ещё и очередь — вкладка одна на всех потребителей.
signal: AbortSignal.timeout((cfg.timeout + 120) * 1000),
});
} catch (e) {
throw new Error(
`deepseek-api не отвечает по адресу ${cfg.deepseekHost}: ${e.message}\n` +
'Запустите start.cmd в папке deepseek-api — браузер он поднимет сам.');
}
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
`deepseek-api ответил ${r.status}: ${data.error || 'без пояснений'}\n` +
`Что видно во вкладке: curl ${cfg.deepseekHost}/api/probe`);
}
const answer = data.message?.content ?? '';
log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`);
// Сервис сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки
// там не бесконечны. Если такой текст всё-таки доехал — это не ответ, и
// разбирать его как ответ нельзя: в блоках-запросах и правках его нет,
// а цикл решил бы, что запросов не поступило, и молча закончил работу.
if (isServerBusy(answer)) throw new Error(
'DeepSeek занят: «Server is busy». Повторы исчерпаны — попробуйте позже.');
return answer;
}
// Ответ, которого на самом деле нет: сервер занят и просит повторить.
function isServerBusy(text) {
const t = String(text || '').trim();
return t.length < 400 && /server is busy|сервер занят|try again later/i.test(t);
}
// ------------------------------------------------------------------ цикл
// 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')),
'');
}
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
// инструменты проигнорировал — сам написал «точный код в другом файле» и
// выдал три догадки вместо запроса. Инструкция в хвосте весит больше.
if (tools) parts.push(TAIL_REMINDER, '');
parts.push('ВОПРОС:', question.trim());
return parts.join('\n');
}
const TAIL_REMINDER = [
'НАПОМИНАНИЕ ПЕРЕД ОТВЕТОМ. Присланными файлами дело не ограничено: рабочая',
'папка доступна тебе целиком. Если для ответа нужен код, которого ты не',
'видел, — НЕ строй догадок и не пиши «точный код в другом файле». Верни',
'блок-запрос, он будет исполнен, и следующим сообщением ты получишь данные:',
'',
'=== NEED: путь/файл.js === прислать файл целиком',
'=== LIST: **/*.mjs === список файлов по маске',
'=== FIND: имяФункции === где встречается текст, с номерами строк',
'',
'Не знаешь пути — начни с LIST или FIND. Запрашивай всё нужное сразу, одним',
'сообщением. Догадка вместо запроса считается неверным ответом.',
].join('\n');
const FOLLOW_HEAD = [
'РЕЗУЛЬТАТ ЗАПРОСА. Данные ниже. Отвечай по существу вопроса из первого',
'сообщения; не благодари и не пересказывай присланное. Если данных всё ещё',
'не хватает — верни новый блок-запрос, иначе отвечай. Правки файлов —',
'блоками === FILE: путь === с содержимым в тройных кавычках.',
'',
].join('\n');
// Ход разговора ведём здесь, а не в чате DeepSeek. Сервис памяти не имеет:
// каждый запрос он кладёт в чат целиком, затирая прошлый. Раньше эту роль играл
// флаг --new, и помнил всё сам DeepSeek; теперь стенограмма растёт у нас.
//
// Так даже надёжнее: видно в одном месте, что именно уедет модели, и чужой
// потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
// приносим заново. Плата — вопрос растёт с каждым шагом на длину прошлого
// ответа; при steps=4 это единицы килобайт против 22 КБ первого сообщения.
function growTranscript(prev, answer, next) {
return [prev, '',
'=== ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ ===', answer.trim(), '=== END ===', '',
next].join('\n');
}
async function runAgent(cfg, root, question, files, log, { tools = true, context = '' } = {}) {
let text = await buildFirst(root, files, question, { tools, context });
let answer = '';
let lastSig = '';
// Без инструментов круг ровно один: запрашивать модели нечем, а блок 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; }
// Один и тот же запрос второй раз подряд — петля: данные мы уже присылали,
// значит DeepSeek их не принял и повторит это бесконечно.
const sig = requests.map(r => `${r.kind}:${r.arg}`).join('|');
if (sig === lastSig) { log(`шаг ${n}: тот же запрос повторно — петля, выхожу`); break; }
lastSig = sig;
if (n === steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; }
log(`шаг ${n}: запрошено ${requests.length}`);
const result = await runRequests(root, requests, log);
text = growTranscript(text, answer, FOLLOW_HEAD + result);
}
return answer;
}
// ------------------------------------------------------------- запись правок
async function applyPatch(root, rel, content) {
const full = resolveInside(root, rel);
if (!full) return { ok: false, reason: 'путь ведёт за пределы рабочей папки' };
let backup = null;
try {
await readFile(full, 'utf8');
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
backup = join('backup', `${rel}.bak-${stamp}`);
await mkdir(dirname(join(root, backup)), { recursive: true });
await copyFile(full, join(root, backup));
} catch { /* новый файл, копировать нечего */ }
await mkdir(dirname(full), { recursive: true });
await writeFile(full, content, 'utf8');
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 секунды.
// Стримить нечего: строк за прогон десяток, а опрос — тот же приём, которым
// пульт orchestro следит за мостом.
const progress = { steps: [], busy: false, started: 0 };
async function serve(cfg) {
const http = await import('node:http');
const root = process.cwd();
const port = Number.parseInt(flags.port, 10) || cfg.uiPort;
const json = (res, code, data) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
// Куски копим Buffer'ами и склеиваем один раз: складывание строками рвёт
// многобайтовый символ на границе, и русский вопрос уезжает крякозябрами.
const readBody = req => new Promise((ok, bad) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('error', bad);
req.on('end', () => {
try { ok(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (e) { bad(e); }
});
});
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://127.0.0.1:${port}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(await readFile(join(HERE, 'ui.html'), 'utf8'));
}
if (req.method === 'GET' && url.pathname === '/api/tree')
return json(res, 200, { root, files: await listFiles(root) });
if (req.method === 'GET' && url.pathname === '/api/status') {
// 200 от /api/tags значит «браузер поднят и вкладка на месте»; 503 —
// сервис жив, но браузера ещё нет (поднимется на первом же вопросе);
// отказ соединения — сервис не запущен вовсе. Три состояния, и плашка
// в ui.html должна их различать.
try {
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(2500) });
return json(res, 200, { service: true, deepseek: r.ok });
} 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 });
if (req.method === 'POST' && url.pathname === '/api/ask') {
if (progress.busy) return json(res, 409, { error: 'предыдущий вопрос ещё в работе' });
const body = await readBody(req);
if (!String(body.question || '').trim()) return json(res, 400, { error: 'пустой вопрос' });
progress.busy = true; progress.steps = []; progress.started = Date.now();
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),
{ tools: body.tools !== false, context: String(body.context || '') });
return json(res, 200, { answer, steps: progress.steps, patches: parsePatches(answer) });
} finally { progress.busy = false; }
}
if (req.method === 'POST' && url.pathname === '/api/apply') {
const { path: rel, content } = await readBody(req);
return json(res, 200, await applyPatch(root, rel, content));
}
res.writeHead(404); res.end('нет такой страницы');
} catch (e) { json(res, 500, { error: String(e && e.message || e) }); }
});
// Занятый порт не должен ронять запуск: прошлый пульт мог остаться висеть.
const listen = p => new Promise((ok, bad) => {
const onErr = e => (e.code === 'EADDRINUSE' ? ok(null) : bad(e));
server.once('error', onErr);
server.listen(p, '127.0.0.1', () => { server.off('error', onErr); ok(p); });
});
let actual = null;
for (let p = port; p < port + 20 && actual === null; p++) actual = await listen(p);
if (actual === null) throw new Error(`свободный порт не найден в диапазоне ${port}..${port + 19}`);
console.log(`пульт: http://127.0.0.1:${actual}`);
console.log(`папка: ${root}`);
console.log('остановить — Ctrl+C');
// Открываем СТРОГО в браузере по умолчанию. В отладочный браузер нельзя:
// пульт займёт там вкладку, и мост перестанет находить chat.deepseek.com.
if (!flags['no-open']) {
const { exec } = await import('node:child_process');
exec(`start "" "http://127.0.0.1:${actual}"`, { shell: 'cmd.exe' }, () => {});
}
await new Promise(() => {});
}
// ------------------------------------------------------------- самопроверка
async function selftest() {
const { strict: assert } = await import('node:assert');
const root = process.cwd();
// Запросы разбираются, а такие же строки ВНУТРИ блока файла — нет.
const sample = [
'Не хватает данных.',
'=== NEED: ask.mjs, ui.html ===',
'=== LIST: **/*.mjs ===',
'=== FIND: waitForAnswer ===',
'=== FILE: agent-prompt.md ===',
'```',
'text',
'=== NEED: это строка внутри файла ===',
' отступ сохраняется',
'```',
FILE_CLOSE,
].join('\n');
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);
assert.equal(pat.length, 1);
assert.equal(pat[0].path, 'agent-prompt.md');
// Тройные кавычки и подпись языка сняты, отступ и строка-обманка целы.
assert.equal(pat[0].content, '=== NEED: это строка внутри файла ===\n отступ сохраняется');
// Выход за пределы папки отвергается, свои пути — нет.
assert.equal(resolveInside(root, '../../windows/system32/drivers/etc/hosts'), null);
assert.equal(resolveInside(root, 'C:\\windows\\win.ini'), null);
assert.ok(resolveInside(root, 'sub/dir/file.js'));
// Маска: * не перепрыгивает через папку, ** перепрыгивает.
assert.ok(!maskToRe('*.mjs').test('sub/x.mjs'));
assert.ok(maskToRe('*.mjs').test('x.mjs'));
assert.ok(maskToRe('**/*.mjs').test('sub/dir/x.mjs'));
assert.ok(maskToRe('**/*.mjs').test('x.mjs'));
assert.ok(!maskToRe('**/*.mjs').test('x.md'));
console.log('самопроверка пройдена');
}
// ------------------------------------------------------------------ запуск
const cfg = await readConfig();
cfg.steps = Number.parseInt(flags.steps, 10) || cfg.steps;
cfg.timeout = Number.parseInt(flags.timeout, 10) || cfg.timeout;
if (flags.chat) cfg.deepseekModel = 'deepseek:' + String(flags.chat);
if (flags.selftest) { await selftest(); process.exit(0); }
if (flags.ui || positional[0] === 'ui') {
await serve(cfg);
} else if (flags.check) {
// Три состояния, и путать их нельзя: 200 — всё готово; 503 — сервис жив,
// но браузер ещё не поднят (встанет на первом вопросе); отказ соединения —
// сервис не запущен вовсе.
console.log(`сервис: ${cfg.deepseekHost}`);
try {
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(3000) });
console.log(r.ok ? 'готов: браузер поднят, вкладка DeepSeek открыта'
: `не готов (HTTP ${r.status}): браузер поднимется на первом вопросе`);
process.exit(r.ok ? 0 : 1);
} catch (e) {
console.error(`не отвечает: ${e.message}`);
console.error('Запустите start.cmd в папке deepseek-api.');
process.exit(1);
}
} else {
const question = positional.join(' ');
if (!question.trim()) { console.log(USAGE); process.exit(question ? 1 : 0); }
const root = process.cwd();
const files = String(flags.files || '').split(',').map(s => s.trim()).filter(Boolean);
const log = (...a) => console.log(...a);
try {
console.log(`папка: ${root}`);
const answer = await runAgent(cfg, root, question, files, log,
{ tools: !flags['no-tools'] });
console.log('\n----- ответ DeepSeek -----');
console.log(answer);
const patches = parsePatches(answer);
if (patches.length) {
console.log(`\n----- файлы в ответе: ${patches.length} -----`);
for (const p of patches) console.log(` ${p.path} ${p.content.split('\n').length} строк`);
if (flags.apply) {
for (const p of patches) {
const r = await applyPatch(root, p.path, p.content);
console.log(r.ok ? `записан ${p.path}${r.backup ? ` (копия: ${r.backup})` : ' (новый)'}`
: `пропущен ${p.path}: ${r.reason}`);
}
} else console.log(' не записаны: нужен флаг --apply');
}
} catch (e) {
console.error(`Ошибка: ${e.message}`);
process.exit(1);
}
}