Как я написал свой Claude Code на DeepSeek за вечер

от автора

# Зачем это всё
Claude Code — терминальный AI-ассистент к которому захотелось прикрутить Дипсик, но есть маленькая Проблема — он привязан к API Anthropic.

Естественно захотелось запилить свой велосипед с черным CMD и командами обеспечивающие ключевые концепции: tool use, permissions, memory, compaction, subagents — но с нуля, на чистом Node.js.Результат — deepseek-agent: ~2000 строк кода, 4 зависимости (openai, fast-glob, dotenv, @modelcontextprotocol/sdk), никаких фреймворков.Казалось бы, не столько сложно запилить своего агента, но есть нюансы.И так поехали

С ходу пилим такую структуру:«index.js — точка входа, REPLsrc/agent.js — agent loopsrc/config.js — .agent/settings.jsonsrc/memory.js — AGENT.md → system promptsrc/permissions.js — alwaysAllow / neverAllow / [y/N]src/hooks.js — PreToolUse / PostToolUse событияsrc/compactor.js — автосжатие контекста через LLMsrc/mcp.js — подключение MCP-серверовsrc/thinking.js — deepseek-reasoner (--think)src/worktree.js — git worktree изоляцияsrc/output.js — JSON-режим для CIsrc/ui.js — ANSI-цветаsrc/tools/ — 9 инструментов`Ключевое решение: каждый инструмент — это объект с фиксированной структурой:`js{ name: "read_file", description: "Read the contents of a text file.", parameters: { /* JSON Schema / }, isReadOnly: true, // false = нужно разрешение async execute(args) { return "результат строкой" }}`

Добавить инструмент = написать объект и вставить его в массив TOOLS. Маршрутизация, JSON Schema для API, хуки, разрешения — всё подхватывается автоматически.но чего-то не хватает, давай добавим сессии, команды, документациюеще три коммита.Вынес систему команд (/clear, /compact, /diff, /review, ...) в отдельный commands.js. Добавил session.js — сессии с чекпоинтами. Переписал README.Здесь появилась важная абстракция: *команды и инструменты — разные вещи**. Команды (/clear, /rewind) — для пользователя. Инструменты (read_file, bash) — для модели. Команды могут вызывать agentLoop(), но не наоборот.Зарегистрировал глобальную команду agent через npm link и поле bin в package.json. Теперь вместо npm start — просто agent из любой директории.Казалось бы все хорошо, но конечно же нет (а как ты хотел) Еще пол дня на полировку проекта.Каждый коммит — конкретная проблема, вроде мелочи, а сильно портят картину.---## Архитектура### Agent Loop — сердце агентаВсё строится вокруг одного цикла в agent.js:`agentLoop(userMessage) ├─ pushMessage({ role: "user", content: userMessage }) └─ while(true) ├─ compactIfNeeded() — сжать контекст если > 80% лимита ├─ chat.completions.create({ stream: true }) │ ├─ собрать fullContent (текст ответа) │ └─ собрать toolCalls (вызовы инструментов) ├─ finish_reason === "stop" → return └─ finish_reason === "tool_calls" └─ для каждого вызова: ├─ PreToolUse hook ├─ checkPermission() ├─ tool.execute(args) ├─ PostToolUse hook └─ pushMessage({ role: "tool", result })`Модель сама решает, какой инструмент вызвать. Агент выполняет вызов и возвращает результат обратно в контекст. Цикл крутится, пока модель не ответит stop.DeepSeek API совместим с OpenAI — используется пакет openai с кастомным baseURL:`jsconst client = new OpenAI({ baseURL: "https://api.deepseek.com", apiKey: process.env.DEEPSEEK_API_KEY})`### Стриминг: собираем tool_calls из дельтПри стриминге tool_calls приходят по частям. Имя функции и аргументы дробятся на чанки:`jsfor await (const chunk of stream) { if (delta?.tool_calls) { for (const tc of delta.tool_calls) { if (!toolCalls[tc.index]) { toolCalls[tc.index] = { id: "", type: "function", function: { name: "", arguments: "" } } } if (tc.id) toolCalls[tc.index].id += tc.id if (tc.function?.name) toolCalls[tc.index].function.name += tc.function.name if (tc.function?.arguments) toolCalls[tc.index].function.arguments += tc.function.arguments } }}`Ключевой момент: tc.index определяет, к какому tool_call относится дельта. Без этого нельзя корректно обработать параллельные вызовы.---## Инструменты: 9 штук, каждый — один файл### read_file — чтение с определением кодировкиНе просто fs.readFile. Определяем кодировку по BOM:`jsif (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) { return buf.slice(3).toString("utf-8") // UTF-8 BOM}if (buf[0] === 0xFF && buf[1] === 0xFE) { return buf.slice(2).toString("utf16le") // UTF-16 LE}`Бинарные файлы блокируются по расширению — без этого модель радостно пытается «прочитать» .png и .exe, тратя токены на мусор.### bash — песочницаБлокируем опасные паттерны по умолчанию:`jsconst SANDBOX_BLOCKED = [ /\bcurl\b/, /\bwget\b/, // сеть /\brm\s+-rf\s+\//, // деструктивные операции /\bsudo\b/, /\bsu\b/ // привилегии]`На Windows переключаем кодовую страницу в UTF-8 перед каждой командой:`jsconst cmd = process.platform === "win32" ? chcp 65001 >nul 2>&1 & ${command} : command`Вывод обрезается до 8000 символов — без этого один cat на большой файл съест весь контекст.### edit_file — точная замена строкВместо line-based diff — exact string replacement. Модель передаёт old_string и new_string. Если строка встречается больше одного раза — ошибка:`jsconst count = original.split(old_string).length - 1if (count > 1) { return Error: old_string found ${count} times — make it more specific}`Красивый diff с ANSI-подсветкой — удалённые строки на тёмно-красном фоне, добавленные на зелёном, с 3 строками контекста.### web_search — DuckDuckGo без API ключаПарсим HTML DuckDuckGo напрямую — никакого API ключа не нужно:`jsconst url = https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}const html = await fetch(url).then(r => r.text())// Извлекаем результаты регуляркойconst resultRegex = /<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>...`### task — субагентыРекурсивный вызов agentLoop() — субагент получает свой контекст и работает независимо:`js// Параллельноconst results = await Promise.all( parallel.map(desc => agentLoop(desc)))// Фоновоconst entry = { done: false, result: null }entry.promise = agentLoop(description).then(result => { entry.done = true entry.result = result})`Для инициализации используется инъекция: initTaskTool(agentLoop). Это решает проблему циклической зависимости — task.js не импортирует agent.js.### todo — задачи с зависимостямиВнутрисессионный трекер задач. Поддерживает blockedBy — задача не может перейти в in_progress, пока зависимости не завершены:`jsif (status === "in_progress" && isBlocked(todo)) { const blocking = todo.blockedBy.filter( depId => getTodo(depId)?.status !== "done" ) return Cannot start #${id} — blocked by: ${blocking.join(«, «)}}`---## Система разрешенийТри уровня:1. alwaysAllow — выполняется без вопросов (read_file, glob, grep)2. neverAllow — заблокировано навсегда3. Интерактивный запрос — для всего остальногоДля файловых операций — запрос на уровне директории:`┌ [?] write_file → src/utils.js└ [y] один раз [d] запомнить папку "src" [N] отклонить:`Нажал d — папка сохраняется в .agent/settings.json. Следующий раз не спросит.Для bash — запрос на уровне инструмента:`┌ [?] bash: {"command":"npm test"}└ [y] один раз [a] запомнить для проекта [N] отклонить:`Нажал a — bash добавляется в alwaysAllow в конфиге.---## Компактор: бесконечный контекст через суммаризациюПроблема: у DeepSeek контекстное окно ограничено. После 10–15 ходов контекст переполняется.Решение: перед каждым запросом к API проверяем размер контекста. Если > 80% лимита — суммаризируем всю историю через ту же модель:`jsif (!force && tokens < contextLimit 0.8) return messages// Оставляем system prompt, суммаризируем остальноеconst summaryResponse = await client.chat.completions.create({ model: getModel(), messages: [ { role: "system", content: "Summarize the conversation..." }, { role: "user", content: rest.map(m => [${m.role}]: ${m.content}).join("\n") } ]})return [system, { role: "user", content: [Summary]:\n${summary} }, { role: "assistant", content: "Understood." }]`Оценка токенов — грубая, но работает: ~3 символа = 1 токен. Base64-изображения считаются по длине строки.---## MCP — подключай чужие инструментыModel Context Protocol — стандарт от Anthropic для подключения внешних инструментов. Конфиг в .agent/settings.json:`json"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] }}`При старте агент подключается к серверу через stdio, получает список инструментов и регистрирует их с префиксом mcp__<server>__<tool>:`jsconst transport = new StdioClientTransport({ command: cfg.command, args: cfg.args ?? []})const client = new Client({ name: "deepseek-agent", version: "0.1.0" })await client.connect(transport)const { tools: serverTools } = await client.listTools()`Инструменты MCP проходят через ту же систему разрешений.---## Память: AGENT.mdТри уровня памяти, все загружаются в system prompt:| Файл | Назначение ||---|---|| ~/.agent/AGENT.md | Глобальные инструкции (стиль, предпочтения) || .agent/AGENT.md | Инструкции для проекта (архитектура, стек) || AGENT.md | Инструкции в корне репо |Это аналог CLAUDE.md в Claude Code. Модель видит эти инструкции в каждом диалоге.---## Хуки: интеграция со своими скриптами.agent/hooks.json позволяет запускать shell-команды на события агента:`json{ "PreToolUse": [{ "command": "cat >> agent.log" }], "PostToolUse": [], "Stop": []}`PreToolUse с ненулевым exit code блокирует выполнение инструмента. Payload приходит через stdin как JSON — можно фильтровать по имени инструмента, аргументам.---## Слеш-команды: 17 штукВсё управление — через /-команды в чате:- /clear — сбросить контекст- /compact — принудительно сжать контекст- /context — прогресс-бар заполненности контекста- /btw <вопрос> — вопрос без добавления в историю- /rewind — откат к чекпоинту (автоматически создаются каждый ход)- /review — отправить git diff на ревью- /security-review — анализ безопасности- /simplify — три параллельных агента: DRY, качество, производительность- /batch <задача> — агент декомпозирует задачу и выполняет параллельно- /loop 5m <промпт> — периодический запуск (аналог cron)- /resume — восстановить предыдущую сессию- /export — сохранить диалог в файл/simplify — пример мощи субагентов. Три агента запускаются параллельно через Promise.all, каждый анализирует изменённые файлы под своим углом:`jsconst tasks = [ "Review for code reuse opportunities and DRY violations...", "Review for code quality: naming, complexity, readability...", "Review for performance and efficiency issues..."]await taskTool.execute({ parallel: tasks })`---## Проблемы, которые пришлось решать### Прожорливость по токенамКоммит fa04583: модель читала файлы целиком и вставляла их в контекст. Решение — обрезка результатов инструментов:`jsconst CONTEXT_LIMIT = 12000const toolContent = full.length > CONTEXT_LIMIT ? full.slice(0, CONTEXT_LIMIT) + \n[… truncated, ${full.length — CONTEXT_LIMIT} chars omitted] : full`Вывод bash тоже ограничен: 8000 символов.### Бинарные файлыКоммит 56116a1: модель лезла в .exe, .png, .zip без спроса. Добавил блокировку бинарных расширений в read_file и исключения бинарников в grep.### Windows: кодировкаКоммит a5193ac: на Windows stdout по умолчанию использует cp1251. Русский текст превращался в кракозябры. Решение: chcp 65001 перед каждой командой bash и BOM-детекция в read_file.### Файлы валятся в консольКоммит 499a9ff: когда модель читала файл, его содержимое выводилось в терминал целиком. Добавил formatToolResult() — для read_file выводит только «42 строки, 1200 символов», а не весь файл.### Разрешение на папкуКоммит 028d4e4: при первой записи в файл агент спрашивал разрешение. При второй — снова. Добавил механизм approvedDirs — одобряешь папку, и все файлы в ней пишутся без вопросов.### Персистентность сессийКоммит 86e21e0: при закрытии терминала вся история терялась. Добавил автосохранение в .agent/session.json при выходе и /resume для восстановления.---## Что под капотом: зависимостиВсего 4 пакета:| Пакет | Зачем ||---|---|| openai | Клиент DeepSeek API (совместим с OpenAI) || fast-glob | Поиск файлов по паттернам в glob и grep || dotenv | Загрузка .env || @modelcontextprotocol/sdk | Клиент MCP |Никаких chalk, inquirer, commander, yargs. ANSI-цвета — 6 строк. CLI-парсинг — process.argv.slice(2). readline — встроенный node:readline.---## Переключение моделейБлагодаря OpenAI-совместимому API, агент работает не только с DeepSeek:`DeepSeek baseURL: https://api.deepseek.com model: deepseek-chatOpenAI без baseURL model: gpt-4oOllama baseURL: http://localhost:11434/v1 model: qwen2.5-coderGroq baseURL: https://api.groq.com/openai/v1 model: llama-3.3-70b-versatile`Меняешь baseURL в коде и model в конфиге — готово.---## Режим extended thinkingФлаг —think переключает на deepseek-reasoner. Эта модель возвращает reasoning_content отдельно от ответа — внутренний chain-of-thought:`jsexport function printReasoning(chunk) { const delta = chunk.choices[0]?.delta if (delta?.reasoning_content) { process.stdout.write(c.dim(delta.reasoning_content)) return true } return false}`В терминале reasoning выводится приглушённым цветом между маркерами [thinking]/[/thinking].---## JSON-режим для CI`bashagent --output-format=json "что делает index.js?"`Каждое событие — отдельная JSON-строка:`json{ "type": "text", "text": "фрагмент ответа" }{ "type": "tool_call", "tool": "bash", "args": { "command": "ls" } }{ "type": "tool_result", "tool": "bash", "result": "file1.js" }`Обычный вывод подавляется — функция print() ничего не делает в JSON-режиме:`jsexport function print(text) { if (_format !== "json") process.stdout.write(text)}`---## Итоги*Что получилось:**- ~2000 строк JavaScript (ES modules)- 27 коммитов за неделю- 4 зависимости, ноль фреймворков- 9 инструментов + MCP для расширения- Система разрешений с персистентностью- Автокомпакция контекста- Субагенты (синхронные, параллельные, фоновые)- 17 слеш-команд- Хуки, память, сессии, git worktree- Работает на Windows, Linux, macOS**Что я вынес:**1. OpenAI SDK — универсальный клиент. DeepSeek, Groq, Ollama — все говорят на одном протоколе. Один пакет покрывает всех.2. Tool use — это просто. JSON Schema описывает параметры, модель сама решает когда вызывать. Не нужно парсить текст, искать команды в ответе — API всё делает.3. Стриминг tool_calls — единственная сложность. Дельты приходят по частям, нужно склеивать по индексу. Но когда разберёшься — это 15 строк кода.4. Контекст — главный ресурс. 80% багов были про «модель съела слишком много токенов». Обрезка результатов, блокировка бинарников, компактор — всё ради экономии контекста.5. Минимализм работает. Без фреймворков проще понимать, что происходит. ANSI-цвета за 6 строк вместо chalk. process.argv` вместо yargs. readline вместо inquirer.Весь код — [на GitHub](https://github.com/skydeex/deepseekAgent).

В следующей статье я напишу как я запилил оптимизатор расхода токенов для ai кодовых агентов

ссылка на оригинал статьи https://habr.com/ru/articles/1035194/