diff --git a/.gitignore b/.gitignore index a207b48..f180931 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ test/coverage.lcov # Playwright MCP .playwright-mcp/ + +# Скачанный бандл DeepSeek (анализ ошибок, не коммитим) +/deepseek-bundle/ /tiktok /.cuckoo/skills # Проектная память — личные заметки AI, не коммитим. diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cad02..76d5c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [5.0.9] - 2026-10-26 + +### Added + +- ⛔ **Уведомление в Telegram при rate-limit DeepSeek (429 / 40029)** — при превышении лимита запросов в Telegram приходит ==КАПС-предупреждение== с просьбой подождать: + - в main-world перехватчике `token-interceptor-inject.js` в патчах `fetch`/`XHR` ловятся HTTP `429` и бизнес-код `40029` (`IP_ACCESS_RESTRICTED`) в теле ответа; + - событие уходит через `postMessage({ __cuckoo: "rate-limit" })`, preload слушает с антиспамом 60с; + - IPC `telegram-rate-limit` → `botsrc.notifyRateLimit()` шлёт сообщение с ожиданием 5–20 минут (учёт `retry-after` при наличии); + - строки локализации `rateLimit.*` (ru/en) в `botsrc/i18n.js`; в окнах-субагентах уведомление подавляется. +- 🔄 **Pull-модель получения initial-prompt в окне субагента** — если push потерялся из-за гонки IPC, окно само запрашивает промпт у main: + - `subagent.js` кэширует combined-промпт по `windowId` и отдаёт через `getInitPrompt()`; + - новый IPC-хендлер `subagent-get-init-prompt` в `src/main/ipc.js`; + - в `chat-input.js` окно-субагент через 20с запрашивает промпт, если не получило пушем. + +### Changed + +- 🪟 **Отправка initial-prompt в субагент стала устойчивее** — повторные отправки в `3с / 8с / 15с`, абсолютный таймаут до 3 минут (было: 1.2с и 60с) в `subagent.js`. +- ⏳ **Ожидание поля ввода в окне субагента** — увеличено с ~15с до 90с (180 попыток × 500мс), `pendingInitialPrompt` больше не сбрасывается по таймауту (`chat-input.js`). +- 🛡 **Защита от дубля initial-prompt** — `applyInitialPrompt()` с флагом `_initialPromptApplied` не даёт отправить промпт дважды (push + pull). + +### Fixed + +- 🐛 **Окно субагента больше не остаётся пустым без промпта** — повторные отправки + pull-запрос закрывают гонку долгой загрузки окна. +- 🧹 **DSML/XML-мусор в ответе субагента не считается финальным ответом** — проверка вынесена ==выше анализа cuckoo-блоков== (`subagent-watch.js`): + - детект по `textContent` **и** `innerHTML` (ловит мусор внутри `
` и когда теги съедены markdown-рендером);
+ - паттерны: маркеры `DSML`, `invoke/parameter/tool_calls name=` с тегом и без;
+ - при обнаружении в чат уходит предупреждение (на английском) использовать `cuckoo`-блок, ответ не завершается; лимит 3 подсказки + антидубль по снапшоту.
+
## [5.0.8] - 2026-10-22
### Added
diff --git a/botsrc/i18n.js b/botsrc/i18n.js
index 9610957..e11635d 100644
--- a/botsrc/i18n.js
+++ b/botsrc/i18n.js
@@ -476,6 +476,16 @@ const STRINGS = {
// ===== Уведомление об ответе AI =====
"ai.truncated": "…(обрезано, всего {n} симв.)",
+ // ===== Rate limit (превышение лимита запросов DeepSeek) =====
+ "rateLimit.title": "⛔️⛔️⛔️ ВНИМАНИЕ! ⛔️⛔️⛔️",
+ "rateLimit.exceeded": "ТЫ ПРЕВЫСИЛ ЛИМИТ ЗАПРОСОВ К DEEPSEEK!",
+ "rateLimit.tooOften": "СЛИШКОМ ЧАСТО ОТПРАВЛЯЕШЬ СООБЩЕНИЯ!",
+ "rateLimit.wait": "⏳ ПОДОЖДИ {min}–{max} МИНУТ, ПОТОМ ПРОБУЙ СНОВА!",
+ "rateLimit.serverHint": "(Сервер просит подождать минимум ~{retry} мин.)",
+ "rateLimit.dontSpam":
+ "Не отправляй сообщения подряд — это только продлит блокировку!",
+ "rateLimit.statusCode": "Код ответа: {status}",
+
// ===== Тестовое сообщение =====
"test.send": "👋 Cookie Code: тестовое уведомление. Всё работает.",
},
@@ -946,6 +956,16 @@ const STRINGS = {
// ===== AI response notification =====
"ai.truncated": "…(truncated, {n} chars total)",
+ // ===== Rate limit (DeepSeek request limit exceeded) =====
+ "rateLimit.title": "⛔️⛔️⛔️ WARNING! ⛔️⛔️⛔️",
+ "rateLimit.exceeded": "YOU HAVE EXCEEDED THE DEEPSEEK REQUEST LIMIT!",
+ "rateLimit.tooOften": "YOU ARE SENDING MESSAGES TOO OFTEN!",
+ "rateLimit.wait": "⏳ WAIT {min}–{max} MINUTES, THEN TRY AGAIN!",
+ "rateLimit.serverHint": "(Server asks to wait at least ~{retry} min.)",
+ "rateLimit.dontSpam":
+ "Do not send messages back-to-back — it will only extend the block!",
+ "rateLimit.statusCode": "Response code: {status}",
+
// ===== Test message =====
"test.send": "👋 Cookie Code: test notification. All good.",
},
diff --git a/botsrc/index.js b/botsrc/index.js
index 94e8b9e..4350b04 100644
--- a/botsrc/index.js
+++ b/botsrc/index.js
@@ -5461,6 +5461,43 @@ async function notifyAIResponse(text) {
return telegramBot.sendMessage("🤖 " + body);
}
+/**
+ * Уведомить о превышении rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED=40029).
+ * Пишет КАПСОМ, что пользователь превысил лимит и нужно подождать 5-20 минут.
+ * @param {{status?:number, retryAfterSec?:number}} [info]
+ */
+async function notifyRateLimit(info) {
+ const cfg = _read();
+ if (!cfg.enabled || !cfg.token || !cfg.chatId)
+ return { success: false, skipped: true };
+
+ // Случайная задержка ожидания 5–20 минут.
+ const minMin = 5;
+ const maxMin = 20;
+ const waitMin = minMin + Math.floor(Math.random() * (maxMin - minMin + 1));
+
+ // Если сервер прислал retry-after — используем его как ориентир (в минутах).
+ const retry =
+ info && Number(info.retryAfterSec) > 0
+ ? Math.ceil(Number(info.retryAfterSec) / 60)
+ : 0;
+
+ const lines = [
+ _t("rateLimit.title"),
+ "",
+ _t("rateLimit.exceeded"),
+ _t("rateLimit.tooOften"),
+ "",
+ _t("rateLimit.wait", { min: waitMin, max: maxMin }),
+ ];
+ if (retry > 0) lines.push(_t("rateLimit.serverHint", { retry }));
+ lines.push("", _t("rateLimit.dontSpam"));
+ if (info && info.status)
+ lines.push(_t("rateLimit.statusCode", { status: info.status }));
+
+ return telegramBot.sendMessage(lines.join("\n"));
+}
+
/** Пингануть бота (getMe) для проверки токена. */
async function ping() {
const cfg = _read();
@@ -5483,6 +5520,7 @@ module.exports = {
notifyToolResult,
notifyAgentStarted,
notifyAIResponse,
+ notifyRateLimit,
startTyping,
stopTyping,
notifyAllDone,
diff --git a/src/main/ipc.js b/src/main/ipc.js
index 82982ad..014d1a1 100644
--- a/src/main/ipc.js
+++ b/src/main/ipc.js
@@ -407,6 +407,22 @@ function registerIpcHandlers() {
}
});
+ // Pull-модель: дочернее окно само запрашивает initial-prompt, если push
+ // потерялся из-за гонки IPC (окно долго грузилось и не успело подписаться).
+ ipcMain.handle("subagent-get-init-prompt", async (event) => {
+ try {
+ const ctx = windowState.getContextByWebContents(event.sender);
+ if (!ctx || !ctx.win || ctx.win.isDestroyed()) {
+ return { success: false, error: "no window context" };
+ }
+ const text = subagent.getInitPrompt(ctx.win.id);
+ if (!text) return { success: false, error: "no init prompt" };
+ return { success: true, text };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ });
+
// Мост Telegram → окно: ответ на вопрос из TG резолвит тот же promise.
try {
const bot = require("../../botsrc");
@@ -1450,6 +1466,20 @@ function registerIpcHandlers() {
}
});
+ // Rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED) -> КАПС-уведомление в Telegram.
+ ipcMain.handle("telegram-rate-limit", async (event, info = {}) => {
+ try {
+ const ctx = windowState.getContextByWebContents(event.sender);
+ if (ctx && ctx.isSubagent) {
+ return { success: false, skipped: true, reason: "subagent window" };
+ }
+ const bot = require("../../botsrc");
+ return await bot.notifyRateLimit(info);
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ });
+
// Индикатор «ИИ печатает…» в Telegram (пока идёт генерация ответа).
ipcMain.handle("telegram-typing-start", async () => {
try {
diff --git a/src/main/subagent.js b/src/main/subagent.js
index 0c35387..350070b 100644
--- a/src/main/subagent.js
+++ b/src/main/subagent.js
@@ -79,6 +79,15 @@ function buildAgentPrompt(agentSystemPrompt, task) {
* Дождаться готовности дочернего окна и отправить в него initial-prompt
* (system prompt Cookie Code + промпт агента + task).
*/
+// combined initial-prompt по windowId — чтобы окно могло запросить его повторно
+// (pull-модель), если push-сообщение потерялось из-за гонки IPC.
+const initPrompts = new Map();
+
+/** Получить сохранённый initial-prompt по windowId (или null). */
+function getInitPrompt(windowId) {
+ return initPrompts.get(windowId) || null;
+}
+
function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
return new Promise((resolve) => {
if (!win || win.isDestroyed()) return resolve(false);
@@ -92,6 +101,9 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
"deepseek",
);
const combined = base + "\n\n" + extraPrompt;
+ try {
+ initPrompts.set(win.id, combined);
+ } catch (_) {}
win.webContents.send("initial-prompt", combined);
sent = true;
console.log(
@@ -108,15 +120,24 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
resolve(false);
}
};
- win.webContents.once("did-finish-load", () => setTimeout(send, 1200));
- // На случай, если страница уже загрузилась до подписки
- setTimeout(send, 3000);
- // Абсолютный таймаут
+ // DeepSeek на холодном старте монтирует React долго — ждём дольше.
+ win.webContents.once("did-finish-load", () => setTimeout(send, 3000));
+ // Повторная попытка, если первая не сработала (окно догрузилось позже).
+ setTimeout(send, 8000);
+ setTimeout(send, 15000);
+ // Абсолютный таймаут: ждём до 3 минут, пока окно будет готово.
setTimeout(
() => {
- if (!sent) resolve(false);
+ if (!sent) {
+ console.warn(
+ "[Subagent] initial-prompt не отправлен за " +
+ Math.round(Math.min(timeoutMs, 180000) / 1000) +
+ "с — окно, возможно, не готово",
+ );
+ resolve(false);
+ }
},
- Math.min(timeoutMs, 60000),
+ Math.min(timeoutMs, 180000),
);
});
}
@@ -381,4 +402,5 @@ module.exports = {
runAgent,
onSubagentResponse,
hasRunningSubagent,
+ getInitPrompt,
};
diff --git a/src/main/token-interceptor-inject.js b/src/main/token-interceptor-inject.js
index 46d0211..391e28d 100644
--- a/src/main/token-interceptor-inject.js
+++ b/src/main/token-interceptor-inject.js
@@ -123,6 +123,32 @@ function buildTokenInterceptorScript() {
return typeof url === "string" && url.indexOf(COMPLETION) !== -1;
}
+ // Rate-limit: 429 или бизнес-код 40029 (IP_ACCESS_RESTRICTED).
+ function publishRateLimit(extra) {
+ try {
+ window.postMessage(
+ {
+ __cuckoo: "rate-limit",
+ status: (extra && extra.status) || 0,
+ retryAfterSec: (extra && extra.retryAfterSec) || 0,
+ ts: Date.now(),
+ },
+ "*",
+ );
+ } catch (e) {}
+ }
+
+ // Проверить тело ответа/ошибки на 40029 и вызвать publishRateLimit.
+ function checkBodyForRateLimit(body) {
+ if (!body || typeof body !== "string") return;
+ if (
+ body.indexOf("40029") === -1 &&
+ body.indexOf("IP_ACCESS_RESTRICTED") === -1
+ )
+ return;
+ publishRateLimit({ status: 200 });
+ }
+
// ---- патч fetch ----
try {
var originalFetch = window.fetch;
@@ -132,6 +158,18 @@ function buildTokenInterceptorScript() {
var p = originalFetch.apply(this, arguments);
if (!isCompletion(url)) return p;
return p.then(function (response) {
+ // HTTP 429 — rate limit.
+ try {
+ if (response && response.status === 429) {
+ var ra = 0;
+ try {
+ ra =
+ parseInt(response.headers.get("retry-after") || "0", 10) ||
+ 0;
+ } catch (e) {}
+ publishRateLimit({ status: 429, retryAfterSec: ra });
+ }
+ } catch (e) {}
try {
var clone = response.clone();
clone
@@ -139,6 +177,7 @@ function buildTokenInterceptorScript() {
.then(function (body) {
var u = parseSseText(body);
if (u) publish(u);
+ checkBodyForRateLimit(body);
})
.catch(function () {});
} catch (e) {}
@@ -164,8 +203,10 @@ function buildTokenInterceptorScript() {
if (isCompletion(this.__cuckooUrl || "")) {
this.addEventListener("load", function () {
try {
+ if (self.status === 429) publishRateLimit({ status: 429 });
var u = parseSseText(self.responseText);
if (u) publish(u);
+ checkBodyForRateLimit(self.responseText);
} catch (e) {}
});
}
diff --git a/src/preload/api.js b/src/preload/api.js
index d225835..ed85fea 100644
--- a/src/preload/api.js
+++ b/src/preload/api.js
@@ -52,6 +52,8 @@ let electronAPI = {
telegramTest: () => ipcRenderer.invoke("telegram-test"),
telegramNotifyAI: (text) =>
ipcRenderer.invoke("telegram-notify-ai", { text }),
+ telegramRateLimit: (info) =>
+ ipcRenderer.invoke("telegram-rate-limit", info || {}),
telegramTypingStart: () => ipcRenderer.invoke("telegram-typing-start"),
telegramTypingStop: () => ipcRenderer.invoke("telegram-typing-stop"),
telegramWhisperStatus: (opts) =>
diff --git a/src/preload/dom/chat-input.js b/src/preload/dom/chat-input.js
index 4718e5a..7832da4 100644
--- a/src/preload/dom/chat-input.js
+++ b/src/preload/dom/chat-input.js
@@ -290,13 +290,17 @@ function sendInitialPromptToInput() {
*/
function waitForInitialPromptAndSend() {
let attempts = 0;
- const maxAttempts = 30;
+ // Ждём появления поля ввода до 90 секунд (180 * 500мс). DeepSeek на холодном
+ // старте может долго монтировать React-приложение — 15с не хватало, и окно
+ // субагента висело пустым без промпта.
+ const maxAttempts = 180;
const checkInterval = setInterval(() => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(checkInterval);
- state.pendingInitialPrompt = false;
+ // НЕ сбрасываем pendingInitialPrompt: пусть следующий вызов
+ // (например, по новому initial-prompt или ручному ретраю) попробует снова.
return;
}
@@ -374,6 +378,32 @@ function fallbackSend(provider, input) {
console.log("[Cookie Code] 已通过 Enter 键触发发送 (未找到发送按钮)");
}
}
+/**
+ * Применить initial-prompt: сохранить и отправить в поле ввода (или ждать его).
+ * @param {string} content
+ */
+let _initialPromptApplied = false;
+function applyInitialPrompt(content) {
+ // Антидубль: push и pull могут прийти оба — применяем только один раз.
+ if (_initialPromptApplied) return;
+ const text = content || "";
+ if (!text) return;
+ _initialPromptApplied = true;
+ state.initialPromptContent = text;
+ state.pendingInitialPrompt = true;
+ if (state.pendingInitialPrompt && state.initialPromptContent) {
+ try {
+ const input = findInputArea();
+ if (input) {
+ sendInitialPromptToInput();
+ } else {
+ // Ждём появления поля ввода.
+ waitForInitialPromptAndSend();
+ }
+ } catch (e) {}
+ }
+}
+
/**
* 注册主进程消息监听(initial-prompt)
* 与原 preload.js 顶层注册时机一致:preload 入口加载时同步调用。
@@ -381,22 +411,39 @@ function fallbackSend(provider, input) {
function registerIpcListeners() {
// 监听主进程发送的初始提示
ipcRenderer.on("initial-prompt", (_event, content) => {
- state.initialPromptContent = content || "";
- state.pendingInitialPrompt = true;
- // 如果当前已有新的空会话输入框,立即发送
- if (state.pendingInitialPrompt && state.initialPromptContent) {
- try {
- const input = findInputArea();
- if (input) {
- sendInitialPromptToInput();
- } else {
- // 等待输入框出现
- waitForInitialPromptAndSend();
- }
- } catch (e) {}
- }
+ applyInitialPrompt(content);
});
+ // Pull-модель: если push initial-prompt потерялся (окно субагента долго
+ // грузилось и не успело подписаться на канал), через 20с сами запросим его
+ // у main-процесса. Актуально только для окна-субагента.
+ try {
+ const isSubagent =
+ (typeof process !== "undefined" &&
+ Array.isArray(process.argv) &&
+ process.argv.some(
+ (a) => typeof a === "string" && a.startsWith("--cuckoo-subagent="),
+ )) ||
+ false;
+ if (isSubagent) {
+ setTimeout(() => {
+ if (state.initialPromptContent) return; // уже получили
+ try {
+ Promise.resolve(ipcRenderer.invoke("subagent-get-init-prompt"))
+ .then((res) => {
+ if (res && res.success && res.text) {
+ console.log(
+ "[Cookie Code] initial-prompt получен по pull-запросу",
+ );
+ applyInitialPrompt(res.text);
+ }
+ })
+ .catch(() => {});
+ } catch (_) {}
+ }, 20000);
+ }
+ } catch (_) {}
+
// Ответ из окна кастомного уведомления — сразу вставляем в чат и отправляем.
ipcRenderer.on("cuckoo-notif-reply", (_event, text) => {
const msg = String(text || "").trim();
diff --git a/src/preload/dom/subagent-watch.js b/src/preload/dom/subagent-watch.js
index 5a1a6d8..8b1f1d9 100644
--- a/src/preload/dom/subagent-watch.js
+++ b/src/preload/dom/subagent-watch.js
@@ -14,6 +14,7 @@
const { ipcRenderer } = require("electron");
const { getProviderByUrl } = require("../../../src/providers");
const { getJsCodeBlocksFromMarkdown } = require("./js-detector");
+const chatInput = require("./chat-input");
const STABLE_MS = 2500; // тишина, после которой считаем ответ финальным
const POLL_MS = 700;
@@ -82,6 +83,98 @@ function showProjectDir(cfg) {
}
}
+// ===== Детект DSML/XML-мусора вместо cuckoo-блоков =====
+// AI иногда выдаёт сырые XML-вызовы (<||DSML||invoke>, ,
+// ) вместо cuckoo-кодблока. Такой ответ нельзя считать
+// финальным — надо поругать модель и дождаться корректного формата.
+const DSML_PATTERNS = [
+ /<\s*||DSML||/i,
+ /<\s*(?:[\w-]+:)?invoke\s+name=/i,
+ /<\s*\/\s*(?:[\w-]+:)?invoke\s*>/i,
+ /<\s*(?:[\w-]+:)?parameter\s+name=/i,
+ /<\s*(?:[\w-]+:)?tool_calls?\b/i,
+];
+
+/**
+ * Есть ли DSML/XML-мусор (сломанный tool-call вместо cuckoo-блока).
+ * Проверяем и видимый текст (textContent), и HTML (innerHTML): React/markdown
+ * может отрендерить как элемент — тогда в textContent останется
+ * только "name=...", а тег будет лишь в innerHTML.
+ * @param {string} text — md.textContent
+ * @param {string} [html] — md.innerHTML
+ * @returns {boolean}
+ */
+function detectDsmlGarbage(text, html) {
+ const a = typeof text === "string" ? text : "";
+ const b = typeof html === "string" ? html : "";
+ const hay = a + "\n" + b;
+ if (!hay.trim()) return false;
+ // 1) Явные маркеры DSML в любом виде.
+ if (/DSML/i.test(hay)) return true;
+ // 2) XML-подобные вызовы инструментов в тексте или HTML.
+ if (DSML_PATTERNS.some((re) => re.test(hay))) return true;
+ // 3) Текст без "<", но с характерной парой invoke/parameter + name=
+ // (теги съедены markdown-рендером): "invoke name=" / "parameter name=".
+ if (/(?:^|[\s>"'])(?:invoke|parameter|tool_calls?)\s+name\s*=/i.test(hay))
+ return true;
+ return false;
+}
+
+/**
+ * Поругать AI за DSML/XML-формат и НЕ завершать ответ.
+ * Ограничено MAX_HINTS попытками, чтобы не зациклиться навсегда.
+ * @param {string} snapshot — текущий текст ответа (для антидубля)
+ */
+const MAX_DSML_HINTS = 3;
+let dsmlHintCount = 0;
+let lastDsmlHintSnapshot = "";
+function handleDsmlGarbage(snapshot) {
+ // Один и тот же сломанный ответ не ругаем повторно.
+ if (snapshot === lastDsmlHintSnapshot) return;
+ if (dsmlHintCount >= MAX_DSML_HINTS) {
+ console.warn(
+ "[Cookie Code][Subagent] ⚠️ DSML-мусор: исчерпан лимит подсказок (" +
+ MAX_DSML_HINTS +
+ "), ждём без отправки новых",
+ );
+ lastDsmlHintSnapshot = snapshot;
+ return;
+ }
+ dsmlHintCount++;
+ lastDsmlHintSnapshot = snapshot;
+ const BT = String.fromCharCode(96);
+ const fence = BT + BT + BT;
+ console.log(
+ "[Cookie Code][Subagent] ⚠️ Обнаружен DSML/XML-мусор (подсказка " +
+ dsmlHintCount +
+ "/" +
+ MAX_DSML_HINTS +
+ "), просим использовать cuckoo-блок",
+ );
+ try {
+ chatInput.sendMessageToChat(
+ "❌ INVALID TOOL CALL FORMAT DETECTED: you used XML/DSML " +
+ "(<||DSML||invoke>, , ), but you MUST use a " +
+ fence +
+ "cuckoo" +
+ fence +
+ " code block. " +
+ "DO NOT write XML tags. Call the tool via a " +
+ fence +
+ "cuckoo" +
+ fence +
+ " code block with JS code (await tool_name({...})). " +
+ "This is critical: the XML format is NOT executed.",
+ "DSML format warning",
+ );
+ } catch (err) {
+ console.error(
+ "[Cookie Code][Subagent] не удалось отправить DSML-подсказку:",
+ err && err.message,
+ );
+ }
+}
+
/**
* Если окно — субагент, запустить цикл наблюдения за финальным ответом.
*/
@@ -142,7 +235,22 @@ function initSubagentWatch() {
// Текст не менялся — проверяем, устоялся ли.
if (Date.now() - lastChangeAt < STABLE_MS) return;
- // Устоявшийся текст. Проверяем, есть ли в нём cuckoo-блоки.
+ // Устоявшийся текст. СНАЧАЛА проверяем DSML/XML-мусор (по тексту и HTML),
+ // ДО анализа блоков: мусор может прийти и внутри , тогда обычная
+ // ветка "tool-блоки" его не поймает и ответ завершится.
+ const rawHtml = (got.md && got.md.innerHTML) || "";
+ if (detectDsmlGarbage(snapshot, rawHtml)) {
+ console.log(
+ "[Cookie Code][Subagent] DSML-детект: мусор найден. text[0..120]=" +
+ JSON.stringify(snapshot.slice(0, 120)) +
+ " html[0..160]=" +
+ JSON.stringify(rawHtml.slice(0, 160)),
+ );
+ handleDsmlGarbage(snapshot);
+ return;
+ }
+
+ // Проверяем, есть ли в нём cuckoo-блоки.
const blocks = (() => {
try {
return getJsCodeBlocksFromMarkdown(got.md) || [];
diff --git a/src/preload/dom/token-interceptor.js b/src/preload/dom/token-interceptor.js
index e8ef5e1..6573496 100644
--- a/src/preload/dom/token-interceptor.js
+++ b/src/preload/dom/token-interceptor.js
@@ -21,11 +21,11 @@
* не бросает исключение наружу, а просто не применяется.
*/
-const { safe } = require('./safe');
+const { safe } = require("./safe");
-const COMPLETION_URL_PART = '/api/v0/chat/completion';
-const EVENT_NAME = 'cuckoo:token-update';
-const LOG_PREFIX = '[Cookie Code][tokens]';
+const COMPLETION_URL_PART = "/api/v0/chat/completion";
+const EVENT_NAME = "cuckoo:token-update";
+const LOG_PREFIX = "[Cookie Code][tokens]";
// Последнее известное накопленное значение (по всему приложению).
let lastTotal = 0;
@@ -39,7 +39,7 @@ let installed = false;
* @returns {{total:number, prompt:number, completion:number, source:string}|null}
*/
function extractUsage(obj) {
- if (!obj || typeof obj !== 'object') return null;
+ if (!obj || typeof obj !== "object") return null;
// === Реальный формат DeepSeek (SSE completion) ===
// Кадр-снимок (WIP): {"v":{"response":{"accumulated_token_usage":50, ...}}}
@@ -49,35 +49,52 @@ function extractUsage(obj) {
if (Array.isArray(obj.v)) {
for (let i = obj.v.length - 1; i >= 0; i--) {
const op = obj.v[i];
- if (op && typeof op === 'object' && op.p === 'accumulated_token_usage') {
- const n = pickNumber(op, ['v', 'value']);
- if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' };
+ if (op && typeof op === "object" && op.p === "accumulated_token_usage") {
+ const n = pickNumber(op, ["v", "value"]);
+ if (n != null)
+ return { total: n, prompt: 0, completion: 0, source: "accumulated" };
}
}
}
// 1b) Кадр-снимок: v.response.accumulated_token_usage.
- const snapResp = obj.v && typeof obj.v === 'object' && !Array.isArray(obj.v) ? obj.v.response : null;
- if (snapResp && typeof snapResp === 'object') {
- const n = pickNumber(snapResp, ['accumulated_token_usage']);
- if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' };
+ const snapResp =
+ obj.v && typeof obj.v === "object" && !Array.isArray(obj.v)
+ ? obj.v.response
+ : null;
+ if (snapResp && typeof snapResp === "object") {
+ const n = pickNumber(snapResp, ["accumulated_token_usage"]);
+ if (n != null)
+ return { total: n, prompt: 0, completion: 0, source: "accumulated" };
}
// 2) accumulated_token_usage в корне (число либо объект) — на случай смены формата.
const acc = obj.accumulated_token_usage;
- if (typeof acc === 'number' && Number.isFinite(acc)) {
- return { total: acc, prompt: 0, completion: 0, source: 'accumulated' };
+ if (typeof acc === "number" && Number.isFinite(acc)) {
+ return { total: acc, prompt: 0, completion: 0, source: "accumulated" };
}
- if (acc && typeof acc === 'object') {
- const t = pickNumber(acc, ['total_tokens', 'total', 'value']);
- if (t != null) return { total: t, prompt: pickNumber(acc, ['prompt_tokens']) || 0, completion: pickNumber(acc, ['completion_tokens']) || 0, source: 'accumulated' };
+ if (acc && typeof acc === "object") {
+ const t = pickNumber(acc, ["total_tokens", "total", "value"]);
+ if (t != null)
+ return {
+ total: t,
+ prompt: pickNumber(acc, ["prompt_tokens"]) || 0,
+ completion: pickNumber(acc, ["completion_tokens"]) || 0,
+ source: "accumulated",
+ };
}
// 3) usage.total_tokens — резервный источник (OpenAI-подобный формат).
const usage = obj.usage;
- if (usage && typeof usage === 'object') {
- const t = pickNumber(usage, ['total_tokens', 'total']);
- if (t != null) return { total: t, prompt: pickNumber(usage, ['prompt_tokens']) || 0, completion: pickNumber(usage, ['completion_tokens']) || 0, source: 'usage' };
+ if (usage && typeof usage === "object") {
+ const t = pickNumber(usage, ["total_tokens", "total"]);
+ if (t != null)
+ return {
+ total: t,
+ prompt: pickNumber(usage, ["prompt_tokens"]) || 0,
+ completion: pickNumber(usage, ["completion_tokens"]) || 0,
+ source: "usage",
+ };
}
return null;
@@ -92,7 +109,7 @@ function extractUsage(obj) {
function pickNumber(obj, keys) {
for (const k of keys) {
const v = obj[k];
- if (typeof v === 'number' && Number.isFinite(v)) return v;
+ if (typeof v === "number" && Number.isFinite(v)) return v;
}
return null;
}
@@ -113,12 +130,30 @@ function publish(usage) {
// Нулевую дельту (стартовый кадр-снимок) не рассылаем — полезны только реальные приросты.
if (delta === 0) return;
try {
- window.dispatchEvent(new CustomEvent(EVENT_NAME, {
- detail: { total: usage.total, delta, prompt: usage.prompt, completion: usage.completion, source: usage.source },
- }));
- } catch (_) { /* CustomEvent недоступен — молча */ }
+ window.dispatchEvent(
+ new CustomEvent(EVENT_NAME, {
+ detail: {
+ total: usage.total,
+ delta,
+ prompt: usage.prompt,
+ completion: usage.completion,
+ source: usage.source,
+ },
+ }),
+ );
+ } catch (_) {
+ /* CustomEvent недоступен — молча */
+ }
try {
- console.log(LOG_PREFIX + ' server tokens: total=' + usage.total + ' delta=' + delta + ' src=' + usage.source);
+ console.log(
+ LOG_PREFIX +
+ " server tokens: total=" +
+ usage.total +
+ " delta=" +
+ delta +
+ " src=" +
+ usage.source,
+ );
} catch (_) {}
}
@@ -129,14 +164,14 @@ function publish(usage) {
* @returns {{total:number, prompt:number, completion:number, source:string}|null}
*/
function parseSseText(text) {
- if (!text || typeof text !== 'string') return null;
+ if (!text || typeof text !== "string") return null;
let found = null;
const lines = text.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
- if (!trimmed || !trimmed.startsWith('data:')) continue;
+ if (!trimmed || !trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
- if (!payload || payload === '[DONE]') continue;
+ if (!payload || payload === "[DONE]") continue;
const parsed = safeJson(payload);
if (!parsed) continue;
const usage = extractUsage(parsed);
@@ -151,7 +186,11 @@ function parseSseText(text) {
* @returns {object|null}
*/
function safeJson(s) {
- try { return JSON.parse(s); } catch (_) { return null; }
+ try {
+ return JSON.parse(s);
+ } catch (_) {
+ return null;
+ }
}
/**
@@ -160,10 +199,34 @@ function safeJson(s) {
* @returns {boolean}
*/
function isCompletionUrl(url) {
- if (!url || typeof url !== 'string') return false;
+ if (!url || typeof url !== "string") return false;
return url.indexOf(COMPLETION_URL_PART) !== -1;
}
+// Антиспам: не чаще одного уведомления о rate-limit в 60 секунд.
+let lastRateLimitNotifyTs = 0;
+const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 60 * 1000;
+
+/**
+ * Уведомить Telegram о превышении rate-limit (429 / IP_ACCESS_RESTRICTED).
+ * @param {{status?:number, retryAfterSec?:number}} d
+ */
+function notifyRateLimit(d) {
+ try {
+ const now = Date.now();
+ if (now - lastRateLimitNotifyTs < RATE_LIMIT_NOTIFY_COOLDOWN_MS) return;
+ lastRateLimitNotifyTs = now;
+ const api = typeof window !== "undefined" ? window.electronAPI : null;
+ if (!api || typeof api.telegramRateLimit !== "function") return;
+ api
+ .telegramRateLimit({
+ status: (d && d.status) || 0,
+ retryAfterSec: (d && d.retryAfterSec) || 0,
+ })
+ .catch(() => {});
+ } catch (_) {}
+}
+
/**
* Установить приём серверных токенов.
*
@@ -183,14 +246,28 @@ function install() {
installed = true;
// Основной канал: postMessage из main world.
- safe('token-interceptor.listenMessage', () => {
- window.addEventListener('message', (e) => {
+ safe("token-interceptor.listenMessage", () => {
+ window.addEventListener("message", (e) => {
try {
const d = e && e.data;
- if (!d || d.__cuckoo !== 'token-update') return;
- if (typeof d.total !== 'number' || !Number.isFinite(d.total)) return;
- const delta = typeof d.delta === 'number' ? d.delta : 0;
- const usage = { total: d.total, delta, prompt: 0, completion: 0, source: d.source || 'accumulated' };
+ if (!d) return;
+
+ // Rate-limit (429 / IP_ACCESS_RESTRICTED=40029) -> уведомление в Telegram.
+ if (d.__cuckoo === "rate-limit") {
+ notifyRateLimit(d);
+ return;
+ }
+
+ if (d.__cuckoo !== "token-update") return;
+ if (typeof d.total !== "number" || !Number.isFinite(d.total)) return;
+ const delta = typeof d.delta === "number" ? d.delta : 0;
+ const usage = {
+ total: d.total,
+ delta,
+ prompt: 0,
+ completion: 0,
+ source: d.source || "accumulated",
+ };
// publish ожидает total и сам считает дельту от lastTotal; чтобы не
// считать дважды, синхронизируем lastTotal из main world.
lastTotal = d.total - delta;
@@ -200,8 +277,8 @@ function install() {
});
// Резерв: патчи в самом preload (сработают только без contextIsolation).
- safe('token-interceptor.patchFetch', () => patchFetch());
- safe('token-interceptor.patchXhr', () => patchXhr());
+ safe("token-interceptor.patchFetch", () => patchFetch());
+ safe("token-interceptor.patchXhr", () => patchXhr());
}
/**
@@ -209,22 +286,26 @@ function install() {
* клонируем ответ и читаем тело как текст (SSE).
*/
function patchFetch() {
- if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
+ if (typeof window === "undefined" || typeof window.fetch !== "function")
+ return;
const originalFetch = window.fetch;
window.fetch = function patchedFetch(input, init) {
- const url = typeof input === 'string' ? input : (input && input.url) || '';
+ const url = typeof input === "string" ? input : (input && input.url) || "";
const p = originalFetch.apply(this, arguments);
if (!isCompletionUrl(url)) return p;
return p.then((response) => {
- safe('token-interceptor.fetchRead', () => {
+ safe("token-interceptor.fetchRead", () => {
// Клонируем, чтобы не «съесть» тело у вызывающего кода.
const clone = response.clone();
- clone.text().then((body) => {
- const usage = parseSseText(body);
- if (usage) publish(usage);
- }).catch(() => {});
+ clone
+ .text()
+ .then((body) => {
+ const usage = parseSseText(body);
+ if (usage) publish(usage);
+ })
+ .catch(() => {});
});
return response;
});
@@ -235,22 +316,28 @@ function patchFetch() {
* Патч XMLHttpRequest.open/send: запоминаем URL, по завершении читаем responseText.
*/
function patchXhr() {
- if (typeof window === 'undefined' || typeof window.XMLHttpRequest !== 'function') return;
+ if (
+ typeof window === "undefined" ||
+ typeof window.XMLHttpRequest !== "function"
+ )
+ return;
const XHR = window.XMLHttpRequest;
const originalOpen = XHR.prototype.open;
const originalSend = XHR.prototype.send;
XHR.prototype.open = function patchedOpen(method, url) {
- try { this.__cuckooUrl = url; } catch (_) {}
+ try {
+ this.__cuckooUrl = url;
+ } catch (_) {}
return originalOpen.apply(this, arguments);
};
XHR.prototype.send = function patchedSend() {
try {
- const url = this.__cuckooUrl || '';
+ const url = this.__cuckooUrl || "";
if (isCompletionUrl(url)) {
- this.addEventListener('load', () => {
- safe('token-interceptor.xhrRead', () => {
+ this.addEventListener("load", () => {
+ safe("token-interceptor.xhrRead", () => {
const body = this.responseText;
const usage = parseSseText(body);
if (usage) publish(usage);
@@ -269,8 +356,12 @@ module.exports = {
_parseSseText: parseSseText,
_isCompletionUrl: isCompletionUrl,
_publish: publish,
- _resetTotal: () => { lastTotal = 0; },
- _setLastTotal: (v) => { lastTotal = v; },
+ _resetTotal: () => {
+ lastTotal = 0;
+ },
+ _setLastTotal: (v) => {
+ lastTotal = v;
+ },
_getLastTotal: () => lastTotal,
EVENT_NAME,
};