From 97eefb57c7f71414974296df978b95cddc9cd056 Mon Sep 17 00:00:00 2001 From: Alex Cube Date: Tue, 11 Aug 2026 16:31:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F:=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D1=86?= =?UTF-8?q?=D0=B8=D0=BA=D0=BB=20=D0=B4=D0=BB=D1=8F=20DeepSeek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 6 + README.md | 170 +++++++++++++ agent-prompt.md | 35 +++ config.example.json | 6 + deepwork.mjs | 569 ++++++++++++++++++++++++++++++++++++++++++++ start-deepwork.cmd | 63 +++++ ui.html | 159 +++++++++++++ 7 files changed, 1008 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent-prompt.md create mode 100644 config.example.json create mode 100644 deepwork.mjs create mode 100644 start-deepwork.cmd create mode 100644 ui.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03db4d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +backup/ +config.json +node_modules/ +*.log +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f475595 --- /dev/null +++ b/README.md @@ -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 получает их даром — мост вызывается +подпроцессом. diff --git a/agent-prompt.md b/agent-prompt.md new file mode 100644 index 0000000..070634d --- /dev/null +++ b/agent-prompt.md @@ -0,0 +1,35 @@ +# Приставка к первому шагу цикла + +Едет в самое начало первого сообщения, перед файлами и вопросом. Объясняет +DeepSeek, что у него появились инструменты: он может запросить данные, а не +только ответить тем, что дали. + +Всё, что ниже разделителя, уходит в чат дословно. + +--- + +ИНСТРУМЕНТЫ. Между тобой и рабочей папкой стоит программа. Если данных не +хватает — не гадай и не проси человека прислать файл. Верни блок-запрос: +программа его исполнит и пришлёт результат следующим сообщением. + +=== NEED: путь/файл.js, другой/файл.css === + прислать содержимое этих файлов целиком + +=== LIST: **/*.mjs === + список файлов по маске (`**` — любые папки, `*` — часть имени) + +=== FIND: waitForAnswer === + строки, где встречается этот текст, с путями и номерами строк + +Правила, соблюдать строго — блоки разбирает программа, не человек: + +- блок пишется ОТДЕЛЬНОЙ строкой, целиком, без отступов и без кавычек; +- один круг занимает до минуты, поэтому запрашивай ВСЁ нужное сразу — + несколькими блоками в одном сообщении, а не по одному за раз; +- пути — от корня рабочей папки, ровно в том виде, в каком ты их видел; +- запросов нет — отвечай по существу и блоки не выдумывай; +- получив данные, сразу отвечай на вопрос: не благодари, не пересказывай + присланное и не описывай, что собираешься сделать; +- шагов у цикла мало, растягивать нельзя. Хватает данных — отвечай. + +Правки файлов возвращай блоками `=== FILE: путь ===`, как описано выше. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..b31d655 --- /dev/null +++ b/config.example.json @@ -0,0 +1,6 @@ +{ + "bridge": "C:\\#Projects\\ollama_orchestro\\ask.mjs", + "chat": "think", + "steps": 4, + "timeout": 240 +} diff --git a/deepwork.mjs b/deepwork.mjs new file mode 100644 index 0000000..82dccf8 --- /dev/null +++ b/deepwork.mjs @@ -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); + } +} diff --git a/start-deepwork.cmd b/start-deepwork.cmd new file mode 100644 index 0000000..f5553d2 --- /dev/null +++ b/start-deepwork.cmd @@ -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 diff --git a/ui.html b/ui.html new file mode 100644 index 0000000..86ebf0b --- /dev/null +++ b/ui.html @@ -0,0 +1,159 @@ + + +deepwork + + + + +
+

проверяю мост…

+
+ +
+
+ + + +
+
+
+ +