DeepSeek через deepseek-api; память шагов переехала из чата в стенограмму

Транспорт: был дочерний процесс ask.mjs из ollama_orchestro с обменом через
временные файлы, стал POST /api/chat к сервису deepseek-api. Он держит вкладку
браузера и отдаёт наружу протокол Ollama, поэтому здесь про браузер больше
не знают: ушли проверка существования моста, временные файлы шагов вместе
с флагом --keep-temp и четыре осиротевших импорта.

Главное не в транспорте. Раньше шаги 2..N уходили с флагом --new, и ход
разговора помнил сам чат DeepSeek. Сервис памяти не имеет принципиально:
каждый запрос он кладёт в чат целиком, затирая прошлый. Поэтому стенограмму
ведём здесь — growTranscript подклеивает к отправленному тексту прошлый ответ
модели между метками === ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ === и === END ===, и следующим
шагом уезжает всё сразу.

Так надёжнее, чем было: видно в одном месте, что именно уедет модели, а чужой
потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
приносим заново. Плата — вопрос растёт на длину прошлого ответа.

--check теперь различает три состояния: сервис отвечает и браузер поднят;
сервис жив, но браузера ещё нет; сервис не запущен. Плашка в ui.html тоже.

Настройки: bridge/chat/cdpPort заменены на deepseekHost и deepseekModel.

