Три вещи, которых не хватало по сравнению с пультом 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>
708 lines
38 KiB
JavaScript
708 lines
38 KiB
JavaScript
#!/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);
|
||
}
|
||
}
|