Files
deepwork/deepwork.mjs
T
Alex CubeandClaude Opus 5 97eefb57c7 Первая версия: инструменты и цикл для DeepSeek
DeepSeek через веб-чат видит только то, что человек положил в вопрос. Здесь
у него появляется обратное направление: он возвращает блок-запрос, программа
исполняет его и досылает результат следующим сообщением в тот же чат. Обычный
агентский цикл, только транспорт — текст.

Инструменты (только чтение, только внутри рабочей папки):
  === NEED: путь, путь ===   прислать файлы целиком
  === LIST: **/*.mjs ===     список файлов по маске
  === FIND: текст ===        где встречается, с номерами строк

Запуска команд намеренно нет: исполнять строки из ответа, пришедшего из
интернета, без белого списка и подтверждения нельзя.

Мост ollama_orchestro/ask.mjs не импортируется и не копируется — он разбирает
process.argv и исполняет команду на верхнем уровне, поэтому import немедленно
запустил бы её. Вызывается подпроцессом теми флагами, которые у него уже есть:
шаг 1 без --new (правка первого сообщения), шаги 2..N с --new, иначе DeepSeek
не помнит предыдущий шаг. Взамен orchestro не меняется ни на строку, а его
правки прилетают сюда даром.

Два урока, каждый стоил живого прогона и вписан в код:
- инструкция в начале сообщения не работает. 22 КБ файла между приставкой и
  вопросом — и блоки-запросы игнорируются. Помогает дословное напоминание
  вплотную к вопросу;
- дерево папки надо давать сразу. Без него DeepSeek угадывает имена файлов и
  тратит два круга по минуте на LIST.

Есть веб-пульт (start-deepwork.cmd, бросить папку): файлы галочками, журнал
шагов по ходу, запись правок с копией в backup/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:31:33 +03:00

570 lines
28 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 — цикл с инструментами поверх моста ollama_orchestro/ask.mjs.
//
// DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он
// получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND),
// программа его исполняет и досылает результат следующим сообщением в тот же
// чат. Получается обычный агентский цикл, только транспорт — текст.
//
// Мост НЕ импортируется и НЕ копируется: ask.mjs разбирает process.argv и
// исполняет команду на верхнем уровне, поэтому import немедленно запустил бы
// её. Вызываем его подпроцессом теми флагами, которые у него уже есть.
// ollama_orchestro при этом не меняется ни на строку.
import { readFile, writeFile, mkdir, copyFile, readdir, stat, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
// ------------------------------------------------------------------ аргументы
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 next = rawArgs[i + 1];
if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++; }
else flags[a.slice(2)] = 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/)
--chat роль think (по умолчанию) | search
--timeout сек максимум бездействия страницы, передаётся мосту
--keep-temp не удалять временные файлы шагов
--check проверить, что мост на месте и вкладка жива
--selftest прогнать внутренние проверки, ничего не отправляя
Инструменты, которые может вернуть DeepSeek (только чтение, только внутри
текущей папки):
=== NEED: путь, путь === прислать файлы целиком
=== LIST: **/*.mjs === список файлов по маске
=== FIND: текст === где встречается текст
Мост берётся из config.json (ключ bridge), по умолчанию —
../ollama_orchestro/ask.mjs рядом с этой папкой.
`.trim();
// ------------------------------------------------------------------ настройки
const CONFIG_DEFAULTS = {
bridge: resolve(HERE, '..', 'ollama_orchestro', 'ask.mjs'),
chat: 'think',
steps: 4,
timeout: 240,
cdpPort: 9333, // отладочный порт браузера — только чтобы показать, жив ли мост
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 = [];
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');
}
// ------------------------------------------------------------------ мост
// Один шаг: текст уходит через --file, ответ забирается через --out. Оба пути
// абсолютные, поэтому рабочая папка подпроцесса роли не играет.
// fresh=true — первое сообщение (мост правит его и чат остаётся из одного
// обмена); fresh=false — обычная отправка, только так DeepSeek помнит шаг.
async function callBridge(cfg, text, { fresh, temp, n, log }) {
const qFile = join(temp, `step${n}-q.txt`);
const aFile = join(temp, `step${n}-a.txt`);
await writeFile(qFile, text, 'utf8');
const args = ['ask', '--chat', String(cfg.chat), '--file', qFile, '--out', aFile,
'--timeout', String(cfg.timeout), '--quiet'];
if (!fresh) args.push('--new');
log(`шаг ${n}: ${text.length} символов${fresh ? '' : ' (--new)'}, жду мост…`);
const started = Date.now();
const code = await new Promise((done, fail) => {
const p = spawn(process.execPath, [cfg.bridge, ...args], { cwd: dirname(cfg.bridge) });
let err = '';
p.stdout.on('data', () => {});
p.stderr.on('data', d => { err += d.toString(); });
p.on('error', fail);
p.on('close', c => { if (c !== 0) fail(new Error(err.trim() || `мост вышел с кодом ${c}`)); else done(c); });
});
void code;
const answer = await readFile(aFile, 'utf8');
log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`);
return answer;
}
// ------------------------------------------------------------------ цикл
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 { /* нет файла — поедет без приставки, инструменты просто не всплывут */ }
// Дерево папки едет сразу. Первый живой прогон без него: 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, '');
}
// Напоминание повторяется ВПЛОТНУЮ к вопросу. Первый живой прогон: приставка
// ушла в начало сообщения, между ней и вопросом легли 22 КБ файла, и DeepSeek
// инструменты проигнорировал — сам написал «точный код в другом файле» и
// выдал три догадки вместо запроса. Инструкция в хвосте весит больше.
parts.push(TAIL_REMINDER, '', 'ВОПРОС:', question.trim());
return parts.join('\n');
}
const TAIL_REMINDER = [
'НАПОМИНАНИЕ ПЕРЕД ОТВЕТОМ. Присланными файлами дело не ограничено: рабочая',
'папка доступна тебе целиком. Если для ответа нужен код, которого ты не',
'видел, — НЕ строй догадок и не пиши «точный код в другом файле». Верни',
'блок-запрос, он будет исполнен, и следующим сообщением ты получишь данные:',
'',
'=== NEED: путь/файл.js === прислать файл целиком',
'=== LIST: **/*.mjs === список файлов по маске',
'=== FIND: имяФункции === где встречается текст, с номерами строк',
'',
'Не знаешь пути — начни с LIST или FIND. Запрашивай всё нужное сразу, одним',
'сообщением. Догадка вместо запроса считается неверным ответом.',
].join('\n');
const FOLLOW_HEAD = [
'РЕЗУЛЬТАТ ЗАПРОСА. Данные ниже. Отвечай по существу вопроса из первого',
'сообщения; не благодари и не пересказывай присланное. Если данных всё ещё',
'не хватает — верни новый блок-запрос, иначе отвечай. Правки файлов —',
'блоками === FILE: путь === с содержимым в тройных кавычках.',
'',
].join('\n');
async function runAgent(cfg, root, question, files, log) {
const temp = join(tmpdir(), `deepwork-${process.pid}`);
await mkdir(temp, { recursive: true });
let text = await buildFirst(root, files, question);
let answer = '';
let lastSig = '';
for (let n = 1; n <= cfg.steps; n++) {
answer = await callBridge(cfg, text, { fresh: n === 1, temp, n, log });
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 === cfg.steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; }
log(`шаг ${n}: запрошено ${requests.length}`);
const result = await runRequests(root, requests, log);
text = FOLLOW_HEAD + result;
}
if (!flags['keep-temp']) await rm(temp, { recursive: true, force: true }).catch(() => {});
else log(`временные файлы: ${temp}`);
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 };
}
// ------------------------------------------------------------------ пульт
// Ход работы копится здесь, а страница забирает его опросом раз в 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') {
try {
const r = await fetch(`http://127.0.0.1:${cfg.cdpPort}/json/list`, { signal: AbortSignal.timeout(1500) });
const pages = (await r.json()).filter(t => t.type === 'page');
return json(res, 200, { browser: true, deepseek: pages.some(t => (t.url || '').includes('chat.deepseek.com')) });
} catch { return json(res, 200, { browser: false, deepseek: false }); }
}
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));
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']);
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.chat = String(flags.chat);
if (flags.selftest) { await selftest(); process.exit(0); }
if (!existsSync(cfg.bridge)) {
console.error(`Мост не найден: ${cfg.bridge}`);
console.error('Укажите путь к ask.mjs в config.json (образец — config.example.json).');
process.exit(1);
}
if (flags.ui || positional[0] === 'ui') {
await serve(cfg);
} else if (flags.check) {
console.log(`мост: ${cfg.bridge}`);
const p = spawn(process.execPath, [cfg.bridge, 'probe'], { cwd: dirname(cfg.bridge), stdio: 'inherit' });
p.on('close', c => process.exit(c));
} 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);
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);
}
}