Первая версия: инструменты и цикл для 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>
This commit is contained in:
Alex Cube
2026-08-11 16:31:33 +03:00
co-authored by Claude Opus 5
commit 97eefb57c7
7 changed files with 1008 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
backup/
config.json
node_modules/
*.log
Thumbs.db
.DS_Store
+170
View File
@@ -0,0 +1,170 @@
# deepwork
Цикл с инструментами поверх моста `ollama_orchestro/ask.mjs`.
DeepSeek через веб-чат видит только то, что положили ему в вопрос. Здесь у него
появляется обратное направление: он возвращает блок-запрос, программа его
исполняет и досылает результат следующим сообщением в тот же чат. Обычный
агентский цикл, только транспорт — текст.
## Как пользоваться
Обычный путь — **пульт**: бросить папку проекта на `start-deepwork.cmd`.
Откроется страница: файлы галочками, поле вопроса, ход работы построчно,
кнопка «Записать» у каждого пришедшего файла.
Пульт открывается в браузере ПО УМОЛЧАНИЮ, а не в отладочном: там он занял бы
вкладку, и мост перестал бы видеть DeepSeek. Порт 8787 — рядом с пультом
orchestro (8777), оба могут работать разом.
Тот же цикл из консоли:
```
cd c:\мой\проект
node c:\#Projects\deepwork\deepwork.mjs "вопрос" --files a.js,b.js --apply
node c:\#Projects\deepwork\deepwork.mjs ui тот же пульт вручную
```
Работает в текущей папке: её файлы показывает, в неё же пишет правки.
## Инструменты
Только чтение и только внутри текущей папки:
| Блок в ответе | Что делает |
|---|---|
| `=== NEED: путь, путь ===` | прислать файлы целиком |
| `=== LIST: **/*.mjs ===` | список файлов по маске |
| `=== FIND: текст ===` | где встречается текст, с номерами строк |
Запуска команд (`RUN`) нет намеренно: исполнять строки из ответа, пришедшего
из интернета, без белого списка и подтверждения нельзя.
Лимиты на результат шага: `NEED` — 200 000 символов суммарно, `FIND` — 100
попаданий, `LIST` — 300 путей. Обрезанное помечается прямо в тексте.
## Как устроено
Мост **не импортируется и не копируется**. `ask.mjs` разбирает `process.argv`
и исполняет команду на верхнем уровне — `import` немедленно запустил бы её.
Поэтому мост вызывается подпроцессом теми флагами, которые у него уже есть:
```
node ask.mjs ask --chat think --file step1.txt --out ans1.txt --quiet
node ask.mjs ask --chat think --new --file step2.txt --out ans2.txt --quiet
```
Шаг 1 идёт без `--new`: мост правит первое сообщение, и чат остаётся из одного
обмена. Шаги 2..N — с `--new`, обычной отправкой: только так DeepSeek помнит
предыдущий шаг. Цена — переподключение к браузеру на каждом шаге, 1–2 секунды.
Взамен `ollama_orchestro` не меняется ни на строку, а его правки прилетают сюда
даром.
Чат берётся тот же, что у orchestro (`think` из его `chats.json`). Своя роль не
заводится: `chats.json` принадлежит мосту.
Цикл останавливается, когда запросов в ответе нет, когда кончились шаги или
когда пришёл тот же самый запрос второй раз подряд (петля).
## Настройка
`config.json` (в репозиторий не попадает, образец — `config.example.json`):
```json
{ "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", "chat": "think", "steps": 4 }
```
Без файла мост ищется рядом: `../ollama_orchestro/ask.mjs`.
## Проверка
```
node deepwork.mjs --selftest внутренние проверки, ничего не отправляют
node deepwork.mjs --check мост на месте, вкладка жива
```
Браузер должен быть запущен `start-browser.cmd` из `ollama_orchestro`
с отладочным портом 9333.
## Что проверено
- `--selftest`: разбор запросов, отказ пути за пределы папки, маски `*` и `**`,
снятие тройных кавычек и подписи языка. Строка вида `=== NEED: … ===`
**внутри** блока `=== FILE: ===` за запрос не принимается — иначе правка
файла с описанием формата запускала бы запрос сама.
- Цикл целиком на заглушке вместо моста: приставка едет в первом сообщении,
второй вызов уходит с `--new`, результаты `NEED` и `FIND` доезжают,
отступы в записанном файле сохраняются, прежняя версия уходит в `backup/`.
- Пропавший файл в `NEED` возвращает «файла нет» и цикл не роняет.
- `--steps 1` отдаёт ответ с пометкой «шаги кончились», а не зависает.
- Битый `config.json` — понятная ошибка, а не молчаливый откат к умолчаниям.
### Живые прогоны на DeepSeek, 11.08.2026
Задача одна и та же: из папки `ollama_orchestro` спросить, где решается, что
ответ дописан до конца, дав в вопросе **только** `ui.html` — то есть заведомо
не тот файл. Правильный ответ (`waitForAnswer` в `ask.mjs`) достижим только
через инструменты.
| Прогон | Шагов | Что вышло |
|---|---|---|
| 1. приставка только в начале сообщения | 1 | **запросов нет.** «Точный код в серверном файле» и три догадки |
| 2. + напоминание вплотную к вопросу | 4 | нашёл, но 2 круга ушли на угадывание имён файлов |
| 3. + дерево папки в первом сообщении | **2** | запросил `ask.mjs` сразу, ответил по существу |
Два вывода, оба стоили живого прогона:
- **Инструкция в начале сообщения не работает.** Между приставкой и вопросом
легли 22 КБ файла, и DeepSeek инструменты просто не заметил. Помогло
дословное повторение перед самим вопросом (`TAIL_REMINDER`).
- **Дерево папки надо давать сразу.** Без него DeepSeek угадывал имена
(`server.js`, `bridge.js` — таких в проекте нет), потом просил `LIST *.js`
(проект на `.mjs`, пусто), потом `LIST **/*`. Дерево — 600 символов против
двух кругов по минуте.
### Пульт, 11.08.2026
Сквозной прогон через страницу: вопрос без единого приложенного файла —
«какая функция решает, что ответ дописан?». 36 секунд, три шага. DeepSeek
запросил пятью блоками `FIND` сразу (`waitForAnswer`, `streaming`, `done`,
`stop_reason`, `finish_reason`), затем `NEED: ask.mjs`, и ответил точно:
`waitForAnswer`, строка 675. Журнал шагов обновлялся на странице по ходу.
## Обрезанные ответы: причина найдена и устранена в мосте (11.08.2026)
В третьем прогоне ответ оборвался посреди блока кода на `// Бы`. Оказалось,
это не «конец определён раньше времени» и не кнопка `Continue`.
**Браузер работал в фоне.** У неактивного окна страница не отрисовывается, а
DeepSeek рисует блоки кода лениво: пока блок не нарисован, в DOM от него
лежат первые две строки, а остального нет вовсе — его не достать ни
`innerText`, ни `textContent`. Прокрутка не помогает (проверено проходом по
всему ответу шагами: ни одного лишнего символа).
Помогает единственное — заставить браузер отрисовать страницу, и через CDP
это делает `Page.captureScreenshot`. Замеры на живых ответах:
| | до отрисовки | после |
|---|---|---|
| ответ про TaskQueue | 2068 | 24 362 |
| ответ про EventBus | 3136 | 30 817 |
| ответ про Router (уже с правкой) | 258 | **32 045** |
Правка в `ollama_orchestro/ask.mjs`: `revealAnswer()` перед съёмом текста
делает дешёвый скриншот (jpeg, качество 1) и перечитывает ответ, до трёх раз,
пока текст растёт. Картинка выбрасывается.
Вдобавок `browser-launch.ps1` запускает браузер развёрнутым и с запретом
фонового режима (`--disable-backgrounding-occluded-windows`,
`--disable-renderer-backgrounding`, `--disable-background-timer-throttling`,
`--disable-features=CalculateNativeWinOcclusion`). Это не замена скриншоту:
свёрнутое окно Windows не рисует вообще.
Вторая причина обрезания, найденная там же, — кнопка **Continue**: DeepSeek
режет длинный ответ по своему лимиту и предлагает дописать, а генерация при
этом честно завершается. Теперь `waitForAnswer` жмёт её и ждёт дальше;
продолжение уходит в тот же блок ответа. Сквозная проверка — ответ на 47 414
символов с целыми кодом и тестами.
Обе правки в `ollama_orchestro`, deepwork получает их даром — мост вызывается
подпроцессом.
+35
View File
@@ -0,0 +1,35 @@
# Приставка к первому шагу цикла
Едет в самое начало первого сообщения, перед файлами и вопросом. Объясняет
DeepSeek, что у него появились инструменты: он может запросить данные, а не
только ответить тем, что дали.
Всё, что ниже разделителя, уходит в чат дословно.
---
ИНСТРУМЕНТЫ. Между тобой и рабочей папкой стоит программа. Если данных не
хватает — не гадай и не проси человека прислать файл. Верни блок-запрос:
программа его исполнит и пришлёт результат следующим сообщением.
=== NEED: путь/файл.js, другой/файл.css ===
прислать содержимое этих файлов целиком
=== LIST: **/*.mjs ===
список файлов по маске (`**` — любые папки, `*` — часть имени)
=== FIND: waitForAnswer ===
строки, где встречается этот текст, с путями и номерами строк
Правила, соблюдать строго — блоки разбирает программа, не человек:
- блок пишется ОТДЕЛЬНОЙ строкой, целиком, без отступов и без кавычек;
- один круг занимает до минуты, поэтому запрашивай ВСЁ нужное сразу —
несколькими блоками в одном сообщении, а не по одному за раз;
- пути — от корня рабочей папки, ровно в том виде, в каком ты их видел;
- запросов нет — отвечай по существу и блоки не выдумывай;
- получив данные, сразу отвечай на вопрос: не благодари, не пересказывай
присланное и не описывай, что собираешься сделать;
- шагов у цикла мало, растягивать нельзя. Хватает данных — отвечай.
Правки файлов возвращай блоками `=== FILE: путь ===`, как описано выше.
+6
View File
@@ -0,0 +1,6 @@
{
"bridge": "C:\\#Projects\\ollama_orchestro\\ask.mjs",
"chat": "think",
"steps": 4,
"timeout": 240
}
+569
View File
@@ -0,0 +1,569 @@
#!/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);
}
}
+63
View File
@@ -0,0 +1,63 @@
@echo off
setlocal
REM ============================================================
REM deepwork web console. Drag and drop a project folder onto
REM this file: DeepSeek gets tools (NEED / LIST / FIND) and can
REM read that folder himself, step by step.
REM ASCII only on purpose - cmd.exe mangles UTF-8 Cyrillic.
REM ============================================================
echo ========================================
echo deepwork - DeepSeek with tools
echo ========================================
echo.
REM %~dp0 ALWAYS ends with a backslash. A bare "%VAR%" argument built from it
REM would escape its own closing quote - that bug already ate a flag once in
REM ollama_orchestro. Keep the trimmed copy for whole-path arguments.
set "DW_DIR=%~dp0"
set "DW_ROOT=%DW_DIR:~0,-1%"
if "%~1"=="" (
echo [ERROR] No project folder specified!
echo Drag and drop a project folder onto this file.
echo.
pause
exit /b 1
)
if not exist "%~1" (
echo [ERROR] Folder "%~1" does not exist!
echo.
pause
exit /b 1
)
echo [OK] Project folder: %~1
where node >nul 2>&1
if errorlevel 1 (
echo [ERROR] Node.js not found in PATH!
echo.
pause
exit /b 1
)
echo [OK] Node.js found
powershell -NoProfile -Command "try { Invoke-RestMethod -Uri 'http://127.0.0.1:9333/json/version' -TimeoutSec 2 | Out-Null; exit 0 } catch { exit 1 }" >nul 2>&1
if errorlevel 1 (
echo [WARN] Bridge is NOT available on port 9333.
echo Run start-browser.cmd in ollama_orchestro, then log in.
echo The console will start anyway and show the same warning.
echo.
)
cd /d "%~1"
echo.
echo Open the printed address in your DEFAULT browser, not the debug one.
echo Ctrl+C here stops it.
echo.
node "%DW_DIR%deepwork.mjs" ui
echo.
pause
+159
View File
@@ -0,0 +1,159 @@
<!doctype html>
<meta charset="utf-8">
<title>deepwork</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; display: grid; grid-template-columns: 320px 1fr;
font: 14px/1.45 system-ui, "Segoe UI", sans-serif; background: #14161a; color: #e6e8ec; }
aside { border-right: 1px solid #262a31; display: flex; flex-direction: column; min-height: 0; }
main { display: flex; flex-direction: column; min-height: 0; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #8b93a1;
margin: 0; padding: 12px; border-bottom: 1px solid #262a31; }
.scroll { overflow: auto; flex: 1; padding: 8px 12px; min-height: 0; }
label.f { display: flex; gap: 8px; align-items: baseline; padding: 2px 0; cursor: pointer; }
label.f span { color: #6c7381; font-size: 12px; margin-left: auto; }
.row { display: flex; gap: 8px; align-items: center; padding: 10px 12px; border-top: 1px solid #262a31; }
.muted { color: #8b93a1; font-size: 12px; }
input[type=text], textarea, select { background: #1b1e24; color: #e6e8ec; border: 1px solid #333844;
border-radius: 6px; padding: 8px; font: inherit; width: 100%; }
textarea { resize: vertical; min-height: 84px; }
button { background: #262b34; color: #e6e8ec; border: 1px solid #3a414f; border-radius: 6px;
padding: 7px 14px; font: inherit; cursor: pointer; }
button:hover:not(:disabled) { background: #2f3540; }
button.primary { background: #3b6ea5; border-color: #4a83c0; }
button:disabled { opacity: .5; cursor: default; }
pre { background: #1b1e24; border: 1px solid #262a31; border-radius: 6px; padding: 10px;
white-space: pre-wrap; word-break: break-word; margin: 0 0 12px; }
.step { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #9fd08b; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #7a4; display: inline-block; }
.dot.bad { background: #c55; }
.patch { border: 1px solid #333844; border-radius: 6px; margin-bottom: 10px; }
.patch > .row { border-top: none; }
.patch pre { margin: 0 12px 12px; max-height: 300px; overflow: auto; }
</style>
<aside>
<h2>Файлы <span class="muted" id="root"></span></h2>
<div class="row"><input type="text" id="filter" placeholder="фильтр по имени"></div>
<div class="scroll" id="tree">загружаю…</div>
<div class="row"><button id="none">Снять все</button><span class="muted" id="sel">ничего не выбрано</span></div>
</aside>
<main>
<h2><span class="dot" id="dot"></span> <span id="bar">проверяю мост…</span></h2>
<div style="padding:12px">
<textarea id="q" placeholder="Например: почему ответ приходит обрезанным? Дай исправленную функцию целиком."></textarea>
</div>
<div class="row">
<button class="primary" id="send">Спросить</button>
<label class="muted">шагов <select id="steps" style="width:auto">
<option>2</option><option selected>4</option><option>6</option><option>8</option></select></label>
<span class="muted" id="status"></span>
</div>
<div class="scroll" id="out"></div>
</main>
<script>
let files = [];
const $ = id => document.getElementById(id);
const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const chosen = () => [...document.querySelectorAll('#tree input:checked')].map(i => i.value);
function renderTree() {
const needle = $('filter').value.toLowerCase();
const keep = new Set(chosen());
$('tree').innerHTML = files
.filter(f => !needle || f.path.toLowerCase().includes(needle))
.map(f => `<label class="f"><input type="checkbox" value="${esc(f.path)}"${keep.has(f.path) ? ' checked' : ''}>
${esc(f.path)}<span>${Math.max(1, Math.round(f.size / 1024))} КБ</span></label>`)
.join('') || '<div class="muted">ничего не подошло</div>';
count();
}
function count() {
const c = chosen();
const kb = files.filter(f => c.includes(f.path)).reduce((s, f) => s + f.size, 0) / 1024;
$('sel').textContent = c.length ? `${c.length} шт., ${Math.round(kb)} КБ` : 'ничего не выбрано';
}
$('tree').addEventListener('change', count);
$('filter').addEventListener('input', renderTree);
$('none').onclick = () => { document.querySelectorAll('#tree input').forEach(i => i.checked = false); count(); };
fetch('/api/tree').then(r => r.json()).then(d => {
files = d.files; $('root').textContent = d.root; renderTree();
});
async function status() {
try {
const s = await fetch('/api/status').then(r => r.json());
$('dot').className = 'dot' + (s.deepseek ? '' : ' bad');
$('bar').textContent = s.deepseek ? 'мост на месте, вкладка DeepSeek открыта'
: s.browser ? 'браузер есть, но вкладки DeepSeek нет' : 'браузер с портом 9333 не найден — запустите start-browser.cmd';
} catch { $('dot').className = 'dot bad'; $('bar').textContent = 'пульт не отвечает'; }
}
status(); setInterval(status, 5000);
// Пока идёт прогон, страница не должна молчать: круг занимает до 4 минут,
// и без счётчика непонятно, работает ли что-то вообще.
let poll = null;
function watch() {
poll = setInterval(async () => {
const p = await fetch('/api/progress').then(r => r.json()).catch(() => null);
if (!p) return;
$('status').textContent = `идёт ${p.seconds} с`;
$('out').innerHTML = '<pre>' + p.steps.map(s => `<div class="step">${esc(s)}</div>`).join('') + '</pre>';
}, 2000);
}
$('send').onclick = async () => {
const question = $('q').value.trim();
if (!question) return;
$('send').disabled = true;
$('out').innerHTML = '<pre class="muted">отправляю…</pre>';
watch();
try {
const r = await fetch('/api/ask', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, files: chosen(), steps: +$('steps').value }),
});
const d = await r.json();
clearInterval(poll);
if (d.error) { $('out').innerHTML = `<pre style="color:#e88">${esc(d.error)}</pre>`; return; }
render(d);
} catch (e) {
clearInterval(poll);
$('out').innerHTML = `<pre style="color:#e88">${esc(String(e))}</pre>`;
} finally { $('send').disabled = false; $('status').textContent = ''; }
};
function render(d) {
const steps = d.steps.map(s => `<div class="step">${esc(s)}</div>`).join('');
const patches = d.patches.map((p, i) => `
<div class="patch">
<div class="row"><b>${esc(p.path)}</b>
<span class="muted">${p.content.split('\n').length} строк</span>
<button data-i="${i}" style="margin-left:auto">Записать</button>
<span class="muted" id="note${i}"></span></div>
<pre>${esc(p.content.slice(0, 4000))}${p.content.length > 4000 ? '\n…' : ''}</pre>
</div>`).join('');
$('out').innerHTML = `<pre>${steps}</pre>
<h2 style="border:none;padding:0 0 8px">Ответ</h2>
<pre>${esc(d.answer)}</pre>
${d.patches.length ? `<h2 style="border:none;padding:0 0 8px">Файлы в ответе</h2>${patches}` : ''}`;
$('out').querySelectorAll('.patch button').forEach(b => b.onclick = async () => {
const p = d.patches[+b.dataset.i];
b.disabled = true;
const r = await fetch('/api/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: p.path, content: p.content }),
}).then(x => x.json());
document.getElementById('note' + b.dataset.i).textContent =
r.ok ? (r.backup ? `записан, копия: ${r.backup}` : 'записан (новый файл)') : `не записан: ${r.reason}`;
});
}
</script>