# dsh-context-governor [English](README.md) | **Русский** | [中文](README.zh.md) [![npm](https://img.shields.io/npm/v/dsh-context-governor)](https://www.npmjs.com/package/dsh-context-governor) [![CI](https://github.com/mpinaev/dsh-context-governor/actions/workflows/ci.yml/badge.svg)](https://github.com/mpinaev/dsh-context-governor/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/dsh-context-governor)](LICENSE) Плагин DeepSeek Harness: индикатор контекста сессии — размер prompt, цена шага, cache-hit, полосы и порог компакции, баланс DeepSeek и тариф, кнопка handoff. **Плагин не вызывает модель и не тратит ни одного токена** — подробности ниже. ## Как это выглядит Чип в шапке сессии раскрывает панель. Одна и та же панель доступна на английском, китайском и русском: | English | 中文 | Русский | |---|---|---| | ![Панель контекста сессии (English)](https://raw.githubusercontent.com/mpinaev/dsh-context-governor/main/assets/screenshot-1.png) | ![会话上下文面板(中文)](https://raw.githubusercontent.com/mpinaev/dsh-context-governor/main/assets/screenshot-2.png) | ![Панель контекста сессии (русский)](https://raw.githubusercontent.com/mpinaev/dsh-context-governor/main/assets/screenshot-3.png) | Кнопка handoff стоит рядом с выбором модели: ![Кнопка handoff](https://raw.githubusercontent.com/mpinaev/dsh-context-governor/main/assets/screenshot-4.png) ## Не тратит токены и не вызывает модель Всё, что показывает плагин, — это **измерение и арифметика**. Он читает токены и окно из проекций harness, считает цену по настроенным ставкам и читает баланс по HTTP. - **Ни одного запроса на генерацию.** Ни для ревью, ни для суммаризации, ни в фоне: плагин не просит модель что-либо посчитать. - **Ноль токенов.** Плагин не отправляет запросов провайдеру и не расходует бюджет. Единственное обращение к LLM-слою — чтение каталога моделей (`resolveModelInfo`) за окном и лимитами; это не генерация и не оплачиваемые токены. - **Ничего не меняет.** Историю, системный промпт и кэш префикса не трогает, поэтому на prompt-cache повлиять не может. - **Сеть — только за балансом.** Сервис аккаунта harness (если выполнен вход) и `api.deepseek.com/user/balance` — это не модель. Плюс `git status` при нажатии кнопки handoff, и то локально. ## Установка Пакетом: ```sh dsh plugin --profile web add dsh-context-governor ``` Из исходников: ```sh dsh plugin --profile web add github:mpinaev/dsh-context-governor ``` Локально, без установки: положить каталог в `~/.dsh/profiles/web/plugins/` и вставить его в `cordis.patch.yml` профиля ссылкой на файл — ```yaml - insert: - id: context-governor name: /абсолютный/путь/к/dsh-context-governor/index.js ``` После установки пакетом перезапусти `dsh web` (хост-половина читается при старте) и обнови страницу (клиентская половина отдаётся снимком по ревизии). Проверка: `npm test` — smoke-тест прогоняет хост-половину на stub-харнессе без сети и без вызовов модели и фиксирует порог компакции, вёдра кэша, цену шага и привязку баланса к провайдеру. ## Поддерживаемые версии DSH | Версия DSH | Состояние | |---|---| | `0.1.7-rc.2` … `<0.2.0` | поддерживается, проверено вживую | | `0.2.0-rc.1` … `<0.3.0` | диапазон объявлен; проверяется еженедельной CI-пробой | DSH **отключает** плагин, если объявленный диапазон не покрывает текущую версию (проверяются `peerDependencies` с учётом пререлизов). Поэтому при старте хост пишет в лог источник токенов и тариф: если этих строк нет — скорее всего, плагин отключён, и стоит посмотреть `dsh --dump-config`. ## Разработка ```sh git clone https://github.com/mpinaev/dsh-context-governor.git cd dsh-context-governor npm test # 58 проверок: без сети и без вызовов модели ./scripts/compat-check.sh # поднимет отдельный инстанс на 3099 и спросит его API dsh plugin --profile web add "link:$PWD" ``` Правила и порядок релиза — в [CONTRIBUTING.md](CONTRIBUTING.md). ## Что показывает - размер prompt (свежий вход + кэш-токены) и **оценку цены шага в $** (только для DeepSeek; у прочих цена не показывается вовсе); - относительный множитель стоимости относительно `base` (свежий вход = 1.0, кэш-чтение = `cacheReadRate/freshRate`, у deepseek-flash это ~0.02); - cache-hit по итогам сессии, % (частичное попадание не округляется до 100); - холодный вход за шаг (свежие токены в последнем запросе); - полосу: 0 = норма, 1 = warn, 2 = high, 3 = critical; - **баланс DeepSeek** (числа с /user/balance, ключ на клиент не уходит); - **тариф пик/off-peak** с обратным отсчётом до смены. **Почему множитель по абсолютному prompt врёт.** У deepseek-flash кэш-чтение в 50 раз дешевле свежего входа ($0.003 против $0.15 за 1M off-peak). При cache-hit 99 % контекст 260k стоит ~$0.0015 за шаг, хотя `prompt/base` дал бы x2.6. Поэтому цена считается по ведрам: свежие × `freshRate`, кэш-чтение × `cacheReadRate`, запись в кэш × `cacheWriteRate`, output × `outputRate`. ## Источники данных **Токены.** Размер prompt, cache-hit и output берутся из **проекции harness** `tokenUsage` (сервис `ctx.sessionProjections`): `uncachedInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Это авторитетные числа провайдера — они переживают пейджинг и компакцию и корректно закрывают слот при ретрае (`llm/retry-started`), поэтому повторная попытка не удваивает токены. Если проекций в сборке нет, плагин падает на собственную свёртку `llm/stream`. Cache-hit считается по итогам сессии, а не по одному последнему шагу, поэтому сходится с чипом cache-hit самого harness. Частичное попадание не округляется до 100 %: вместо этого добавляются знаки после запятой, а ровно `100%` бывает только при полном попадании. **Окно и порог.** Окно модели и давление берутся из проекции `contextPressure` (`contextWindow`, `pressureTokens`), резерв вывода — из `request/header` (`maxTokens` запроса). Порог компакции считается ровно как в harness (`dsh-compaction-basic`): ``` порог = min(thresholdRatio × окно, окно − резерв − headroom) ``` **Порог — по своей сессии.** Окно и резерв хранятся отдельно для каждой сессии: сессия, чей маршрут ещё не объявлен, показывает «окно неизвестно», а не порог чужой модели. Полосы при неизвестном окне не выдумываются. **Три ведра входа считаются по своим ставкам.** Кэш-чтение (по умолчанию $0.003/1M) в 50 раз дешевле свежего входа, а запись в кэш у DeepSeek тарифицируется как обычный вход (`cacheWriteRate`, по умолчанию = `freshRate`). Cache-hit — это доля prompt, отданная из кэша **на чтение**; запись в кэш хитом не считается. Провайдер, у которого кэша нет вовсе, ставит `cached: false` в своей записи `providerRates`: кэш-строки, кэш-ставки и метка cache-hit пропадают, а предупреждения `cacheHitLow` и «холодный префилл» молчат. Без флага они висели бы на каждом шаге: у провайдера без кэша cache-hit всегда 0%, а свежим по определению становится весь вход. **Деньги.** Ни harness, ни провайдер не сообщают стоимость токенов в долларах: никакой цены нет ни в модели, ни в проекции, ни в сервисе. Поэтому плагин хранит свою таблицу ставок, но применяет её **только там, где знает цены** — по умолчанию это DeepSeek (`deepseek-official`, ids на `deepseek`), плюс любой провайдер, объявленный в `providerRates` или перечисленный в `pricedProviders`. Для остальных (cline, clinebot, OpenRouter, pi-ai, …) ничего про цену не считается и не показывается: в чипе пропадает кусок со стоимостью, а в панели — строки «стоимость шага», «относительно базы» и «ставки». Ни выдуманного числа, ни заглушки «цена неизвестна». **Ставки — по провайдеру.** `providerRates.` переопределяет общие `freshRate` / `cacheReadRate` / `cacheWriteRate` / `outputRate`, так что у второго провайдера могут быть свои числа (и свой `peakMultiplier`). **Сезонный тариф — тоже по провайдеру.** Пик/off-peak, праздники Китая и множитель `×N` — это расписание DeepSeek, поэтому метка (⚡/🌙) и строки тарифа показываются только у **сезонного** провайдера: по умолчанию у id на `deepseek`. У чужого тариф плоский — ни метки, ни строк про пик и Пекин; если чужой провайдер правда живёт по расписанию DeepSeek, он ставит `seasonal: true` в своей записи `providerRates`. ## Сигналы - серверный лог при входе в полосу, при холодном префилле и при дорогом шаге; - клиентский чип в шапке сессии, подсвеченный по полосе, с панелью деталей; - в чипе: баланс и, у провайдера с расписанием DeepSeek, метка тарифа (⚡ пик / 🌙 off-peak с отсчётом). ## Тариф пик/off-peak и баланс **Тариф.** Правило DeepSeek фиксировано: пик — по пекинскому времени (UTC+8), по будням, 09:00–12:00 и 14:00–18:00; всё остальное, включая выходные целиком, — off-peak и стоит вдвое дешевле. Локальная таймзона в решении не участвует, только в отображении. Ставки в конфиге (freshRate, cacheReadRate, cacheWriteRate, outputRate) — это off-peak; в пик они умножаются на peakMultiplier (по умолчанию 2), поэтому цена шага и предупреждения считаются по фактическому тарифу момента. Обратный отсчёт ведёт к ближайшей реальной смене: границы внутри выходных пропускаются, то есть после пятницы 18:00 отсчёт идёт до понедельника 09:00, а не до субботы. У провайдера с таким расписанием в пик метка тарифа, время Пекина и баланс показываются красным — и в чипе, и в панели, так что дорогие часы видно сразу. **Государственные праздники Китая.** В официальном правиле есть оговорка, которую легко пропустить: пик — это будни **кроме государственных праздников КНР**, а в праздники DeepSeek держит off-peak круглые сутки. Плагин берёт календарь на год из поддерживаемого источника (`holidayUrl`, по умолчанию данные пакета `chinese-days`), один раз кэширует его на диск (`holidayCacheDir`, по умолчанию `/cache/context-governor`) и обновляет сам — код каждый год править не нужно. Сеть трогается только в фоне: если календарь не доехал, тариф считается по будням, а панель честно пишет «календарь праздников не загружен». Свои даты можно задать в конфиге `holidays` — массив `'YYYY-MM-DD'` или диапазонов `'YYYY-MM-DD..YYYY-MM-DD'`; они применяются сразу, приоритетнее источника и работают без сети. В праздник строка тарифа читается как «off-peak (праздник Китая)», а счётчик перепрыгивает каникулы целиком — до следующего рабочего пика. Сеть можно выключить целиком: `holidayFetch: false`. **Баланс.** Порядок источников: 1. **Официальный аккаунт платформы** — сервис harness `ctx.deepseekAccount` (`getBalance`). Работает, когда выполнен вход в аккаунт и клиент прислал версию сборки; кошельки приходят строками, валюта может быть CNY или USD, подарочные — отдельным списком. 2. **API-ключ** — `GET https://api.deepseek.com/user/balance` с ключом из credentials-seam (`DEEPSEEK_API_KEY`), затем из окружения. Поле `source` в ответе говорит, откуда цифры: `account` или `api-key`. Откат на второй источник — намеренный: чужая сборка может быть без платформенного входа, и баланс не должен из-за этого пропадать. **Баланс показывается только на провайдерах DeepSeek.** На cline, clinebot и любом другом провайдере чужая сумма в чипе — прямая дезинформация: там баланс не отображается, а в панели вместо числа стоит пометка «только для DeepSeek». Список провайдеров настраивается через `balanceProviders` (по умолчанию `['deepseek-official']`), плюс принимается любой id, начинающийся с `deepseek`. Если у сессии ещё нет своего маршрута, провайдер не угадывается: баланс скрыт. Ключ **никогда не покидает хост**: на клиент уходят только числа (итог, подарок, пополнение, валюта, доступность). Параллельные чтения схлопываются, ошибки деградируют в состояние (no-credential, error, disabled), а не в исключение. Кнопка ⟳ сбрасывает кэш и перечитывает баланс и тариф. **Валюта не пересчитывается.** Цена шага и настроенные ставки заданы в долларах (`freshRate`, `cacheReadRate`, `cacheWriteRate`, `outputRate` — это $/1M), а баланс печатается в той валюте, которую вернул источник: у DeepSeek это USD или CNY, сумма — до цента. Плагин не пересчитывает валюты между собой, поэтому баланс в `¥` и цена шага в `$` в одном чипе — разные единицы: сравнивать их напрямую бессмысленно, сначала переведите сами. **Доступ к роутам.** `/context-governor/api/*` закрыты служебным заголовком и проверкой Origin: запрос обязан нести `x-dsh-context-governor: 1` (его ставит только клиент плагина) и не быть кросс-доменным. Без заголовка — 403, при чужом `Origin` — 403. Обычный GET с loopback без `Origin` пропускается — это удобно для диагностики через `curl`. Практический смысл: при 99 % cache-hit дорогой шаг — это не большой контекст, а свежий вход или провал кэша, и тариф удваивает эту разницу. ## Кнопка handoff Один клик: хост собирает сводку сессии (задача, состояние, cwd, затронутые файлы, дочерние сессии), клиент открывает **новую сессию в том же рабочем пространстве** через `ctx.uiWorkspace.startSession()` и подставляет сводку в её черновик. Веток и запасных путей нет: нет Alt+клика, нет вставки в текущую сессию, нет буфера обмена. Надёжность держат два объявленных сервиса: - клиент — `inject = ['slots', 'uiWorkspace']`: без объявления Cordis не отдаёт сервис, и раньше кнопка молча уходила в запасные ветки вместо открытия сессии; - хост — `inject = [... 'sessionQuery']`: без него сводка выходила без задачи, состояния и путей (одни заглушки). Сводка попадает в новую сессию отложенно: клик запоминает текст, `startSession()` открывает сессию, её слот ввода рендерится — и тот же компонент подставляет текст в черновик через `inputActions.setDraft`. ## Языки интерфейса Языки: **en** (по умолчанию), **zh**, **ru**. Переключатель — маленькая кнопка рядом с ⟳ в шапке панели чипа: она показывает текущий код (EN / 中文 / RU) и циклически меняет язык. Выбор запоминается в браузере (`localStorage: dsh-context-governor.lang`) и сразу перерисовывает и чип, и кнопку handoff. Язык влияет на весь текст интерфейса (подписи панели, предупреждения, подсказки) и на язык документа, который кнопка handoff вставляет в новую сессию: клиент передаёт `lang` в `/api/handoff`. Хост отдаёт данные без текста (полоса, тариф и предупреждения — кодами), поэтому смена языка не требует перезапроса данных. ## Пороги **Окно модели плагин нигде не прописывает — он читает его из harness:** 1. Основной источник — проекция `contextPressure` (`contextWindow`, `pressureTokens`) сервиса `ctx.sessionProjections`: это авторитетное окно живой сессии. 2. Резерв вывода — `maxTokens` запроса из события `request/header`; до него подсказкой служит `request/context` (`contextWindow`). 3. Каталог моделей (`ctx.llm.resolveModelInfo` для `agentDefaultModel.currentSelection()`) остаётся подсказкой до первого запроса и нужен только для списка известных маршрутов. Окно и резерв хранятся **по каждой сессии**. Из окна считаются порог компакции и полосы: ``` резерв = maxTokens запроса порог компакции = min(thresholdRatio * окно, окно - резерв - headroomTokens) полоса i = bandRatios[i] * порог компакции ``` При окне 1 000 000 и резерве 256 000 порог = 678 464, полосы = 237k / 407k / 577k. При окне 800 000 — порог 478 464, полосы 167k / 287k / 407k. Формула совпадает с `dsh-compaction-basic` (`W - reserved - headroom`); если бы полосы задавались константами, «критично» оказалось бы ниже реального порога перезаписи префикса. Плагин адаптируется и к разным моделям в разных сессиях: у каждой сессии своё окно маршрута, и чужое ей не подставляется. Если окно ещё не разрешилось, полосы не выдумываются (их нет), а в панели появляется предупреждение «окно модели неизвестно». В `~/.dsh/profiles/web/cordis.patch.yml` в блоке `context-governor` настраиваются только `bandRatios`, `headroomTokens`, `thresholdRatio`, общие ставки (`freshRate`, `cacheReadRate`, `cacheWriteRate`, `outputRate`, `peakMultiplier`), переопределения по провайдеру (`pricedProviders`, `providerRates` — ставки, `seasonal`, `cached`, `peakMultiplier`) и пороги сигналов (`anomalyDelta`, `anomalyCostUsd`, `cacheHitFloorPct`); календарь праздников — `holidays`, `holidayFetch`, `holidayUrl`, `holidayCacheDir`, `holidayRetryMs`, `holidayTimeoutMs`; баланс — `balanceEnabled`, `useAccountBalance`, `balanceProviders`, `balanceTtlMs`, `balanceTimeoutMs`. `windowTokens` и `reservedTokens` можно задать принудительно, но по умолчанию они `0` — «спросить у harness»; хардкод там означает потерю адаптивности. ## Важно Полосы считаются от окна модели; `warn/high/critical` не читаются. Плагин измеряет и ничего не режет, историю и промпт не меняет. ## Лицензия MIT — см. [LICENSE](LICENSE).