Проверено живым многошаговым прогоном: шаг 1 — 2094 символа, ответ
NEED: deepwork.mjs; шаг 2 — 28 447 символов стенограммы, ответ дословно верный
и по вопросу из ПЕРВОГО сообщения, то есть память шагов работает. Два шага
за 17 с. --selftest пройден, --check зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 13:40:09 +03:00
co-authored by Claude Opus 5
parent 3586e32a89
commit 44f07c8af9
4 changed files with 126 additions and 89 deletions
+87 -64
View File
@@ -1,22 +1,18 @@
#!/usr/bin/env node
// deepwork — цикл с инструментами поверх моста ollama_orchestro/ask.mjs.
// deepwork — цикл с инструментами поверх сервиса deepseek-api.
//
// DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он
// получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND),
// программа его исполняет и досылает результат следующим сообщением в тот же
// чат. Получается обычный агентский цикл, только транспорт — текст.
//
// Мост НЕ импортируется и НЕ копируется: ask.mjs разбирает process.argv и
// исполняет команду на верхнем уровне, поэтому import немедленно запустил бы
// её. Вызываем его подпроцессом теми флагами, которые у него уже есть.
// ollama_orchestro при этом не меняется ни на строку.
// С DeepSeek говорим по HTTP: у веб-версии API нет, но рядом крутится сервис
// deepseek-api, который держит вкладку браузера и отдаёт наружу протокол
// Ollama. Здесь про браузер не знают вовсе.
import { readFile, writeFile, mkdir, copyFile, readdir, stat, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { readFile, writeFile, mkdir, copyFile, readdir, stat } from 'node:fs/promises';
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -50,7 +46,6 @@ deepwork — задать DeepSeek вопрос и дать ему дочита
(прежняя версия уезжает в backup/)
--chat роль think (по умолчанию) | search
--timeout сек максимум бездействия страницы, передаётся мосту
--keep-temp не удалять временные файлы шагов
--check проверить, что мост на месте и вкладка жива
--selftest прогнать внутренние проверки, ничего не отправляя
@@ -61,18 +56,21 @@ deepwork — задать DeepSeek вопрос и дать ему дочита
=== LIST: **/*.mjs === список файлов по маске
=== FIND: текст === где встречается текст
Мост берётся из config.json (ключ bridge), по умолчанию —
../ollama_orchestro/ask.mjs рядом с этой папкой.
Адрес сервиса берётся из config.json (ключ deepseekHost), по умолчанию —
http://localhost:11500. Сервис запускается своим start.cmd.
`.trim();
// ------------------------------------------------------------------ настройки
const CONFIG_DEFAULTS = {
bridge: resolve(HERE, '..', 'ollama_orchestro', 'ask.mjs'),
chat: 'think',
// У веб-версии DeepSeek нет API. Рядом крутится отдельный сервис deepseek-api
// (git.08h.ru/alex/deepseek-api): он держит вкладку браузера и отдаёт наружу
// протокол Ollama. Запускается своим start.cmd, браузер поднимает сам.
deepseekHost: 'http://localhost:11500',
deepseekModel: 'deepseek:think', // think | search | vision — режим чата
steps: 4,
timeout: 240,
cdpPort: 9333, // отладочный порт браузера — только чтобы показать, жив ли мост
timeout: 240, // секунд на один шаг; сверх этого сервису дают ещё две минуты
// на очередь: вкладка одна на всех потребителей
uiPort: 8787, // не 8777: там живёт пульт orchestro, оба могут работать разом
};
@@ -238,42 +236,50 @@ async function runRequests(root, requests, log) {
return parts.filter(Boolean).join('\n\n');
}
// ------------------------------------------------------------------ мост
// ------------------------------------------------------------------ DeepSeek
// Один шаг: текст уходит через --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)'}, жду мост…`);
// Один шаг: вопрос уходит по HTTP, ответ приходит там же. Раньше здесь был
// дочерний процесс с обменом через временные файлы — разговор с вкладкой
// браузера уехал в deepseek-api, а нам он показывает протокол Ollama.
async function callDeepseek(cfg, text, { n, log }) {
log(`шаг ${n}: ${text.length} символов, спрашиваю DeepSeek…`);
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');
let r;
try {
r = await fetch(`${cfg.deepseekHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: cfg.deepseekModel,
stream: false, // иначе придёт NDJSON, а нам нужен объект
messages: [{ role: 'user', content: text }],
}),
// Свой срок больше сервисного: там таймаут считает БЕЗДЕЙСТВИЕ страницы,
// а сюда добавляется ещё и очередь — вкладка одна на всех потребителей.
signal: AbortSignal.timeout((cfg.timeout + 120) * 1000),
});
} catch (e) {
throw new Error(
`deepseek-api не отвечает по адресу ${cfg.deepseekHost}: ${e.message}\n` +
'Запустите start.cmd в папке deepseek-api — браузер он поднимет сам.');
}
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
`deepseek-api ответил ${r.status}: ${data.error || 'без пояснений'}\n` +
`Что видно во вкладке: curl ${cfg.deepseekHost}/api/probe`);
}
const answer = data.message?.content ?? '';
log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`);
// Мост сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки
// Сервис сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки
// там не бесконечны. Если такой текст всё-таки доехал — это не ответ, и
// разбирать его как ответ нельзя: в блоках-запросах и правках его нет,
// а цикл решил бы, что запросов не поступило, и молча закончил работу.
if (isServerBusy(answer)) throw new Error(
'DeepSeek занят: «Server is busy». Мост исчерпал повторы — попробуйте позже.');
'DeepSeek занят: «Server is busy». Повторы исчерпаны — попробуйте позже.');
return answer;
}
@@ -337,16 +343,27 @@ const FOLLOW_HEAD = [
'',
].join('\n');
async function runAgent(cfg, root, question, files, log) {
const temp = join(tmpdir(), `deepwork-${process.pid}`);
await mkdir(temp, { recursive: true });
// Ход разговора ведём здесь, а не в чате DeepSeek. Сервис памяти не имеет:
// каждый запрос он кладёт в чат целиком, затирая прошлый. Раньше эту роль играл
// флаг --new, и помнил всё сам DeepSeek; теперь стенограмма растёт у нас.
//
// Так даже надёжнее: видно в одном месте, что именно уедет модели, и чужой
// потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
// приносим заново. Плата — вопрос растёт с каждым шагом на длину прошлого
// ответа; при steps=4 это единицы килобайт против 22 КБ первого сообщения.
function growTranscript(prev, answer, next) {
return [prev, '',
'=== ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ ===', answer.trim(), '=== END ===', '',
next].join('\n');
}
async function runAgent(cfg, root, question, files, log) {
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 });
answer = await callDeepseek(cfg, text, { n, log });
const requests = parseRequests(answer);
if (!requests.length) { log(`шаг ${n}: запросов нет, готово`); break; }
@@ -360,11 +377,9 @@ async function runAgent(cfg, root, question, files, log) {
log(`шаг ${n}: запрошено ${requests.length}`);
const result = await runRequests(root, requests, log);
text = FOLLOW_HEAD + result;
text = growTranscript(text, answer, FOLLOW_HEAD + result);
}
if (!flags['keep-temp']) await rm(temp, { recursive: true, force: true }).catch(() => {});
else log(`временные файлы: ${temp}`);
return answer;
}
@@ -426,11 +441,14 @@ async function serve(cfg) {
return json(res, 200, { root, files: await listFiles(root) });
if (req.method === 'GET' && url.pathname === '/api/status') {
// 200 от /api/tags значит «браузер поднят и вкладка на месте»; 503 —
// сервис жив, но браузера ещё нет (поднимется на первом же вопросе);
// отказ соединения — сервис не запущен вовсе. Три состояния, и плашка
// в ui.html должна их различать.
try {
const r = await fetch(`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 }); }
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(2500) });
return json(res, 200, { service: true, deepseek: r.ok });
} catch { return json(res, 200, { service: false, deepseek: false }); }
}
if (req.method === 'GET' && url.pathname === '/api/progress')
@@ -533,22 +551,27 @@ async function selftest() {
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.chat) cfg.deepseekModel = 'deepseek:' + 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));
// Три состояния, и путать их нельзя: 200 — всё готово; 503 — сервис жив,
// но браузер ещё не поднят (встанет на первом вопросе); отказ соединения —
// сервис не запущен вовсе.
console.log(`сервис: ${cfg.deepseekHost}`);
try {
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(3000) });
console.log(r.ok ? 'готов: браузер поднят, вкладка DeepSeek открыта'
: `не готов (HTTP ${r.status}): браузер поднимется на первом вопросе`);
process.exit(r.ok ? 0 : 1);
} catch (e) {
console.error(`не отвечает: ${e.message}`);
console.error('Запустите start.cmd в папке deepseek-api.');
process.exit(1);
}
} else {
const question = positional.join(' ');
if (!question.trim()) { console.log(USAGE); process.exit(question ? 1 : 0); }