From 2024705066b42b07be1b8ea84b69691dfb941ace Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:51:53 +0300 Subject: [PATCH 1/9] fix(safe): catch async errors and add throttled fail stats safe() now awaits promises and falls back on rejection, so a single failing observer/overlay/detector no longer breaks the whole pipeline. Adds safeAsync(), resolveFallback(), per-label fail counters with throttling, and _resetSafeStats() for tests. --- src/preload/dom/safe.js | 128 +++++++++++++++++++++++++++++++++++----- 1 file changed, 114 insertions(+), 14 deletions(-) diff --git a/src/preload/dom/safe.js b/src/preload/dom/safe.js index 7404710..c0dcd33 100644 --- a/src/preload/dom/safe.js +++ b/src/preload/dom/safe.js @@ -4,10 +4,16 @@ * Цель: при изменении вёрстки/селекторов на сайте приложение не падает — * операция просто не применяется, а в консоль пишется предупреждение. * + * ВАЖНО: safe() больше не «роняет» вызывающий пайплайн при async-ошибках — + * rejected Promise перехватывается и резолвится в fallback. + * * Использование: * const { safe, safeWrap } = require('./safe'); * const el = safe('deepseek.findInput', () => provider.findInput(), null); * + * // Async-версия (возвращает Promise, ошибка → fallback): + * await safeAsync('observer.processLatest', () => processLatestAIResponseInner(), undefined); + * * const provider = safeWrap(rawProvider, 'provider:deepseek', { * skip: ['homeUrl', 'sessionUrlBase', 'matchesUrl', 'extractSessionId', 'id', 'name'], * fallback: { findInput: null, findSendButton: null, extractUserInfo: '', ... }, @@ -15,47 +21,124 @@ */ /** Префикс лога. */ -const LOG_PREFIX = '[Cookie Code][safe:'; +const LOG_PREFIX = "[Cookie Code][safe:"; /** * Throttle логов, чтобы observer / interval не спамили одинаковыми ошибками. * Ключ → timestamp последнего сообщения. + * Ключ включает message, чтобы разные ошибки одного label не схлопывались. */ const lastLoggedAt = new Map(); const LOG_THROTTLE_MS = 5000; +/** + * Счётчик падений по label (сколько раз safe() ловил ошибку). + * Используется только для диагностики — поведение не меняет. + */ +const failCounts = new Map(); + /** * Записать предупреждение с троттлингом. * @param {string} label * @param {Error|string} err */ function warnOnce(label, err) { + const msg = err && err.message ? err.message : String(err); + const key = label + "::" + msg; const now = Date.now(); - const prev = lastLoggedAt.get(label) || 0; + const prev = lastLoggedAt.get(key) || 0; + + // Учёт частоты падений по label (не троттлится). + const n = (failCounts.get(label) || 0) + 1; + failCounts.set(label, n); + if (now - prev < LOG_THROTTLE_MS) return; - lastLoggedAt.set(label, now); - const msg = err && err.message ? err.message : String(err); + lastLoggedAt.set(key, now); try { - console.warn(LOG_PREFIX + label + '] ' + msg); + const suffix = n > 1 ? " (x" + n + ")" : ""; + console.warn(LOG_PREFIX + label + "] " + msg + suffix); } catch (_) { // console может быть недоступен — молча игнорируем } } +/** Есть ли у значения then(). */ +function isThenable(v) { + return ( + v && + (typeof v === "object" || typeof v === "function") && + typeof v.then === "function" + ); +} + +/** + * Безопасно вычислить fallback: если это функция — вызвать, иначе вернуть как есть. + * Падение самого fallback не пробрасывается наружу. + * @param {*} fb + * @param {*} thisArg + * @param {any[]} args + */ +function resolveFallback(fb, thisArg, args) { + if (typeof fb !== "function") return fb; + try { + return fb.apply(thisArg, args || []); + } catch (e) { + warnOnce("safe.fallback", e); + return undefined; + } +} + /** * Безопасно выполнить функцию. При исключении — залогировать и вернуть fallback. + * Если функция вернула Promise — ошибка перехватывается и Promise резолвится + * в fallback (никакого unhandledRejection и падения пайплайна). + * * @template T * @param {string} label Метка для логов (например 'force-dark-theme.enforce'). - * @param {() => T} fn Функция без аргументов. + * @param {() => T} fn Функция без аргументов (sync или async). * @param {T} [fallback] Значение по умолчанию при ошибке (null / [] / false / '' / undefined). * @returns {T} */ function safe(label, fn, fallback) { + let result; try { - return fn(); + result = fn(); } catch (err) { warnOnce(label, err); - return fallback; + return resolveFallback(fallback, undefined, []); + } + + // Async: перехватываем rejected Promise и превращаем в fallback. + if (isThenable(result)) { + return result.then( + (v) => v, + (err) => { + warnOnce(label, err); + return resolveFallback(fallback, undefined, []); + }, + ); + } + + return result; +} + +/** + * Async-версия: всегда возвращает Promise. Ошибка (sync или async) → fallback. + * Удобно для мест, где важно гарантировать await-совместимость. + * + * @template T + * @param {string} label + * @param {() => Promise|T} fn + * @param {T} [fallback] + * @returns {Promise} + */ +async function safeAsync(label, fn, fallback) { + try { + const v = fn(); + return isThenable(v) ? await v : v; + } catch (err) { + warnOnce(label, err); + return resolveFallback(fallback, undefined, []); } } @@ -71,7 +154,7 @@ function safe(label, fn, fallback) { * @returns {object} */ function safeWrap(target, labelPrefix, opts) { - if (!target || typeof target !== 'object') return target; + if (!target || typeof target !== "object") return target; const skip = new Set((opts && opts.skip) || []); const fallback = (opts && opts.fallback) || {}; const out = {}; @@ -80,7 +163,7 @@ function safeWrap(target, labelPrefix, opts) { const val = target[key]; // Пропущенные ключи и не-функции копируем как есть. - if (skip.has(key) || typeof val !== 'function') { + if (skip.has(key) || typeof val !== "function") { out[key] = val; continue; } @@ -88,19 +171,36 @@ function safeWrap(target, labelPrefix, opts) { const fb = Object.prototype.hasOwnProperty.call(fallback, key) ? fallback[key] : null; - const label = labelPrefix + '.' + key; + const label = labelPrefix + "." + key; out[key] = function safeWrapped(...args) { + let result; try { - return val.apply(target, args); + result = val.apply(target, args); } catch (err) { warnOnce(label, err); - return typeof fb === 'function' ? fb.apply(target, args) : fb; + return resolveFallback(fb, target, args); + } + if (isThenable(result)) { + return result.then( + (v) => v, + (err) => { + warnOnce(label, err); + return resolveFallback(fb, target, args); + }, + ); } + return result; }; } return out; } -module.exports = { safe, safeWrap, warnOnce }; +/** Сбросить диагностические счётчики (для тестов/перезапуска). */ +function _resetSafeStats() { + lastLoggedAt.clear(); + failCounts.clear(); +} + +module.exports = { safe, safeAsync, safeWrap, warnOnce, _resetSafeStats }; From 16277035a07a9111c8504479b00c20d7dabb9e27 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:52:04 +0300 Subject: [PATCH 2/9] feat(diff): add commit-all button to push full diff into chat New getAllDiff() collects a unified diff of every changed file; exposed via git-diff-all IPC and gitDiffAll() preload API. The diff panel header gets refresh/sparkles actions (SVG instead of emoji) with commitAllToChat() wired to #cuckoo-diff-commit-all, plus i18n strings diff.btn.commitAll*, diff.noDiff, diff.commitAll.notReady. --- src/main/git-diff.js | 262 +++++++---- src/main/ipc.js | 17 + src/preload/api.js | 3 + src/preload/i18n/i18n.js | 13 + src/preload/overlay/diff-panel.js | 726 ++++++++++++++++++++---------- src/preload/overlay/events.js | 6 + src/preload/overlay/template.js | 11 +- 7 files changed, 724 insertions(+), 314 deletions(-) diff --git a/src/main/git-diff.js b/src/main/git-diff.js index 2fce889..8cc955c 100644 --- a/src/main/git-diff.js +++ b/src/main/git-diff.js @@ -2,7 +2,7 @@ * Git-интеграция для панели «Изменения». * Берёт данные из git (status + unified diff), а не из in-memory лога. */ -const { execFile } = require('child_process'); +const { execFile } = require("child_process"); /** * Запустить git-команду. @@ -12,14 +12,19 @@ const { execFile } = require('child_process'); */ function git(cwd, args) { return new Promise((resolve) => { - execFile('git', args, { cwd, maxBuffer: 20 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => { - resolve({ - ok: !err, - stdout: stdout || '', - stderr: stderr || '', - code: err && typeof err.code === 'number' ? err.code : (err ? 1 : 0), - }); - }); + execFile( + "git", + args, + { cwd, maxBuffer: 20 * 1024 * 1024, windowsHide: true }, + (err, stdout, stderr) => { + resolve({ + ok: !err, + stdout: stdout || "", + stderr: stderr || "", + code: err && typeof err.code === "number" ? err.code : err ? 1 : 0, + }); + }, + ); }); } @@ -28,10 +33,15 @@ function git(cwd, args) { * @returns {Promise<{isRepo:boolean, root:string|null, reason?:string}>} */ async function checkRepo(projectDir) { - if (!projectDir) return { isRepo: false, root: null, reason: 'Проект не инициализирован' }; - const r = await git(projectDir, ['rev-parse', '--show-toplevel']); + if (!projectDir) + return { isRepo: false, root: null, reason: "Проект не инициализирован" }; + const r = await git(projectDir, ["rev-parse", "--show-toplevel"]); if (!r.ok) { - return { isRepo: false, root: null, reason: r.stderr.trim() || 'Не git-репозиторий' }; + return { + isRepo: false, + root: null, + reason: r.stderr.trim() || "Не git-репозиторий", + }; } return { isRepo: true, root: r.stdout.trim() }; } @@ -49,19 +59,19 @@ function parseStatus(stdout) { let rest = raw.slice(3); let origPath = null; // Переименования: "R old -> new" - const arrow = rest.indexOf(' -> '); - if (arrow !== -1 && (x === 'R' || y === 'R')) { - origPath = rest.slice(0, arrow).replace(/^"|"$/g, ''); + const arrow = rest.indexOf(" -> "); + if (arrow !== -1 && (x === "R" || y === "R")) { + origPath = rest.slice(0, arrow).replace(/^"|"$/g, ""); rest = rest.slice(arrow + 4); } - const path = rest.replace(/^"|"$/g, ''); + const path = rest.replace(/^"|"$/g, ""); let status; - if (x === '?' && y === '?') status = 'untracked'; - else if (x === 'A') status = 'added'; - else if (x === 'D' || y === 'D') status = 'deleted'; - else if (x === 'R') status = 'renamed'; - else if (x === 'M' || y === 'M') status = 'modified'; - else status = 'changed'; + if (x === "?" && y === "?") status = "untracked"; + else if (x === "A") status = "added"; + else if (x === "D" || y === "D") status = "deleted"; + else if (x === "R") status = "renamed"; + else if (x === "M" || y === "M") status = "modified"; + else status = "changed"; out.push({ path, origPath, index: x, worktree: y, status }); } return out; @@ -73,29 +83,37 @@ function parseStatus(stdout) { */ async function getStatus(projectDir) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; - const r = await git(repo.root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git status failed' }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; + const r = await git(repo.root, [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git status failed" }; // -z формат: записи разделены NUL; переименования содержат два NUL-поля - const parts = r.stdout.split('\u0000').filter(Boolean); + const parts = r.stdout.split("\u0000").filter(Boolean); const files = []; for (let i = 0; i < parts.length; i++) { const rec = parts[i]; - const x = rec[0], y = rec[1]; + const x = rec[0], + y = rec[1]; let path = rec.slice(3); let origPath = null; - if (x === 'R' || x === 'C') { + if (x === "R" || x === "C") { // следующий элемент — исходный путь origPath = parts[++i] || null; } let status; - if (x === '?' && y === '?') status = 'untracked'; - else if (x === 'A') status = 'added'; - else if (x === 'D' || y === 'D') status = 'deleted'; - else if (x === 'R') status = 'renamed'; - else if (x === 'M' || y === 'M') status = 'modified'; - else status = 'changed'; + if (x === "?" && y === "?") status = "untracked"; + else if (x === "A") status = "added"; + else if (x === "D" || y === "D") status = "deleted"; + else if (x === "R") status = "renamed"; + else if (x === "M" || y === "M") status = "modified"; + else status = "changed"; files.push({ path, origPath, index: x, worktree: y, status }); } return { success: true, root: repo.root, files }; @@ -111,31 +129,56 @@ async function getStatus(projectDir) { */ async function getFileDiff(projectDir, filePath, status) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; - if (status === 'untracked') { + if (status === "untracked") { // /dev/null через git показываем как новый файл - const r = await git(repo.root, ['diff', '--no-index', '--no-color', '--', process.platform === 'win32' ? 'NUL' : '/dev/null', filePath]); + const r = await git(repo.root, [ + "diff", + "--no-index", + "--no-color", + "--", + process.platform === "win32" ? "NUL" : "/dev/null", + filePath, + ]); // --no-index возвращает код 1 при наличии различий — это нормально - const diff = r.stdout || ''; + const diff = r.stdout || ""; if (diff) return { success: true, diff }; // fallback: просто показать содержимое файла как «+» строки try { - const fs = require('fs'); - const path = require('path'); + const fs = require("fs"); + const path = require("path"); const abs = path.join(repo.root, filePath); - const content = fs.readFileSync(abs, 'utf-8'); + const content = fs.readFileSync(abs, "utf-8"); const lines = content.split(/\r?\n/); - const fake = ['diff --git a/' + filePath + ' b/' + filePath, 'new file mode 100644', '--- /dev/null', '+++ b/' + filePath, '@@ -0,0 +1,' + lines.length + ' @@'] - .concat(lines.map(l => '+' + l)).join('\n'); + const fake = [ + "diff --git a/" + filePath + " b/" + filePath, + "new file mode 100644", + "--- /dev/null", + "+++ b/" + filePath, + "@@ -0,0 +1," + lines.length + " @@", + ] + .concat(lines.map((l) => "+" + l)) + .join("\n"); return { success: true, diff: fake }; } catch (e) { - return { success: false, reason: 'Не удалось прочитать файл: ' + e.message }; + return { + success: false, + reason: "Не удалось прочитать файл: " + e.message, + }; } } - const r = await git(repo.root, ['diff', 'HEAD', '--no-color', '--', filePath]); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git diff failed' }; + const r = await git(repo.root, [ + "diff", + "HEAD", + "--no-color", + "--", + filePath, + ]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git diff failed" }; return { success: true, diff: r.stdout }; } @@ -147,16 +190,34 @@ async function getFileDiff(projectDir, filePath, status) { */ async function getLog(projectDir, limit) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; const n = Math.max(1, Math.min(200, limit || 20)); // Разделители: RS (0x1e) между записями, US (0x1f) между полями - const fmt = '%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1e'; - const r = await git(repo.root, ['log', '-n', String(n), '--date=iso', '--pretty=format:' + fmt]); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git log failed' }; - const commits = r.stdout.split('\x1e').map(s => s.trim()).filter(Boolean).map(rec => { - const p = rec.split('\x1f'); - return { hash: p[0] || '', short: p[1] || '', author: p[2] || '', date: p[3] || '', subject: p[4] || '' }; - }); + const fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1e"; + const r = await git(repo.root, [ + "log", + "-n", + String(n), + "--date=iso", + "--pretty=format:" + fmt, + ]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git log failed" }; + const commits = r.stdout + .split("\x1e") + .map((s) => s.trim()) + .filter(Boolean) + .map((rec) => { + const p = rec.split("\x1f"); + return { + hash: p[0] || "", + short: p[1] || "", + author: p[2] || "", + date: p[3] || "", + subject: p[4] || "", + }; + }); return { success: true, root: repo.root, commits }; } @@ -168,10 +229,18 @@ async function getLog(projectDir, limit) { */ async function getCommitDiff(projectDir, hash) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; - if (!hash) return { success: false, reason: 'hash is required' }; - const r = await git(repo.root, ['show', '--no-color', '--stat', '--patch', hash]); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git show failed' }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; + if (!hash) return { success: false, reason: "hash is required" }; + const r = await git(repo.root, [ + "show", + "--no-color", + "--stat", + "--patch", + hash, + ]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git show failed" }; return { success: true, diff: r.stdout }; } @@ -181,22 +250,29 @@ async function getCommitDiff(projectDir, hash) { */ async function getCommitFiles(projectDir, hash) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; - if (!hash) return { success: false, reason: 'hash is required' }; - const r = await git(repo.root, ['show', '--name-status', '--pretty=format:', hash]); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git show failed' }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; + if (!hash) return { success: false, reason: "hash is required" }; + const r = await git(repo.root, [ + "show", + "--name-status", + "--pretty=format:", + hash, + ]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git show failed" }; const files = []; for (const line of r.stdout.split(/\r?\n/)) { if (!line.trim()) continue; const parts = line.split(/\t/); - const code = (parts[0] || '').trim(); - let path = (parts[1] || '').trim(); - if (code.startsWith('R') && parts[2]) path = parts[2].trim(); - let status = 'changed'; - if (code.startsWith('A')) status = 'added'; - else if (code.startsWith('M')) status = 'modified'; - else if (code.startsWith('D')) status = 'deleted'; - else if (code.startsWith('R')) status = 'renamed'; + const code = (parts[0] || "").trim(); + let path = (parts[1] || "").trim(); + if (code.startsWith("R") && parts[2]) path = parts[2].trim(); + let status = "changed"; + if (code.startsWith("A")) status = "added"; + else if (code.startsWith("M")) status = "modified"; + else if (code.startsWith("D")) status = "deleted"; + else if (code.startsWith("R")) status = "renamed"; files.push({ status, path }); } return { success: true, files }; @@ -208,12 +284,46 @@ async function getCommitFiles(projectDir, hash) { */ async function getCommitFileDiff(projectDir, hash, filePath) { const repo = await checkRepo(projectDir); - if (!repo.isRepo) return { success: false, reason: 'git не найден: ' + (repo.reason || '') }; - if (!hash) return { success: false, reason: 'hash is required' }; - if (!filePath) return { success: false, reason: 'filePath is required' }; - const r = await git(repo.root, ['show', '--no-color', hash, '--', filePath]); - if (!r.ok) return { success: false, reason: r.stderr.trim() || 'git show failed' }; + if (!repo.isRepo) + return { success: false, reason: "git не найден: " + (repo.reason || "") }; + if (!hash) return { success: false, reason: "hash is required" }; + if (!filePath) return { success: false, reason: "filePath is required" }; + const r = await git(repo.root, ["show", "--no-color", hash, "--", filePath]); + if (!r.ok) + return { success: false, reason: r.stderr.trim() || "git show failed" }; return { success: true, diff: r.stdout }; } -module.exports = { checkRepo, getStatus, getFileDiff, parseStatus, getLog, getCommitDiff, getCommitFiles, getCommitFileDiff }; +/** + * Получить объединённый unified diff всех изменённых файлов (status + worktree). + * Используется кнопкой «Вставить все diff в чат». + * @param {string} projectDir + * @returns {Promise<{success:boolean, diff?:string, files?:Array, reason?:string}>} + */ +async function getAllDiff(projectDir) { + const st = await getStatus(projectDir); + if (!st.success) return { success: false, reason: st.reason }; + const files = st.files || []; + if (files.length === 0) return { success: true, diff: "", files: [] }; + + const parts = []; + for (const f of files) { + const r = await getFileDiff(projectDir, f.path, f.status); + if (r && r.success && r.diff && r.diff.trim()) { + parts.push(r.diff.trimEnd()); + } + } + return { success: true, diff: parts.join("\n\n"), files }; +} + +module.exports = { + checkRepo, + getStatus, + getFileDiff, + getAllDiff, + parseStatus, + getLog, + getCommitDiff, + getCommitFiles, + getCommitFileDiff, +}; diff --git a/src/main/ipc.js b/src/main/ipc.js index 3e67a75..70295ae 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1553,6 +1553,23 @@ function registerIpcHandlers() { } }); + // Объединённый diff всех изменённых файлов (для кнопки «Вставить все diff в чат»). + ipcMain.handle("git-diff-all", async (event) => { + try { + const ctx = windowState.getContextByWebContents(event.sender); + const projectDir = + ctx && ctx.sessionStore && ctx.sessionStore.state.selectedProjectDir; + if (!projectDir) + return { + success: false, + reason: "git не найден: проект не инициализирован", + }; + return await gitDiff.getAllDiff(projectDir); + } catch (err) { + return { success: false, reason: err.message }; + } + }); + // ========== Git history (вкладка «История») ========== ipcMain.handle("git-log", async (event, { limit } = {}) => { try { diff --git a/src/preload/api.js b/src/preload/api.js index 3495140..3b3b3eb 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -265,6 +265,9 @@ let electronAPI = { gitDiffFile: (filePath, status) => { return ipcRenderer.invoke("git-diff-file", { filePath, status }); }, + gitDiffAll: () => { + return ipcRenderer.invoke("git-diff-all"); + }, gitLog: (limit) => { return ipcRenderer.invoke("git-log", { limit }); }, diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 92a0583..dad4828 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -922,6 +922,19 @@ const KEYS = { }, "diff.error": { ru: "Ошибка", en: "Error" }, "diff.errorPrefix": { ru: "Ошибка: {msg}", en: "Error: {msg}" }, + "diff.btn.commitAll": { ru: "Коммит", en: "Commit" }, + "diff.btn.commitAll.title": { + ru: "Вставить все diff в чат и попросить AI закоммитить", + en: "Paste all diffs to chat and ask AI to commit", + }, + "diff.noDiff": { + ru: "Нет изменённых файлов для коммита", + en: "No changed files to commit", + }, + "diff.commitAll.notReady": { + ru: "Не удалось собрать diff", + en: "Failed to collect diff", + }, // ---- Todo-панель ---- "todo.btn.hide": { ru: "Скрыть", en: "Hide" }, diff --git a/src/preload/overlay/diff-panel.js b/src/preload/overlay/diff-panel.js index c92b5a4..926fcff 100644 --- a/src/preload/overlay/diff-panel.js +++ b/src/preload/overlay/diff-panel.js @@ -3,7 +3,7 @@ * - Изменения: список изменённых файлов (git status) → клик → окно с unified diff. * - История: список последних коммитов → клик → файлы коммита → клик → окно с diff; * кнопка «Весь коммит» показывает полный diff коммита. - * + * * Улучшения: * - Навигация по hunks (кнопки + хоткеи n/p) * - Внутристроковый diff для замен @@ -14,15 +14,15 @@ * - Кэширование результатов * - Виртуализация для больших diff */ -const { showToast } = require('./ui'); -const { t } = require('../i18n/i18n'); +const { showToast } = require("./ui"); +const { t } = require("../i18n/i18n"); -const PANEL_ID = 'cuckoo-diff-panel'; -const LIST_ID = 'cuckoo-diff-list'; -const LOG_LIST_ID = 'cuckoo-git-log-list'; -const COMMIT_FILES_ID = 'cuckoo-commit-files'; -const COMMIT_FILES_LIST_ID = 'cuckoo-commit-files-list'; -const VIEWER_ID = 'cuckoo-diff-viewer'; +const PANEL_ID = "cuckoo-diff-panel"; +const LIST_ID = "cuckoo-diff-list"; +const LOG_LIST_ID = "cuckoo-git-log-list"; +const COMMIT_FILES_ID = "cuckoo-commit-files"; +const COMMIT_FILES_LIST_ID = "cuckoo-commit-files-list"; +const VIEWER_ID = "cuckoo-diff-viewer"; const COMMITS_LIMIT = 20; const VIRTUAL_ROW_HEIGHT = 20; // px для виртуализации @@ -35,37 +35,45 @@ let currentHunkIndex = -1; // индекс текущего hunk let diffFilters = { showAdded: true, showDeleted: true, showContext: true }; // фильтры function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text == null ? '' : String(text); + const div = document.createElement("div"); + div.textContent = text == null ? "" : String(text); return div.innerHTML; } function statusLabel(status) { switch (status) { - case 'modified': return { txt: 'M', cls: 'modified' }; - case 'added': return { txt: 'A', cls: 'added' }; - case 'deleted': return { txt: 'D', cls: 'deleted' }; - case 'renamed': return { txt: 'R', cls: 'renamed' }; - case 'untracked': return { txt: 'U', cls: 'untracked' }; - default: return { txt: '?', cls: 'changed' }; + case "modified": + return { txt: "M", cls: "modified" }; + case "added": + return { txt: "A", cls: "added" }; + case "deleted": + return { txt: "D", cls: "deleted" }; + case "renamed": + return { txt: "R", cls: "renamed" }; + case "untracked": + return { txt: "U", cls: "untracked" }; + default: + return { txt: "?", cls: "changed" }; } } // ================= Вкладки ================= function setActiveTab(tab) { - const tChanges = document.getElementById('cuckoo-diff-tab-changes'); - const tHistory = document.getElementById('cuckoo-diff-tab-history'); + const tChanges = document.getElementById("cuckoo-diff-tab-changes"); + const tHistory = document.getElementById("cuckoo-diff-tab-history"); const bodyChanges = document.getElementById(LIST_ID); const bodyHistory = document.getElementById(LOG_LIST_ID); const commitFiles = document.getElementById(COMMIT_FILES_ID); - const isChanges = tab === 'changes'; - tChanges?.classList.toggle('active', isChanges); - tHistory?.classList.toggle('active', !isChanges); - bodyChanges?.classList.toggle('cuckoo-hidden', !isChanges); - if (bodyHistory) bodyHistory.classList.toggle('cuckoo-hidden', isChanges || !!currentCommit); - if (commitFiles) commitFiles.classList.toggle('cuckoo-hidden', isChanges || !currentCommit); + const isChanges = tab === "changes"; + tChanges?.classList.toggle("active", isChanges); + tHistory?.classList.toggle("active", !isChanges); + bodyChanges?.classList.toggle("cuckoo-hidden", !isChanges); + if (bodyHistory) + bodyHistory.classList.toggle("cuckoo-hidden", isChanges || !!currentCommit); + if (commitFiles) + commitFiles.classList.toggle("cuckoo-hidden", isChanges || !currentCommit); if (isChanges) { currentCommit = null; @@ -80,46 +88,120 @@ function setActiveTab(tab) { async function renderDiffList() { const list = document.getElementById(LIST_ID); if (!list) return; - list.innerHTML = '
' + t('diff.loading') + '
'; + list.innerHTML = + '
' + t("diff.loading") + "
"; try { const res = await window.electronAPI.gitStatus(); if (!res || !res.success) { - list.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.gitNotFound')) + '
'; + list.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.gitNotFound")) + + "
"; return; } const files = res.files || []; if (files.length === 0) { - list.innerHTML = '
' + t('diff.noChanges') + '
'; + list.innerHTML = + '
' + t("diff.noChanges") + "
"; return; } // Статистика - let totalAdded = 0, totalDeleted = 0; + let totalAdded = 0, + totalDeleted = 0; if (res.stats) { totalAdded = res.stats.added || 0; totalDeleted = res.stats.deleted || 0; } - const statsHtml = totalAdded || totalDeleted - ? `
📊 +${totalAdded} -${totalDeleted} в ${files.length} файл${files.length > 1 ? 'ах' : 'е'}
` - : ''; - - list.innerHTML = statsHtml + files.map(f => { - const s = statusLabel(f.status); - return '
' + - '' + s.txt + '' + - '' + escapeHtml(f.path) + '' + - '
'; - }).join(''); - - list.querySelectorAll('.cuckoo-diff-file').forEach(el => { - el.addEventListener('click', () => { - list.querySelectorAll('.cuckoo-diff-file').forEach(x => x.classList.remove('active')); - el.classList.add('active'); + const statsHtml = + totalAdded || totalDeleted + ? `
📊 +${totalAdded} -${totalDeleted} в ${files.length} файл${files.length > 1 ? "ах" : "е"}
` + : ""; + + list.innerHTML = + statsHtml + + files + .map((f) => { + const s = statusLabel(f.status); + return ( + '
' + + '' + + s.txt + + "" + + '' + + escapeHtml(f.path) + + "" + + "
" + ); + }) + .join(""); + + list.querySelectorAll(".cuckoo-diff-file").forEach((el) => { + el.addEventListener("click", () => { + list + .querySelectorAll(".cuckoo-diff-file") + .forEach((x) => x.classList.remove("active")); + el.classList.add("active"); openDiffViewer(el.dataset.path, el.dataset.status); }); }); } catch (err) { - list.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + list.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; + } +} + +// ================= Кнопка «Коммит»: все diff в чат ================= + +/** + * Собрать unified diff всех изменённых файлов и вставить его в чат + * вместе с просьбой закоммитить (через агента или самому). + * Минималистичная кнопка ✓ в шапке панели diff. + */ +async function commitAllToChat() { + const { t } = require("../i18n/i18n"); + const { sendMessageToChat } = require("../dom/chat-input"); + try { + const res = await window.electronAPI.gitDiffAll(); + if (!res || !res.success) { + showToast((res && res.reason) || t("diff.commitAll.notReady"), 3000); + return; + } + const diff = (res.diff || "").trim(); + if (!diff) { + showToast(t("diff.noDiff"), 3000); + return; + } + const filesCount = (res.files || []).length; + const msg = [ + "Закоммить изменения.", + "", + "Можешь вызвать агента (git-committer) и поручить коммит ему,", + "или сделай коммит сам — как удобнее. Осмысленный commit message", + "в Conventional Commits, затем push.", + "", + "Изменённые файлы (" + filesCount + "):", + (res.files || []) + .map((f) => "- [" + (f.status || "?") + "] " + f.path) + .join("\n"), + "", + "```diff", + diff, + "```", + ].join("\n"); + sendMessageToChat(msg, "commit-all"); + } catch (err) { + showToast(t("diff.commitAll.notReady") + ": " + (err.message || err), 3000); } } @@ -128,32 +210,55 @@ async function renderDiffList() { async function renderGitLog() { const list = document.getElementById(LOG_LIST_ID); if (!list) return; - list.innerHTML = '
' + t('diff.loading') + '
'; + list.innerHTML = + '
' + t("diff.loading") + "
"; try { const res = await window.electronAPI.gitLog(COMMITS_LIMIT); if (!res || !res.success) { - list.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.gitNotFound')) + '
'; + list.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.gitNotFound")) + + "
"; return; } const commits = res.commits || []; if (commits.length === 0) { - list.innerHTML = '
' + t('diff.noCommits') + '
'; + list.innerHTML = + '
' + t("diff.noCommits") + "
"; return; } - list.innerHTML = commits.map(c => - '
' + - '' + escapeHtml(c.subject) + '' + - '' + escapeHtml(c.short) + ' · ' + escapeHtml(c.author) + ' · ' + escapeHtml(c.date) + '' + - '
' - ).join(''); - - list.querySelectorAll('.cuckoo-commit-item').forEach(el => { - el.addEventListener('click', () => { + list.innerHTML = commits + .map( + (c) => + '
' + + '' + + escapeHtml(c.subject) + + "" + + '' + + escapeHtml(c.short) + + " · " + + escapeHtml(c.author) + + " · " + + escapeHtml(c.date) + + "" + + "
", + ) + .join(""); + + list.querySelectorAll(".cuckoo-commit-item").forEach((el) => { + el.addEventListener("click", () => { openCommitFiles(el.dataset.hash, el.dataset.subject); }); }); } catch (err) { - list.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + list.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; } } @@ -163,41 +268,66 @@ async function openCommitFiles(hash, subject) { const logList = document.getElementById(LOG_LIST_ID); const commitFiles = document.getElementById(COMMIT_FILES_ID); const filesList = document.getElementById(COMMIT_FILES_LIST_ID); - const subjectEl = document.getElementById('cuckoo-commit-subject'); + const subjectEl = document.getElementById("cuckoo-commit-subject"); - if (logList) logList.classList.add('cuckoo-hidden'); - if (commitFiles) commitFiles.classList.remove('cuckoo-hidden'); - if (subjectEl) subjectEl.textContent = subject || ''; - if (filesList) filesList.innerHTML = '
' + t('diff.loading') + '
'; + if (logList) logList.classList.add("cuckoo-hidden"); + if (commitFiles) commitFiles.classList.remove("cuckoo-hidden"); + if (subjectEl) subjectEl.textContent = subject || ""; + if (filesList) + filesList.innerHTML = + '
' + t("diff.loading") + "
"; try { const res = await window.electronAPI.gitCommitFiles(hash); if (!res || !res.success) { - filesList.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.error')) + '
'; + filesList.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.error")) + + "
"; return; } const files = res.files || []; if (files.length === 0) { - filesList.innerHTML = '
' + t('diff.noFiles') + '
'; + filesList.innerHTML = + '
' + t("diff.noFiles") + "
"; return; } - filesList.innerHTML = files.map(f => { - const s = statusLabel(f.status); - return '
' + - '' + s.txt + '' + - '' + escapeHtml(f.path) + '' + - '
'; - }).join(''); - - filesList.querySelectorAll('.cuckoo-diff-file').forEach(el => { - el.addEventListener('click', async () => { - filesList.querySelectorAll('.cuckoo-diff-file').forEach(x => x.classList.remove('active')); - el.classList.add('active'); + filesList.innerHTML = files + .map((f) => { + const s = statusLabel(f.status); + return ( + '
' + + '' + + s.txt + + "" + + '' + + escapeHtml(f.path) + + "" + + "
" + ); + }) + .join(""); + + filesList.querySelectorAll(".cuckoo-diff-file").forEach((el) => { + el.addEventListener("click", async () => { + filesList + .querySelectorAll(".cuckoo-diff-file") + .forEach((x) => x.classList.remove("active")); + el.classList.add("active"); await openCommitFileViewer(currentCommit.hash, el.dataset.path); }); }); } catch (err) { - filesList.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + filesList.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; } } @@ -206,8 +336,8 @@ function backToLog() { currentCommit = null; const logList = document.getElementById(LOG_LIST_ID); const commitFiles = document.getElementById(COMMIT_FILES_ID); - if (logList) logList.classList.remove('cuckoo-hidden'); - if (commitFiles) commitFiles.classList.add('cuckoo-hidden'); + if (logList) logList.classList.remove("cuckoo-hidden"); + if (commitFiles) commitFiles.classList.add("cuckoo-hidden"); } // ================= Внутристроковый diff ================= @@ -243,7 +373,10 @@ function intraLineDiff(oldLine, newLine) { } } } - return [{ type: 'delete', text: oldLine }, { type: 'insert', text: newLine }]; + return [ + { type: "delete", text: oldLine }, + { type: "insert", text: newLine }, + ]; } function backtrackIntra(trace, oldLine, newLine, d, k, max) { @@ -253,22 +386,27 @@ function backtrackIntra(trace, oldLine, newLine, d, k, max) { for (let depth = d; depth >= 0; depth--) { const v = trace[depth]; - const kPrev = depth === 0 ? 0 : (k === -depth || (k !== depth && v[k - 1 + max] < v[k + 1 + max])) ? k + 1 : k - 1; + const kPrev = + depth === 0 + ? 0 + : k === -depth || (k !== depth && v[k - 1 + max] < v[k + 1 + max]) + ? k + 1 + : k - 1; const xPrev = depth === 0 ? 0 : v[kPrev + max]; const yPrev = xPrev - kPrev; while (x > xPrev && y > yPrev) { - result.unshift({ type: 'equal', text: oldLine[x - 1] }); + result.unshift({ type: "equal", text: oldLine[x - 1] }); x--; y--; } if (depth > 0) { if (x > xPrev) { - result.unshift({ type: 'delete', text: oldLine[x - 1] }); + result.unshift({ type: "delete", text: oldLine[x - 1] }); x--; } else { - result.unshift({ type: 'insert', text: newLine[y - 1] }); + result.unshift({ type: "insert", text: newLine[y - 1] }); y--; } } @@ -280,10 +418,19 @@ function backtrackIntra(trace, oldLine, newLine, d, k, max) { } function renderIntraLine(chunks) { - return chunks.map(ch => { - const cls = ch.type === 'insert' ? 'cuckoo-diff-intra-add' : ch.type === 'delete' ? 'cuckoo-diff-intra-del' : ''; - return cls ? '' + escapeHtml(ch.text) + '' : escapeHtml(ch.text); - }).join(''); + return chunks + .map((ch) => { + const cls = + ch.type === "insert" + ? "cuckoo-diff-intra-add" + : ch.type === "delete" + ? "cuckoo-diff-intra-del" + : ""; + return cls + ? '' + escapeHtml(ch.text) + "" + : escapeHtml(ch.text); + }) + .join(""); } // ================= Окно просмотра diff ================= @@ -292,25 +439,36 @@ function parseDiffLines(diffText) { if (!diffText || !diffText.trim()) return []; const lines = diffText.split(/\r?\n/); const parsed = []; - + for (let i = 0; i < lines.length; i++) { const line = lines[i]; - let cls = 'ctx'; - if (line.startsWith('+++') || line.startsWith('---')) cls = 'meta'; - else if (line.startsWith('@@')) cls = 'hunk'; - else if (line.startsWith('commit ') || line.startsWith('Author:') || line.startsWith('Date:') || line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity') || line.startsWith('rename ')) cls = 'meta'; - else if (line.startsWith('+')) cls = 'add'; - else if (line.startsWith('-')) cls = 'del'; - + let cls = "ctx"; + if (line.startsWith("+++") || line.startsWith("---")) cls = "meta"; + else if (line.startsWith("@@")) cls = "hunk"; + else if ( + line.startsWith("commit ") || + line.startsWith("Author:") || + line.startsWith("Date:") || + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("similarity") || + line.startsWith("rename ") + ) + cls = "meta"; + else if (line.startsWith("+")) cls = "add"; + else if (line.startsWith("-")) cls = "del"; + parsed.push({ text: line, cls, index: i }); } - + return parsed; } function renderUnifiedDiff(diffText) { if (!diffText || !diffText.trim()) { - return '
' + t('diff.empty') + '
'; + return '
' + t("diff.empty") + "
"; } currentDiffLines = parseDiffLines(diffText); @@ -320,15 +478,28 @@ function renderUnifiedDiff(diffText) { const enhanced = []; for (let i = 0; i < currentDiffLines.length; i++) { const cur = currentDiffLines[i]; - if (cur.cls === 'del' && i + 1 < currentDiffLines.length && currentDiffLines[i + 1].cls === 'add') { + if ( + cur.cls === "del" && + i + 1 < currentDiffLines.length && + currentDiffLines[i + 1].cls === "add" + ) { const next = currentDiffLines[i + 1]; const oldLine = cur.text.slice(1); const newLine = next.text.slice(1); - - if (oldLine.length > 0 && newLine.length > 0 && oldLine.length < 200 && newLine.length < 200) { + + if ( + oldLine.length > 0 && + newLine.length > 0 && + oldLine.length < 200 && + newLine.length < 200 + ) { const chunks = intraLineDiff(oldLine, newLine); - enhanced.push({ ...cur, intra: chunks, prefix: '-' }); - enhanced.push({ ...next, intra: chunks.filter(c => c.type !== 'delete'), prefix: '+' }); + enhanced.push({ ...cur, intra: chunks, prefix: "-" }); + enhanced.push({ + ...next, + intra: chunks.filter((c) => c.type !== "delete"), + prefix: "+", + }); i++; // skip next continue; } @@ -336,49 +507,69 @@ function renderUnifiedDiff(diffText) { enhanced.push({ ...cur, intra: null }); } - let html = enhanced.map((item, idx) => { - const visible = shouldShowLine(item.cls); - const style = visible ? '' : ' style="display:none"'; - const text = item.intra - ? (item.prefix || '') + renderIntraLine(item.intra) - : '' + escapeHtml(item.text) + ''; - return '
' + text + '
'; - }).join(''); - - return '
' + renderControls() + '
' + - '
' + - '
' + - '
' + html + '
' + - '
'; + let html = enhanced + .map((item, idx) => { + const visible = shouldShowLine(item.cls); + const style = visible ? "" : ' style="display:none"'; + const text = item.intra + ? (item.prefix || "") + renderIntraLine(item.intra) + : '' + escapeHtml(item.text) + ""; + return ( + '
" + + text + + "
" + ); + }) + .join(""); + + return ( + '
' + + renderControls() + + "
" + + '
' + + '
' + + '
' +
+    html +
+    "
" + + "
" + ); } function renderControls() { - return '
' + + return ( + '
' + '' + '-' + '' + - '
' + + "
" + '
' + '' + '' + '' + - '
' + - ''; + "" + + '' + ); } function shouldShowLine(cls) { - if (cls === 'add') return diffFilters.showAdded; - if (cls === 'del') return diffFilters.showDeleted; - if (cls === 'ctx') return diffFilters.showContext; + if (cls === "add") return diffFilters.showAdded; + if (cls === "del") return diffFilters.showDeleted; + if (cls === "ctx") return diffFilters.showContext; return true; // meta, hunk всегда показывать } function applyFilters() { const viewer = document.getElementById(VIEWER_ID); if (!viewer) return; - viewer.querySelectorAll('.cuckoo-diff-line').forEach(line => { + viewer.querySelectorAll(".cuckoo-diff-line").forEach((line) => { const cls = line.classList[1]; // 'add', 'del', 'ctx', etc - line.style.display = shouldShowLine(cls) ? '' : 'none'; + line.style.display = shouldShowLine(cls) ? "" : "none"; }); updateMinimap(); } @@ -388,61 +579,73 @@ function attachViewerHandlers() { if (!viewer) return; // Навигация - const prevBtn = viewer.querySelector('#cuckoo-diff-prev'); - const nextBtn = viewer.querySelector('#cuckoo-diff-next'); - - prevBtn?.addEventListener('click', () => navigateHunk(-1)); - nextBtn?.addEventListener('click', () => navigateHunk(1)); + const prevBtn = viewer.querySelector("#cuckoo-diff-prev"); + const nextBtn = viewer.querySelector("#cuckoo-diff-next"); + + prevBtn?.addEventListener("click", () => navigateHunk(-1)); + nextBtn?.addEventListener("click", () => navigateHunk(1)); // Фильтры - viewer.querySelector('#cuckoo-filter-add')?.addEventListener('change', e => { - diffFilters.showAdded = e.target.checked; - applyFilters(); - }); - viewer.querySelector('#cuckoo-filter-del')?.addEventListener('change', e => { - diffFilters.showDeleted = e.target.checked; - applyFilters(); - }); - viewer.querySelector('#cuckoo-filter-ctx')?.addEventListener('change', e => { - diffFilters.showContext = e.target.checked; - applyFilters(); - }); + viewer + .querySelector("#cuckoo-filter-add") + ?.addEventListener("change", (e) => { + diffFilters.showAdded = e.target.checked; + applyFilters(); + }); + viewer + .querySelector("#cuckoo-filter-del") + ?.addEventListener("change", (e) => { + diffFilters.showDeleted = e.target.checked; + applyFilters(); + }); + viewer + .querySelector("#cuckoo-filter-ctx") + ?.addEventListener("change", (e) => { + diffFilters.showContext = e.target.checked; + applyFilters(); + }); // Копирование - viewer.querySelector('#cuckoo-diff-copy')?.addEventListener('click', () => { - const lines = currentDiffLines.map(l => l.text).join('\n'); - navigator.clipboard.writeText(lines).then(() => { - showToast('Diff copied', 2000); - }).catch(() => { - showToast('Copy failed', 2000); - }); + viewer.querySelector("#cuckoo-diff-copy")?.addEventListener("click", () => { + const lines = currentDiffLines.map((l) => l.text).join("\n"); + navigator.clipboard + .writeText(lines) + .then(() => { + showToast("Diff copied", 2000); + }) + .catch(() => { + showToast("Copy failed", 2000); + }); }); // Клик на строку → копировать без префикса - viewer.querySelectorAll('.cuckoo-diff-line').forEach(line => { - line.addEventListener('click', () => { - const text = line.textContent || ''; - const clean = text.startsWith('+') || text.startsWith('-') ? text.slice(1) : text; + viewer.querySelectorAll(".cuckoo-diff-line").forEach((line) => { + line.addEventListener("click", () => { + const text = line.textContent || ""; + const clean = + text.startsWith("+") || text.startsWith("-") ? text.slice(1) : text; navigator.clipboard.writeText(clean).catch(() => {}); - line.classList.add('cuckoo-diff-line-copied'); - setTimeout(() => line.classList.remove('cuckoo-diff-line-copied'), 500); + line.classList.add("cuckoo-diff-line-copied"); + setTimeout(() => line.classList.remove("cuckoo-diff-line-copied"), 500); }); }); // Хоткеи const keyHandler = (e) => { - if (e.key === 'n') navigateHunk(1); - if (e.key === 'p') navigateHunk(-1); + if (e.key === "n") navigateHunk(1); + if (e.key === "p") navigateHunk(-1); }; - viewer.addEventListener('keydown', keyHandler); - viewer.dataset.keyAttached = 'true'; + viewer.addEventListener("keydown", keyHandler); + viewer.dataset.keyAttached = "true"; // Мини-карта updateMinimap(); } function navigateHunk(direction) { - const hunks = currentDiffLines.map((l, i) => ({ ...l, i })).filter(l => l.cls === 'hunk'); + const hunks = currentDiffLines + .map((l, i) => ({ ...l, i })) + .filter((l) => l.cls === "hunk"); if (hunks.length === 0) return; currentHunkIndex += direction; @@ -451,33 +654,47 @@ function navigateHunk(direction) { const target = hunks[currentHunkIndex]; const viewer = document.getElementById(VIEWER_ID); - const body = viewer?.querySelector('.cuckoo-diff-viewer-body'); - const lineEl = body?.querySelector(`.cuckoo-diff-line[data-idx="${target.i}"]`); - + const body = viewer?.querySelector(".cuckoo-diff-viewer-body"); + const lineEl = body?.querySelector( + `.cuckoo-diff-line[data-idx="${target.i}"]`, + ); + if (lineEl) { - body.querySelectorAll('.cuckoo-diff-line.active').forEach(l => l.classList.remove('active')); - lineEl.classList.add('active'); - lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + body + .querySelectorAll(".cuckoo-diff-line.active") + .forEach((l) => l.classList.remove("active")); + lineEl.classList.add("active"); + lineEl.scrollIntoView({ behavior: "smooth", block: "center" }); } - const counter = viewer?.querySelector('#cuckoo-diff-counter'); - if (counter) counter.textContent = `${currentHunkIndex + 1} / ${hunks.length}`; + const counter = viewer?.querySelector("#cuckoo-diff-counter"); + if (counter) + counter.textContent = `${currentHunkIndex + 1} / ${hunks.length}`; } function updateMinimap() { const viewer = document.getElementById(VIEWER_ID); - const minimap = viewer?.querySelector('.cuckoo-diff-minimap'); + const minimap = viewer?.querySelector(".cuckoo-diff-minimap"); if (!minimap) return; - const visible = currentDiffLines.filter(l => shouldShowLine(l.cls)); + const visible = currentDiffLines.filter((l) => shouldShowLine(l.cls)); const height = Math.max(200, visible.length * 2); - minimap.style.height = height + 'px'; - - minimap.innerHTML = visible.map((line, idx) => { - const color = line.cls === 'add' ? '#4ade80' : line.cls === 'del' ? '#ff6b7a' : line.cls === 'hunk' ? '#8b93ff' : '#333'; - const top = (idx / visible.length) * height; - return `
`; - }).join(''); + minimap.style.height = height + "px"; + + minimap.innerHTML = visible + .map((line, idx) => { + const color = + line.cls === "add" + ? "#4ade80" + : line.cls === "del" + ? "#ff6b7a" + : line.cls === "hunk" + ? "#8b93ff" + : "#333"; + const top = (idx / visible.length) * height; + return `
`; + }) + .join(""); } async function openDiffViewer(filePath, status) { @@ -486,23 +703,31 @@ async function openDiffViewer(filePath, status) { const viewer = document.getElementById(VIEWER_ID); if (!viewer) return; - const titleEl = viewer.querySelector('#cuckoo-diff-viewer-title'); - const bodyEl = viewer.querySelector('#cuckoo-diff-viewer-body'); + const titleEl = viewer.querySelector("#cuckoo-diff-viewer-title"); + const bodyEl = viewer.querySelector("#cuckoo-diff-viewer-body"); if (titleEl) titleEl.textContent = filePath; - if (bodyEl) bodyEl.innerHTML = '
' + t('diff.loading') + '
'; - viewer.classList.remove('cuckoo-hidden'); + if (bodyEl) + bodyEl.innerHTML = + '
' + t("diff.loading") + "
"; + viewer.classList.remove("cuckoo-hidden"); if (!diffText) { try { const res = await window.electronAPI.gitDiffFile(filePath, status); if (!res || !res.success) { - bodyEl.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.diffFailed')) + '
'; + bodyEl.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.diffFailed")) + + "
"; return; } - diffText = res.diff || ''; + diffText = res.diff || ""; diffCache.set(cacheKey, diffText); } catch (err) { - bodyEl.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + bodyEl.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; return; } } @@ -519,23 +744,32 @@ async function openCommitFileViewer(hash, filePath) { const viewer = document.getElementById(VIEWER_ID); if (!viewer) return; - const titleEl = viewer.querySelector('#cuckoo-diff-viewer-title'); - const bodyEl = viewer.querySelector('#cuckoo-diff-viewer-body'); - if (titleEl) titleEl.textContent = filePath + ' @ ' + (hash || '').slice(0, 7); - if (bodyEl) bodyEl.innerHTML = '
' + t('diff.loading') + '
'; - viewer.classList.remove('cuckoo-hidden'); + const titleEl = viewer.querySelector("#cuckoo-diff-viewer-title"); + const bodyEl = viewer.querySelector("#cuckoo-diff-viewer-body"); + if (titleEl) + titleEl.textContent = filePath + " @ " + (hash || "").slice(0, 7); + if (bodyEl) + bodyEl.innerHTML = + '
' + t("diff.loading") + "
"; + viewer.classList.remove("cuckoo-hidden"); if (!diffText) { try { const res = await window.electronAPI.gitCommitFileDiff(hash, filePath); if (!res || !res.success) { - bodyEl.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.diffFailed')) + '
'; + bodyEl.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.diffFailed")) + + "
"; return; } - diffText = res.diff || ''; + diffText = res.diff || ""; diffCache.set(cacheKey, diffText); } catch (err) { - bodyEl.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + bodyEl.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; return; } } @@ -553,23 +787,33 @@ async function openCommitFullDiff() { const viewer = document.getElementById(VIEWER_ID); if (!viewer) return; - const titleEl = viewer.querySelector('#cuckoo-diff-viewer-title'); - const bodyEl = viewer.querySelector('#cuckoo-diff-viewer-body'); - if (titleEl) titleEl.textContent = t('diff.commit.titlePrefix') + (currentCommit.hash || '').slice(0, 7); - if (bodyEl) bodyEl.innerHTML = '
' + t('diff.loading') + '
'; - viewer.classList.remove('cuckoo-hidden'); + const titleEl = viewer.querySelector("#cuckoo-diff-viewer-title"); + const bodyEl = viewer.querySelector("#cuckoo-diff-viewer-body"); + if (titleEl) + titleEl.textContent = + t("diff.commit.titlePrefix") + (currentCommit.hash || "").slice(0, 7); + if (bodyEl) + bodyEl.innerHTML = + '
' + t("diff.loading") + "
"; + viewer.classList.remove("cuckoo-hidden"); if (!diffText) { try { const res = await window.electronAPI.gitCommitDiff(currentCommit.hash); if (!res || !res.success) { - bodyEl.innerHTML = '
' + escapeHtml(res && res.reason || t('diff.diffFailed')) + '
'; + bodyEl.innerHTML = + '
' + + escapeHtml((res && res.reason) || t("diff.diffFailed")) + + "
"; return; } - diffText = res.diff || ''; + diffText = res.diff || ""; diffCache.set(cacheKey, diffText); } catch (err) { - bodyEl.innerHTML = '
' + t('diff.errorPrefix', { msg: escapeHtml(err.message || err) }) + '
'; + bodyEl.innerHTML = + '
' + + t("diff.errorPrefix", { msg: escapeHtml(err.message || err) }) + + "
"; return; } } @@ -582,12 +826,12 @@ async function openCommitFullDiff() { function closeDiffViewer() { const viewer = document.getElementById(VIEWER_ID); - if (viewer) viewer.classList.add('cuckoo-hidden'); + if (viewer) viewer.classList.add("cuckoo-hidden"); } // ================= Перетаскивание viewer ================= -const VIEWER_POS_KEY = 'cuckoo-diff-viewer-pos'; +const VIEWER_POS_KEY = "cuckoo-diff-viewer-pos"; function restoreViewerPosition() { const viewer = document.getElementById(VIEWER_ID); @@ -596,37 +840,41 @@ function restoreViewerPosition() { const raw = localStorage.getItem(VIEWER_POS_KEY); if (!raw) return; const pos = JSON.parse(raw); - if (typeof pos.left === 'number' && typeof pos.top === 'number') { - viewer.style.left = pos.left + 'px'; - viewer.style.top = pos.top + 'px'; - viewer.style.transform = 'none'; + if (typeof pos.left === "number" && typeof pos.top === "number") { + viewer.style.left = pos.left + "px"; + viewer.style.top = pos.top + "px"; + viewer.style.transform = "none"; } } catch (_) {} } function makeDiffViewerDraggable() { const viewer = document.getElementById(VIEWER_ID); - const header = viewer?.querySelector('.cuckoo-wm-header'); + const header = viewer?.querySelector(".cuckoo-wm-header"); if (!viewer || !header) return; let dragging = false; - let startX = 0, startY = 0, startLeft = 0, startTop = 0; + let startX = 0, + startY = 0, + startLeft = 0, + startTop = 0; const onDown = (e) => { - if (e.target.closest('button') || e.target.closest('.cuckoo-btn-icon')) return; - + if (e.target.closest("button") || e.target.closest(".cuckoo-btn-icon")) + return; + dragging = true; const rect = viewer.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; startX = e.clientX; startY = e.clientY; - + // Отключаем центрирование при первом перетаскивании - viewer.style.transform = 'none'; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + viewer.style.transform = "none"; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); e.preventDefault(); }; @@ -634,57 +882,67 @@ function makeDiffViewerDraggable() { if (!dragging) return; let left = startLeft + (e.clientX - startX); let top = startTop + (e.clientY - startY); - + // Не выпускаем за пределы экрана const maxLeft = Math.max(0, window.innerWidth - 200); const maxTop = Math.max(0, window.innerHeight - 100); left = Math.max(0, Math.min(maxLeft, left)); top = Math.max(0, Math.min(maxTop, top)); - - viewer.style.left = left + 'px'; - viewer.style.top = top + 'px'; + + viewer.style.left = left + "px"; + viewer.style.top = top + "px"; }; const onUp = () => { if (!dragging) return; dragging = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + try { const rect = viewer.getBoundingClientRect(); - localStorage.setItem(VIEWER_POS_KEY, JSON.stringify({ left: rect.left, top: rect.top })); + localStorage.setItem( + VIEWER_POS_KEY, + JSON.stringify({ left: rect.left, top: rect.top }), + ); } catch (_) {} }; - header.addEventListener('mousedown', onDown); + header.addEventListener("mousedown", onDown); } // ================= Панель ================= function openDiffPanel() { const panel = document.getElementById(PANEL_ID); - if (panel) panel.classList.remove('cuckoo-hidden'); + if (panel) panel.classList.remove("cuckoo-hidden"); currentCommit = null; diffCache.clear(); // сбросить кэш при открытии - setActiveTab('changes'); + setActiveTab("changes"); } function closeDiffPanel() { const panel = document.getElementById(PANEL_ID); - if (panel) panel.classList.add('cuckoo-hidden'); + if (panel) panel.classList.add("cuckoo-hidden"); closeDiffViewer(); } function toggleDiffPanel() { const panel = document.getElementById(PANEL_ID); if (!panel) return; - if (panel.classList.contains('cuckoo-hidden')) openDiffPanel(); + if (panel.classList.contains("cuckoo-hidden")) openDiffPanel(); else closeDiffPanel(); } module.exports = { - openDiffPanel, closeDiffPanel, toggleDiffPanel, - renderDiffList, renderGitLog, closeDiffViewer, - setActiveTab, backToLog, openCommitFullDiff, + openDiffPanel, + closeDiffPanel, + toggleDiffPanel, + renderDiffList, + renderGitLog, + closeDiffViewer, + setActiveTab, + backToLog, + openCommitFullDiff, + commitAllToChat, }; diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index b64ed87..23c5543 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -605,6 +605,12 @@ function bindEvents() { const diffCloseBtn = document.getElementById("cuckoo-diff-close"); diffCloseBtn?.addEventListener("click", closeDiffPanel); + // Diff-панель: «Коммит» — вставить все diff в чат и попросить AI закоммитить + const diffCommitAllBtn = document.getElementById("cuckoo-diff-commit-all"); + diffCommitAllBtn?.addEventListener("click", () => { + require("./diff-panel").commitAllToChat(); + }); + // Diff-панель: обновить (в зависимости от активной вкладки) const diffRefreshBtn = document.getElementById("cuckoo-diff-refresh"); diffRefreshBtn?.addEventListener("click", () => { diff --git a/src/preload/overlay/template.js b/src/preload/overlay/template.js index 3020b80..6e6d49c 100644 --- a/src/preload/overlay/template.js +++ b/src/preload/overlay/template.js @@ -209,7 +209,7 @@ function buildOverlayHTML() { "", ' ', + '">', " ", '
', '
' + t("mcp.empty") + "
", @@ -231,7 +231,10 @@ function buildOverlayHTML() { '
', ' ', + '">', + ' ', ' ', @@ -859,8 +862,8 @@ const OVERLAY_CSS = [ '.cuckoo-tool-diff { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-family: "Consolas", "Cascadia Code", monospace; font-size: 11px; font-weight: 600; white-space: nowrap; }', ".cuckoo-diff-add { color: #4ade80; }", ".cuckoo-diff-del { color: #f87171; }", - ".cuckoo-tool-diff-view { margin: 0; padding: 7px 0; background: rgba(15,18,32,var(--cuckoo-toolblock-opacity)); border-top: 1px solid rgba(255,255,255,.06); overflow: auto; max-height: 360px; font-family: \"Consolas\", \"Cascadia Code\", monospace; font-size: 12px; line-height: 1.5; white-space: pre; }", - ".cuckoo-tool-block[data-expanded=\"false\"] .cuckoo-tool-diff-view { display: none; }", + '.cuckoo-tool-diff-view { margin: 0; padding: 7px 0; background: rgba(15,18,32,var(--cuckoo-toolblock-opacity)); border-top: 1px solid rgba(255,255,255,.06); overflow: auto; max-height: 360px; font-family: "Consolas", "Cascadia Code", monospace; font-size: 12px; line-height: 1.5; white-space: pre; }', + '.cuckoo-tool-block[data-expanded="false"] .cuckoo-tool-diff-view { display: none; }', ".cuckoo-tool-diff-line { display: block; padding: 0 12px; min-height: 1.5em; }", ".cuckoo-tool-diff-line.added { color: #b7f7c7; background: rgba(46,160,67,.16); border-left: 3px solid #4ade80; padding-left: 9px; }", ".cuckoo-tool-diff-line.removed { color: #ffb3bb; background: rgba(248,81,73,.16); border-left: 3px solid #f87171; padding-left: 9px; }", From 9237595dca448d80d3d8848075c0d069f1468507 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:52:11 +0300 Subject: [PATCH 3/9] feat(subagent): ask agents to use one or two tools per step buildAgentPrompt() now appends an explicit instruction to use 1-2 tools at a time instead of a large batch. Also minor cleanup in token-interceptor-inject.js. --- src/main/subagent.js | 2 + src/main/token-interceptor-inject.js | 251 +++++++++++++++------------ 2 files changed, 145 insertions(+), 108 deletions(-) diff --git a/src/main/subagent.js b/src/main/subagent.js index 026843f..212c367 100644 --- a/src/main/subagent.js +++ b/src/main/subagent.js @@ -67,6 +67,8 @@ function buildAgentPrompt(agentSystemPrompt, task) { parts.push("---"); parts.push("Задача: " + task); parts.push(""); + parts.push("Используй 1-2 инструмент за раз а не целую кучу."); + parts.push(""); parts.push( "Когда закончишь, дай сразу финальный результат (не вызывай больше инструментов).", ); diff --git a/src/main/token-interceptor-inject.js b/src/main/token-interceptor-inject.js index 2d221d6..46d0211 100644 --- a/src/main/token-interceptor-inject.js +++ b/src/main/token-interceptor-inject.js @@ -13,134 +13,169 @@ * @returns {string} */ function buildTokenInterceptorScript() { - return '(' + function () { - if (window.__cuckooTokenInterceptorInstalled) return; - window.__cuckooTokenInterceptorInstalled = true; + return ( + "(" + + function () { + if (window.__cuckooTokenInterceptorInstalled) return; + window.__cuckooTokenInterceptorInstalled = true; - var COMPLETION = '/api/v0/chat/completion'; - var lastTotal = 0; + var COMPLETION = "/api/v0/chat/completion"; + var lastTotal = 0; - function pick(o, keys) { - for (var i = 0; i < keys.length; i++) { - var v = o[keys[i]]; - if (typeof v === 'number' && isFinite(v)) return v; + function pick(o, keys) { + for (var i = 0; i < keys.length; i++) { + var v = o[keys[i]]; + if (typeof v === "number" && isFinite(v)) return v; + } + return null; } - return null; - } - function extractUsage(obj) { - if (!obj || typeof obj !== 'object') return null; + function extractUsage(obj) { + if (!obj || typeof obj !== "object") return null; - // BATCH патч-операции: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":82}, ...]} - if (Object.prototype.toString.call(obj.v) === '[object Array]') { - for (var i = obj.v.length - 1; i >= 0; i--) { - var op = obj.v[i]; - if (op && typeof op === 'object' && op.p === 'accumulated_token_usage') { - var n = pick(op, ['v', 'value']); - if (n != null) return { total: n, source: 'accumulated' }; + // BATCH патч-операции: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":82}, ...]} + if (Object.prototype.toString.call(obj.v) === "[object Array]") { + for (var i = obj.v.length - 1; i >= 0; i--) { + var op = obj.v[i]; + if ( + op && + typeof op === "object" && + op.p === "accumulated_token_usage" + ) { + var n = pick(op, ["v", "value"]); + if (n != null) return { total: n, source: "accumulated" }; + } } } + + // Снимок WIP: {"v":{"response":{"accumulated_token_usage":50}}} + var snap = + obj.v && + typeof obj.v === "object" && + Object.prototype.toString.call(obj.v) !== "[object Array]" + ? obj.v.response + : null; + if (snap && typeof snap === "object") { + var n2 = pick(snap, ["accumulated_token_usage"]); + if (n2 != null) return { total: n2, source: "accumulated" }; + } + + var acc = obj.accumulated_token_usage; + if (typeof acc === "number" && isFinite(acc)) + return { total: acc, source: "accumulated" }; + if (acc && typeof acc === "object") { + var t = pick(acc, ["total_tokens", "total", "value"]); + if (t != null) return { total: t, source: "accumulated" }; + } + + var usage = obj.usage; + if (usage && typeof usage === "object") { + var t2 = pick(usage, ["total_tokens", "total"]); + if (t2 != null) return { total: t2, source: "usage" }; + } + return null; } - // Снимок WIP: {"v":{"response":{"accumulated_token_usage":50}}} - var snap = obj.v && typeof obj.v === 'object' && Object.prototype.toString.call(obj.v) !== '[object Array]' ? obj.v.response : null; - if (snap && typeof snap === 'object') { - var n2 = pick(snap, ['accumulated_token_usage']); - if (n2 != null) return { total: n2, source: 'accumulated' }; + function publish(u) { + if (!u || typeof u.total !== "number" || !isFinite(u.total)) return; + var delta = u.total - lastTotal; + if (delta < 0) { + lastTotal = u.total; + return; + } + lastTotal = u.total; + if (delta === 0) return; + try { + window.postMessage( + { + __cuckoo: "token-update", + total: u.total, + delta: delta, + source: u.source, + }, + "*", + ); + } catch (e) {} } - var acc = obj.accumulated_token_usage; - if (typeof acc === 'number' && isFinite(acc)) return { total: acc, source: 'accumulated' }; - if (acc && typeof acc === 'object') { - var t = pick(acc, ['total_tokens', 'total', 'value']); - if (t != null) return { total: t, source: 'accumulated' }; + function parseSseText(text) { + if (!text || typeof text !== "string") return null; + var found = null; + var lines = text.split(/\r?\n/); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line || line.indexOf("data:") !== 0) continue; + var payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + var parsed = null; + try { + parsed = JSON.parse(payload); + } catch (e) { + continue; + } + var u = extractUsage(parsed); + if (u) found = u; + } + return found; } - var usage = obj.usage; - if (usage && typeof usage === 'object') { - var t2 = pick(usage, ['total_tokens', 'total']); - if (t2 != null) return { total: t2, source: 'usage' }; + function isCompletion(url) { + return typeof url === "string" && url.indexOf(COMPLETION) !== -1; } - return null; - } - function publish(u) { - if (!u || typeof u.total !== 'number' || !isFinite(u.total)) return; - var delta = u.total - lastTotal; - if (delta < 0) { lastTotal = u.total; return; } - lastTotal = u.total; - if (delta === 0) return; + // ---- патч fetch ---- try { - window.postMessage({ __cuckoo: 'token-update', total: u.total, delta: delta, source: u.source }, '*'); + var originalFetch = window.fetch; + window.fetch = function (input, init) { + var url = + typeof input === "string" ? input : (input && input.url) || ""; + var p = originalFetch.apply(this, arguments); + if (!isCompletion(url)) return p; + return p.then(function (response) { + try { + var clone = response.clone(); + clone + .text() + .then(function (body) { + var u = parseSseText(body); + if (u) publish(u); + }) + .catch(function () {}); + } catch (e) {} + return response; + }); + }; } catch (e) {} - } - - function parseSseText(text) { - if (!text || typeof text !== 'string') return null; - var found = null; - var lines = text.split(/\r?\n/); - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (!line || line.indexOf('data:') !== 0) continue; - var payload = line.slice(5).trim(); - if (!payload || payload === '[DONE]') continue; - var parsed = null; - try { parsed = JSON.parse(payload); } catch (e) { continue; } - var u = extractUsage(parsed); - if (u) found = u; - } - return found; - } - function isCompletion(url) { - return typeof url === 'string' && url.indexOf(COMPLETION) !== -1; - } - - // ---- патч fetch ---- - try { - var originalFetch = window.fetch; - window.fetch = function (input, init) { - var url = typeof input === 'string' ? input : (input && input.url) || ''; - var p = originalFetch.apply(this, arguments); - if (!isCompletion(url)) return p; - return p.then(function (response) { + // ---- патч XHR ---- + try { + var XHR = window.XMLHttpRequest; + var originalOpen = XHR.prototype.open; + var originalSend = XHR.prototype.send; + XHR.prototype.open = function (method, url) { try { - var clone = response.clone(); - clone.text().then(function (body) { - var u = parseSseText(body); - if (u) publish(u); - }).catch(function () {}); + this.__cuckooUrl = url; } catch (e) {} - return response; - }); - }; - } catch (e) {} - - // ---- патч XHR ---- - try { - var XHR = window.XMLHttpRequest; - var originalOpen = XHR.prototype.open; - var originalSend = XHR.prototype.send; - XHR.prototype.open = function (method, url) { - try { this.__cuckooUrl = url; } catch (e) {} - return originalOpen.apply(this, arguments); - }; - XHR.prototype.send = function () { - try { - var self = this; - if (isCompletion(this.__cuckooUrl || '')) { - this.addEventListener('load', function () { - try { - var u = parseSseText(self.responseText); - if (u) publish(u); - } catch (e) {} - }); - } - } catch (e) {} - return originalSend.apply(this, arguments); - }; - } catch (e) {} - }.toString() + ')();'; + return originalOpen.apply(this, arguments); + }; + XHR.prototype.send = function () { + try { + var self = this; + if (isCompletion(this.__cuckooUrl || "")) { + this.addEventListener("load", function () { + try { + var u = parseSseText(self.responseText); + if (u) publish(u); + } catch (e) {} + }); + } + } catch (e) {} + return originalSend.apply(this, arguments); + }; + } catch (e) {} + }.toString() + + ")();" + ); } module.exports = { buildTokenInterceptorScript }; From 9fe762a0af56d4a9cdf6f33ed61f466fb6e9a22a Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:07:02 +0300 Subject: [PATCH 4/9] feat(memory): add two-level memory (global + project) Split the long-term memory store into two scopes: --- .gitignore | 2 + src/main/ipc.js | 50 +++++++++++-- src/main/memory-store.js | 145 +++++++++++++++++++++++++++++------- src/main/project-context.js | 31 ++++++++ src/preload/api.js | 15 ++-- src/preload/i18n/i18n.js | 4 +- src/prompt/deepseek.md | 2 + src/prompt/default.md | 2 + tools/JsRunner.js | 38 ++++++++-- tools/MemoryTool.js | 128 ++++++++++++++++++++++--------- 10 files changed, 332 insertions(+), 85 deletions(-) diff --git a/.gitignore b/.gitignore index 54ff5d9..a207b48 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,8 @@ test/coverage.lcov .playwright-mcp/ /tiktok /.cuckoo/skills +# Проектная память — личные заметки AI, не коммитим. +/.cuckoo/memory/ # cookie/agents — НЕ игнорируем: агенты проекта должны попадать в git. # Пользовательские агенты лежат в ~/cookie/agents (вне репозитория). # Ранее здесь был /cookie/agents — убрано, иначе новые агенты untracked. diff --git a/src/main/ipc.js b/src/main/ipc.js index 70295ae..3807cda 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -184,6 +184,22 @@ function sessionIdOf(event) { : null; } +/** + * Текущий projectDir окна (из его sessionStore). Может быть null. + * @param {Electron.IpcMainEvent|Electron.IpcMainInvokeEvent} event + * @returns {string|null} + */ +function getProjectDirBySender(sender) { + try { + const ctx = windowState.getContextByWebContents(sender); + return ctx && ctx.sessionStore && ctx.sessionStore.state + ? ctx.sessionStore.state.selectedProjectDir || null + : null; + } catch (_) { + return null; + } +} + function maybeNotifyAllDone(senderId) { try { const todos = todoStore.getList(senderId); @@ -1716,9 +1732,25 @@ function registerIpcHandlers() { // инструменты memory_save / memory_read / memory_clear (tools/MemoryTool.js). const memoryStore = require("./memory-store"); - ipcMain.handle("cuckoo-memory-open-file", async () => { + ipcMain.handle("cuckoo-memory-open-file", async (event, { scope } = {}) => { try { - const file = memoryStore.ensureFile(); + const projectDir = getProjectDirBySender(event.sender); + const s = scope === "project" ? "project" : "global"; + const file = + s === "project" + ? memoryStore.getProjectMemoryPath(projectDir) + : memoryStore.getGlobalMemoryPath(); + if (!file) + return { + success: false, + error: "Проект не инициализирован (нет projectDir)", + }; + memoryStore.ensureFile( + file, + s === "project" + ? "# Память проекта\n\nЗаметки о данном проекте: соглашения, архитектурные решения, важные пути.\nФормат: - [YYYY-MM-DD] текст\n\n" + : "# Память Cookie Code\n\nЗаметки о пользователе, его системе и предпочтениях.\nФормат: - [YYYY-MM-DD] текст\n\n", + ); const errMsg = await shell.openPath(file); if (errMsg) return { success: false, error: errMsg }; return { success: true, path: file }; @@ -1728,9 +1760,11 @@ function registerIpcHandlers() { } }); - ipcMain.handle("cuckoo-memory-clear", async () => { + ipcMain.handle("cuckoo-memory-clear", async (event, { scope } = {}) => { try { - const r = memoryStore.clearMemory(); + const projectDir = getProjectDirBySender(event.sender); + const s = scope === "project" ? "project" : "global"; + const r = memoryStore.clearMemory({ scope: s, projectDir }); if (!r.ok) return { success: false, error: r.error }; return { success: true }; } catch (err) { @@ -1739,10 +1773,12 @@ function registerIpcHandlers() { } }); - ipcMain.handle("cuckoo-memory-stats", async () => { + ipcMain.handle("cuckoo-memory-stats", async (event, { scope } = {}) => { try { - const stats = memoryStore.getStats(); - return { success: true, path: memoryStore.getMemoryPath(), ...stats }; + const projectDir = getProjectDirBySender(event.sender); + const s = scope === "project" ? "project" : "global"; + const stats = memoryStore.getStats({ scope: s, projectDir }); + return { success: true, ...stats }; } catch (err) { return { success: false, error: err.message }; } diff --git a/src/main/memory-store.js b/src/main/memory-store.js index 235f9a6..c4f445d 100644 --- a/src/main/memory-store.js +++ b/src/main/memory-store.js @@ -1,8 +1,11 @@ /** * Хранилище долговременной памяти AI-ассистента. * - * Файл: /memory.md — общий для всех профилей и проектов. - * Формат: append-заметка с датой, одна запись — одна строка: + * Два уровня: + * - Глобальная: /memory.md — общая для всех профилей и проектов. + * - Проектная: /.cuckoo/memory/ProjectMemory.md — своя для каждого проекта. + * + * Формат (единый для обоих уровней): append-заметка с датой, одна запись — одна строка: * - [YYYY-MM-DD] текст записи * * Модуль живёт в main-процессе; инструменты (tools/MemoryTool.js) ходят @@ -15,33 +18,87 @@ const { app } = require("electron"); // Максимальная длина одной записи (символов). Слишком длинные тексты режем. const MAX_ENTRY_LEN = 2000; -const HEADER = +const GLOBAL_HEADER = "# Память Cookie Code\n\nЗаметки о пользователе, его системе и предпочтениях.\nФормат: - [YYYY-MM-DD] текст\n\n"; -function getMemoryPath() { +const PROJECT_HEADER = + "# Память проекта\n\nЗаметки о данном проекте: соглашения, архитектурные решения, важные пути.\nФормат: - [YYYY-MM-DD] текст\n\n"; + +/** Путь к глобальному файлу памяти. */ +function getGlobalMemoryPath() { return path.join(app.getPath("userData"), "memory.md"); } -function ensureFile() { - const file = getMemoryPath(); +/** Путь к проектному файлу памяти (папка создаётся при записи). */ +function getProjectMemoryPath(projectDir) { + if (!projectDir) return null; + return path.join(projectDir, ".cuckoo", "memory", "ProjectMemory.md"); +} + +/** + * Путь к файлу памяти по scope. + * @param {'global'|'project'} scope + * @param {string|null} projectDir + * @returns {string|null} + */ +function getMemoryPath(scope, projectDir) { + if (scope === "project") return getProjectMemoryPath(projectDir); + return getGlobalMemoryPath(); +} + +function ensureFile(file, header) { + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(file)) { - fs.writeFileSync(file, HEADER, "utf-8"); + fs.writeFileSync(file, header, "utf-8"); } return file; } -/** Прочитать содержимое памяти (пустая строка, если файла нет). */ -function readMemory() { +/** + * Прочитать содержимое одного файла памяти. + * @param {'global'|'project'} scope + * @param {string|null} projectDir + * @returns {string} + */ +function readOne(scope, projectDir) { try { - const file = getMemoryPath(); - if (!fs.existsSync(file)) return ""; + const file = getMemoryPath(scope, projectDir); + if (!file || !fs.existsSync(file)) return ""; return fs.readFileSync(file, "utf-8"); } catch (err) { - console.error("[MemoryStore] чтение памяти失败:", err.message); + console.error( + "[MemoryStore] чтение памяти (" + scope + ")失败:", + err.message, + ); return ""; } } +/** + * Прочитать память. + * @param {object} [opts] + * @param {'all'|'global'|'project'} [opts.scope='all'] + * @param {string|null} [opts.projectDir] + * @returns {string} + */ +function readMemory(opts) { + const o = opts || {}; + const scope = o.scope || "all"; + const projectDir = o.projectDir || null; + + if (scope === "global") return readOne("global", projectDir); + if (scope === "project") return readOne("project", projectDir); + + // all: проектная, потом глобальная. Без заголовков "# ..." — они в файлах. + const parts = []; + const p = readOne("project", projectDir); + const g = readOne("global", projectDir); + if (p && p.trim()) parts.push(p.trim()); + if (g && g.trim()) parts.push(g.trim()); + return parts.join("\n\n"); +} + /** Текущая дата в формате YYYY-MM-DD (локально). */ function todayStamp() { const d = new Date(); @@ -54,9 +111,12 @@ function todayStamp() { /** * Добавить одну запись в память. * @param {string} text непустой текст (trim); длиннее MAX_ENTRY_LEN — обрезается - * @returns {{ok: true, entry: string} | {ok: false, error: string}} + * @param {object} [opts] + * @param {'global'|'project'} [opts.scope='global'] + * @param {string|null} [opts.projectDir] + * @returns {{ok: true, entry: string, path: string} | {ok: false, error: string}} */ -function appendMemory(text) { +function appendMemory(text, opts) { try { if (typeof text !== "string") return { ok: false, error: "text must be a string" }; @@ -66,24 +126,45 @@ function appendMemory(text) { // Однострочная запись: переносы заменяем пробелами. t = t.replace(/\s*\r?\n\s*/g, " "); const entry = "- [" + todayStamp() + "] " + t; - ensureFile(); - const file = getMemoryPath(); + + const o = opts || {}; + const scope = o.scope === "project" ? "project" : "global"; + const file = getMemoryPath(scope, o.projectDir || null); + if (!file) + return { ok: false, error: "projectDir не задан для проектной памяти" }; + + const header = scope === "project" ? PROJECT_HEADER : GLOBAL_HEADER; + ensureFile(file, header); + let existing = fs.readFileSync(file, "utf-8"); if (existing.length > 0 && !existing.endsWith("\n")) existing += "\n"; fs.writeFileSync(file, existing + entry + "\n", "utf-8"); - console.log("[MemoryStore] добавлена запись: " + entry); - return { ok: true, entry }; + console.log("[MemoryStore] добавлена запись (" + scope + "): " + entry); + return { ok: true, entry, path: file }; } catch (err) { console.error("[MemoryStore] запись памяти失败:", err.message); return { ok: false, error: err.message }; } } -/** Полностью очистить память (файл остаётся с заголовком). */ -function clearMemory() { +/** + * Полностью очистить память (файл остаётся с заголовком). + * @param {object} [opts] + * @param {'global'|'project'} [opts.scope='global'] + * @param {string|null} [opts.projectDir] + * @returns {{ok: true} | {ok: false, error: string}} + */ +function clearMemory(opts) { try { - fs.writeFileSync(getMemoryPath(), HEADER, "utf-8"); - console.log("[MemoryStore] память очищена"); + const o = opts || {}; + const scope = o.scope === "project" ? "project" : "global"; + const file = getMemoryPath(scope, o.projectDir || null); + if (!file) + return { ok: false, error: "projectDir не задан для проектной памяти" }; + const header = scope === "project" ? PROJECT_HEADER : GLOBAL_HEADER; + ensureFile(file, header); + fs.writeFileSync(file, header, "utf-8"); + console.log("[MemoryStore] память очищена (" + scope + ")"); return { ok: true }; } catch (err) { console.error("[MemoryStore] очистка памяти失败:", err.message); @@ -91,15 +172,23 @@ function clearMemory() { } } -/** Статистика: существует ли файл, размер в байтах, число записей. */ -function getStats() { +/** + * Статистика по файлу памяти. + * @param {object} [opts] + * @param {'global'|'project'} [opts.scope='global'] + * @param {string|null} [opts.projectDir] + */ +function getStats(opts) { try { - const file = getMemoryPath(); - if (!fs.existsSync(file)) return { exists: false, bytes: 0, entries: 0 }; + const o = opts || {}; + const scope = o.scope === "project" ? "project" : "global"; + const file = getMemoryPath(scope, o.projectDir || null); + if (!file || !fs.existsSync(file)) + return { exists: false, bytes: 0, entries: 0, path: file }; const raw = fs.readFileSync(file, "utf-8"); const bytes = Buffer.byteLength(raw, "utf-8"); const entries = raw.split(/\r?\n/).filter((l) => /^-\s*\[/.test(l)).length; - return { exists: true, bytes, entries }; + return { exists: true, bytes, entries, path: file }; } catch (err) { return { exists: false, bytes: 0, entries: 0, error: err.message }; } @@ -107,6 +196,8 @@ function getStats() { module.exports = { MAX_ENTRY_LEN, + getGlobalMemoryPath, + getProjectMemoryPath, getMemoryPath, ensureFile, readMemory, diff --git a/src/main/project-context.js b/src/main/project-context.js index 06c5fb4..e289553 100644 --- a/src/main/project-context.js +++ b/src/main/project-context.js @@ -10,6 +10,7 @@ const windowState = require("./window"); const { toolRegistry } = require("./tool-registry"); const mcpClient = require("./mcp-client"); const { getSkillManager } = require("../main/skill-manager"); +const memoryStore = require("./memory-store"); // 提示词模板目录 const PROMPT_DIR = path.join(__dirname, "..", "prompt"); @@ -438,6 +439,35 @@ async function buildInitPrompt(selectedDir, providerId) { "Skill 位于 /.cuckoo/skills//,其中 SKILL.md 为指令文件,tool.js 可选导出可执行函数。", ].join("\n"); + // Память — абсолютные пути к файлам, чтобы AI знал, где они лежат. + let memorySection = ""; + try { + const globalPath = memoryStore.getGlobalMemoryPath(); + const projectPath = memoryStore.getProjectMemoryPath(selectedDir); + memorySection = [ + "---", + "## Память", + "", + "У тебя есть долговременная память двух уровней. Файлы:", + "- Глобальная (факты о пользователе и его системе): " + globalPath, + "- Проектная (о текущем проекте): " + projectPath, + "", + "Пути также доступны в JS-скриптах: globalThis.memoryPath (глобальная) и globalThis.projectMemoryPath (проектная).", + "", + "Читай и пиши через инструменты:", + "- memorySave(text, scope?) — сохранить заметку. scope: 'project' | 'global' (по умолчанию project, если проект инициализирован).", + "- memoryRead(scope?) — прочитать. scope: 'all' (по умолчанию — обе), 'project', 'global'.", + "- memoryClear(scope?) — очистить. Только по прямой просьбе пользователя.", + "", + "Подробные правила — в секции инструмента memory_save выше.", + ].join("\n"); + } catch (err) { + console.error( + "[Cookie Code] Не удалось собрать секцию памяти:", + err.message, + ); + } + // Agents (субагенты) — прогрессивное раскрытие: только name + description. // Сами определения (systemPrompt, tools, maxTurns) живут в .cuckoo/agents/*.md // и подгружаются в момент вызова run_agent. @@ -461,6 +491,7 @@ async function buildInitPrompt(selectedDir, providerId) { "{{PROJECT_DIR}}": selectedDir, "{{PROJECT_INTRO_SECTION}}": projectIntroSection, "{{SKILL_SECTION}}": skillSection, + "{{MEMORY_SECTION}}": memorySection, "{{MCP_SECTION}}": mcpSection, "{{AGENTS_SECTION}}": agentsSection, }; diff --git a/src/preload/api.js b/src/preload/api.js index 3b3b3eb..de3e64a 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -236,15 +236,16 @@ let electronAPI = { chromaKeyPet: (file, color, tolerance) => { return ipcRenderer.invoke("cuckoo-pets-chroma", { file, color, tolerance }); }, - // ========== Память AI (userData/memory.md) ========== - openMemoryFile: () => { - return ipcRenderer.invoke("cuckoo-memory-open-file"); + // ========== Память AI (userData/memory.md + /.cuckoo/memory/) ========== + // scope: 'global' (по умолчанию) | 'project' + openMemoryFile: (scope) => { + return ipcRenderer.invoke("cuckoo-memory-open-file", { scope }); }, - clearMemory: () => { - return ipcRenderer.invoke("cuckoo-memory-clear"); + clearMemory: (scope) => { + return ipcRenderer.invoke("cuckoo-memory-clear", { scope }); }, - getMemoryStats: () => { - return ipcRenderer.invoke("cuckoo-memory-stats"); + getMemoryStats: (scope) => { + return ipcRenderer.invoke("cuckoo-memory-stats", { scope }); }, // ========== Todo-задачи ========== getTodos: () => { diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index dad4828..ea8a96f 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -546,8 +546,8 @@ const KEYS = { }, "settings.memory.entries": { ru: "записей", en: "entries" }, "settings.memory.hint": { - ru: "AI запоминает факты о вас и вашей системе. Файл памяти: /memory.md", - en: "The AI remembers facts about you and your system. Memory file: /memory.md", + ru: "AI запоминает факты о вас и вашей системе. Глобальная память: /memory.md; проектная: /.cuckoo/memory/ProjectMemory.md", + en: "The AI remembers facts about you and your system. Global memory: /memory.md; project memory: /.cuckoo/memory/ProjectMemory.md", }, // ---- Шрифт ---- "settings.section.font": { ru: "Шрифт", en: "Font" }, diff --git a/src/prompt/deepseek.md b/src/prompt/deepseek.md index a5dbdde..af0017a 100644 --- a/src/prompt/deepseek.md +++ b/src/prompt/deepseek.md @@ -188,6 +188,8 @@ log(r); {{SKILL_SECTION}} +{{MEMORY_SECTION}} + --- ## 当前项目目录 diff --git a/src/prompt/default.md b/src/prompt/default.md index a5dbdde..af0017a 100644 --- a/src/prompt/default.md +++ b/src/prompt/default.md @@ -188,6 +188,8 @@ log(r); {{SKILL_SECTION}} +{{MEMORY_SECTION}} + --- ## 当前项目目录 diff --git a/tools/JsRunner.js b/tools/JsRunner.js index b5ddbff..ac7f0b2 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -66,6 +66,8 @@ const BOOTSTRAP = [ " };", "", " globalThis.projectDir = __projectDir;", + " globalThis.memoryPath = __globalMemoryPath;", + " globalThis.projectMemoryPath = __projectMemoryPath;", "", " async function __call(name, args) {", " var resText = await __hostBridge(name, JSON.stringify(args == null ? {} : args));", @@ -190,14 +192,14 @@ const BOOTSTRAP = [ " globalThis.attachTelegram = async function (filePath, caption, comment) {", " return await __call('attach_telegram', { filePath: filePath, caption: caption || '', comment: comment || '' });", " };", - " globalThis.memorySave = async function (text) {", - " return await __call('memory_save', { text: text });", + " globalThis.memorySave = async function (text, scope) {", + " return await __call('memory_save', { text: text, scope: scope });", " };", - " globalThis.memoryRead = async function () {", - " return await __call('memory_read', {});", + " globalThis.memoryRead = async function (scope) {", + " return await __call('memory_read', { scope: scope });", " };", - " globalThis.memoryClear = async function () {", - " return await __call('memory_clear', {});", + " globalThis.memoryClear = async function (scope) {", + " return await __call('memory_clear', { scope: scope });", " };", " globalThis.runAgent = async function (name, task) {", " return await __call('run_agent', { name: name, task: task });", @@ -520,6 +522,28 @@ class JsRunner { writable: false, configurable: false, }); + // Абсолютные пути к файлам памяти — доступны в скриптах как + // globalThis.memoryPath (глобальная) и globalThis.projectMemoryPath (проектная). + let __globalMemoryPath = null; + let __projectMemoryPath = null; + try { + const memoryStore = require("../src/main/memory-store"); + __globalMemoryPath = memoryStore.getGlobalMemoryPath() || null; + __projectMemoryPath = + memoryStore.getProjectMemoryPath(projectDir) || null; + } catch (_) {} + Object.defineProperty(sandbox, "__globalMemoryPath", { + value: __globalMemoryPath, + enumerable: true, + writable: false, + configurable: false, + }); + Object.defineProperty(sandbox, "__projectMemoryPath", { + value: __projectMemoryPath, + enumerable: true, + writable: false, + configurable: false, + }); // 截断沙箱对象与桥接函数的原型链,阻止经 constructor/__proto__ 逃逸到宿主 realm try { Object.setPrototypeOf(sandbox, null); @@ -543,6 +567,8 @@ class JsRunner { const fallback = {}; fallback.__hostBridge = hostBridge; fallback.__projectDir = projectDir || null; + fallback.__globalMemoryPath = __globalMemoryPath; + fallback.__projectMemoryPath = __projectMemoryPath; context = vm.createContext(fallback, { codeGeneration: { strings: false, wasm: false }, name: "cuckoo-js-sandbox", diff --git a/tools/MemoryTool.js b/tools/MemoryTool.js index 5d888dd..2f3381d 100644 --- a/tools/MemoryTool.js +++ b/tools/MemoryTool.js @@ -1,6 +1,33 @@ const { Tool, ToolResult } = require("./ToolRegistry"); const memoryStore = require("../src/main/memory-store"); +/** + * Разрешить scope и projectDir для операции памяти. + * По умолчанию: + * - save/clear: project, если projectDir задан, иначе global; + * - read: all. + * Пользователь может явно указать scope ('project' | 'global' | 'all'). + * @param {object} params - параметры инструмента + * @param {boolean} readMode - true для memory_read (поддерживает 'all') + * @returns {{scope: string, projectDir: string|null}} + */ +function resolveScope(params, readMode) { + const projectDir = params && params.projectDir ? params.projectDir : null; + let scope = params && typeof params.scope === "string" ? params.scope : null; + + if (readMode) { + if (scope !== "all" && scope !== "global" && scope !== "project") { + scope = "all"; + } + return { scope, projectDir }; + } + + if (scope !== "project" && scope !== "global") { + scope = projectDir ? "project" : "global"; + } + return { scope, projectDir }; +} + /** * memory_save — добавить одну заметку в долговременную память. */ @@ -8,7 +35,7 @@ class MemorySaveTool extends Tool { constructor() { super( "memory_save", - "Сохранить одну заметку в долговременную память о пользователе.", + "Сохранить одну заметку в долговременную память о пользователе или о текущем проекте.", { type: "object", properties: { @@ -17,11 +44,18 @@ class MemorySaveTool extends Tool { description: 'Короткая запись (одна мысль). Сохраняется как "- [дата] текст".', }, + scope: { + type: "string", + enum: ["project", "global"], + description: + "Куда сохранить: 'project' (память проекта, /.cuckoo/memory/ProjectMemory.md) " + + "или 'global' (память о пользователе). По умолчанию — project, если проект инициализирован, иначе global.", + }, }, required: ["text"], additionalProperties: false, }, - "memorySave(text)", + "memorySave(text, scope?)", ); } @@ -32,55 +66,58 @@ class MemorySaveTool extends Tool { text: [ "## Память — что это и когда использовать", "", - "Память — это долговременный файл заметок о пользователе. В неё попадает", - "информация о пользователе, его системе или о том, что он прямо попросил", - "записать. Примеры:", - "- «Мне 10 лет», «Меня зовут Иван», «Я живу в Москве» — факты о пользователе.", - "- «У меня Windows 11, RTX 3060, VS Code, Python 3.12» — факты о его системе.", - "- «Пиши комментарии на русском», «Не трогай папку legacy/», «Используй tabs» — устойчивые предпочтения по работе.", - "- Любая прямая просьба: «Запомни, что…», «Сохрани в память…».", + "Память двух уровней:", + "- **Проектная** — /.cuckoo/memory/ProjectMemory.md. Заметки о конкретном проекте:", + " соглашения, архитектурные решения, важные пути, стек, договорённости.", + "- **Глобальная** — /memory.md. Общие факты о пользователе и его системе.", "", - "Когда сохранять:", - "- Когда пользователь прямо просит что-то запомнить — сохраняй сразу, коротко и от третьего лица (memorySave('Пользователю 10 лет')).", - "- Когда в диалоге всплывает устойчивый факт о пользователе или его окружении, который пригодится в будущих сессиях, — сохраняй без напоминаний.", - "- Не сохраняй временное (текущая задача, разовый путь, содержимое файла), секреты, токены, пароли.", - "", - "Когда читать:", - "- Если ответ зависит от данных о пользователе/системе — сначала memoryRead().", - "- Не вызывай memoryRead() на каждый запрос; только когда это уместно.", + "Куда писать:", + "- По умолчанию memorySave пишет в **проектную** память, если проект инициализирован, иначе в глобальную.", + "- Если факт относится к пользователю/его системе (имя, ОС, GPU, предпочтения по стилю кода) —", + " передавай scope='global'.", + "- Если факт относится к данному проекту (архитектура, стек, соглашения, пути) — scope='project'.", + "- Когда пользователь прямо просит запомнить — сохраняй сразу, коротко и от третьего лица.", "", "Формат и правила:", - '- memorySave(text) — добавляет одну строку "- [дата] текст". Одна запись — одна мысль. Без markdown-заголовков и переводов строк.', + '- memorySave(text, scope?) — добавляет одну строку "- [дата] текст". Одна запись — одна мысль.', "- Дубликаты не создавай: если запись уже есть — пропусти или переформулируй.", - "- memoryClear() — полная очистка. Только по прямой просьбе пользователя. Перед вызовом — предупреди.", - "- Память не попадает в системный промпт автоматически: её содержимое видно только после явного memoryRead().", + "- Не сохраняй временное (текущая задача, разовый путь, содержимое файла), секреты, токены, пароли.", ].join("\n"), }; } async execute(params) { const { text } = params; - const r = memoryStore.appendMemory(text); + const { scope, projectDir } = resolveScope(params, false); + const r = memoryStore.appendMemory(text, { scope, projectDir }); if (!r.ok) return ToolResult.error("Не удалось сохранить в память: " + r.error); - return ToolResult.success("Сохранено в память: " + r.entry); + return ToolResult.success("Сохранено в память (" + scope + "): " + r.entry); } } /** - * memory_read — прочитать всю память. + * memory_read — прочитать память (проектную, глобальную или обе). */ class MemoryReadTool extends Tool { constructor() { super( "memory_read", - "Прочитать долговременную память (заметки о пользователе).", + "Прочитать долговременную память (заметки о пользователе и/или о проекте).", { type: "object", - properties: {}, + properties: { + scope: { + type: "string", + enum: ["all", "project", "global"], + description: + "Что прочитать: 'all' (по умолчанию — проектная + глобальная), " + + "'project' (только проектная), 'global' (только глобальная).", + }, + }, additionalProperties: false, }, - "memoryRead()", + "memoryRead(scope?)", ); } @@ -88,12 +125,18 @@ class MemoryReadTool extends Tool { return { name: "tool:memory_read", order: 112, - text: "Вызывай memoryRead(), когда ответ зависит от данных о пользователе/его системе. Не вызывай на каждый запрос.", + text: [ + "Вызывай memoryRead() перед ответом, если он зависит от данных о пользователе/системе или от соглашений проекта.", + "По умолчанию возвращает обе памяти (проектную + глобальную). Можно указать scope='project' | 'global' | 'all'.", + "Не вызывай memoryRead() на каждый запрос; только когда это уместно.", + "Память не попадает в системный промпт автоматически: её содержимое видно только после явного memoryRead().", + ].join("\n"), }; } - async execute() { - const raw = memoryStore.readMemory(); + async execute(params) { + const { scope, projectDir } = resolveScope(params || {}, true); + const raw = memoryStore.readMemory({ scope, projectDir }); if (!raw || !raw.trim()) { return ToolResult.success("(память пуста)"); } @@ -108,13 +151,21 @@ class MemoryClearTool extends Tool { constructor() { super( "memory_clear", - "Полностью очистить долговременную память. Только по прямой просьбе пользователя.", + "Полностью очистить долговременную память (проектную или глобальную). Только по прямой просьбе пользователя.", { type: "object", - properties: {}, + properties: { + scope: { + type: "string", + enum: ["project", "global"], + description: + "Что очистить: 'project' (память проекта) или 'global' (память о пользователе). " + + "По умолчанию — project, если проект инициализирован, иначе global.", + }, + }, additionalProperties: false, }, - "memoryClear()", + "memoryClear(scope?)", ); } @@ -122,15 +173,20 @@ class MemoryClearTool extends Tool { return { name: "tool:memory_clear", order: 113, - text: "memoryClear() удаляет все записи памяти. Используй только по прямой просьбе пользователя и предупреди его перед вызовом.", + text: [ + "memoryClear(scope?) удаляет все записи памяти. scope='project' | 'global';", + "по умолчанию — проектная, если проект инициализирован, иначе глобальная.", + "Используй только по прямой просьбе пользователя и предупреди его перед вызовом.", + ].join("\n"), }; } - async execute() { - const r = memoryStore.clearMemory(); + async execute(params) { + const { scope, projectDir } = resolveScope(params || {}, false); + const r = memoryStore.clearMemory({ scope, projectDir }); if (!r.ok) return ToolResult.error("Не удалось очистить память: " + r.error); - return ToolResult.success("Память очищена."); + return ToolResult.success("Память очищена (" + scope + ")."); } } From c5774a3232758de1b22333e3c99ed9f756f43c48 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:07:10 +0300 Subject: [PATCH 5/9] fix(diff): close diff panel after clicking Commit The "Commit" button in the diff panel copied the full diff into the chat but left the panel open, covering the input. Call closeDiffPanel() after commitAllToChat(). --- src/preload/overlay/events.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index 23c5543..c05b159 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -609,6 +609,7 @@ function bindEvents() { const diffCommitAllBtn = document.getElementById("cuckoo-diff-commit-all"); diffCommitAllBtn?.addEventListener("click", () => { require("./diff-panel").commitAllToChat(); + closeDiffPanel(); }); // Diff-панель: обновить (в зависимости от активной вкладки) From 4704e20abe2e1389e8fd9a935230298080f84dde Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:42:02 +0300 Subject: [PATCH 6/9] feat(whats-new): add raw changelog parser and IPC for current version Add getLatestRawMarkdown() to return the latest CHANGELOG entry as raw markdown, preserving tables, quotes and code blocks. Expose whats-new-get-current IPC handler and getCurrentChangelog() preload API. Also normalize quotes to double quotes in changelog-parser.js. --- src/main/changelog-parser.js | 79 ++++++++++++++++++++++++++++-------- src/main/ipc.js | 19 +++++++++ src/preload/api.js | 3 ++ 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/main/changelog-parser.js b/src/main/changelog-parser.js index a1a65cf..b322a61 100644 --- a/src/main/changelog-parser.js +++ b/src/main/changelog-parser.js @@ -16,16 +16,20 @@ * Не пытаемся быть универсальным парсером Keep a Changelog — * поддерживаем ровно тот формат, что лежит в нашем репо. */ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); /** * Сравнить две semver-строки. * @returns -1 | 0 | 1 */ function compareSemver(a, b) { - const pa = String(a || '0').split('.').map(n => parseInt(n, 10) || 0); - const pb = String(b || '0').split('.').map(n => parseInt(n, 10) || 0); + const pa = String(a || "0") + .split(".") + .map((n) => parseInt(n, 10) || 0); + const pb = String(b || "0") + .split(".") + .map((n) => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { const x = pa[i] || 0; const y = pb[i] || 0; @@ -41,7 +45,7 @@ function compareSemver(a, b) { * @returns {Array<{version: string, date: string|null, unreleased: boolean, sections: {title: string, items: string[]}[]}>} */ function parseChangelog(md) { - if (!md || typeof md !== 'string') return []; + if (!md || typeof md !== "string") return []; const lines = md.split(/\r?\n/); const entries = []; let current = null; @@ -52,7 +56,7 @@ function parseChangelog(md) { const ITEM_RE = /^\s*[-*]\s+(.+)$/; for (const raw of lines) { - const line = raw.replace(/\s+$/, ''); + const line = raw.replace(/\s+$/, ""); if (!line) continue; const mVer = line.match(VER_RE); @@ -97,7 +101,7 @@ function extractSince(entries, from, to) { for (const e of entries) { if (e.unreleased) continue; // Приводим версию к чистому виду (без префикса v) - const v = String(e.version).replace(/^v/, '').trim(); + const v = String(e.version).replace(/^v/, "").trim(); if (!/^\d+\.\d+\.\d+/.test(v)) continue; if (from && compareSemver(v, from) <= 0) continue; @@ -116,17 +120,17 @@ function extractSince(entries, from, to) { */ function loadBuiltinChangelog() { const candidates = [ - path.join(__dirname, '..', '..', 'CHANGELOG.md'), - path.join(process.resourcesPath || '', 'CHANGELOG.md'), + path.join(__dirname, "..", "..", "CHANGELOG.md"), + path.join(process.resourcesPath || "", "CHANGELOG.md"), ]; for (const p of candidates) { try { if (fs.existsSync(p)) { - return fs.readFileSync(p, 'utf-8'); + return fs.readFileSync(p, "utf-8"); } } catch (_) {} } - return ''; + return ""; } /** @@ -134,18 +138,58 @@ function loadBuiltinChangelog() { * Формат компактнее оригинального: версия → разделы → пункты. */ function toMarkdown(entries) { - if (!entries || entries.length === 0) return ''; + if (!entries || entries.length === 0) return ""; const out = []; for (const e of entries) { - out.push('### v' + e.version + (e.date ? ' — ' + e.date : '')); + out.push("### v" + e.version + (e.date ? " — " + e.date : "")); for (const s of e.sections) { if (!s.items.length) continue; - out.push('**' + s.title + '**'); - for (const it of s.items) out.push('- ' + it); + out.push("**" + s.title + "**"); + for (const it of s.items) out.push("- " + it); } - out.push(''); + out.push(""); } - return out.join('\n').trim(); + return out.join("\n").trim(); +} + +/** + * Вернуть сырой markdown последней версии из CHANGELOG (без потерь). + * В отличие от parseChangelog/toMarkdown сохраняет таблицы, цитаты, блоки кода. + * @param {string} md — содержимое CHANGELOG.md + * @returns {{version: string, markdown: string}|null} + */ +function getLatestRawMarkdown(md) { + if (!md || typeof md !== "string") return null; + const lines = md.split(/\r?\n/); + const VER_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/; + let startIdx = -1; + let version = null; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(VER_RE); + if (m) { + startIdx = i; + version = m[1].trim(); + break; + } + } + if (startIdx === -1) return null; + // Тело — до следующего '## [' того же уровня (следующая версия). + let endIdx = lines.length; + for (let i = startIdx + 1; i < lines.length; i++) { + if (VER_RE.test(lines[i])) { + endIdx = i; + break; + } + } + // Заголовок превращаем в '### vX.Y.Z — дата' (формат, который понимает рендер). + const headerMatch = lines[startIdx].match(VER_RE); + const date = headerMatch && headerMatch[2] ? headerMatch[2].trim() : ""; + const header = "### v" + version + (date ? " — " + date : ""); + const body = lines + .slice(startIdx + 1, endIdx) + .join("\n") + .trim(); + return { version, markdown: (header + "\n\n" + body).trim() }; } module.exports = { @@ -154,4 +198,5 @@ module.exports = { loadBuiltinChangelog, toMarkdown, compareSemver, + getLatestRawMarkdown, }; diff --git a/src/main/ipc.js b/src/main/ipc.js index 3807cda..398cb0f 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -447,6 +447,25 @@ function registerIpcHandlers() { }, ); + // Whats-new: получить текущий CHANGELOG для показа модалки (F1). + // Отдаём СЫРОЙ markdown последней версии — чтобы таблицы/цитаты/код не терялись. + ipcMain.handle("whats-new-get-current", async () => { + try { + const parser = require("./changelog-parser"); + const raw = parser.loadBuiltinChangelog(); + const latest = parser.getLatestRawMarkdown(raw); + if (!latest) { + return { success: false, error: "CHANGELOG.md пуст или не найден" }; + } + return { + success: true, + payload: { from: null, to: latest.version, markdown: latest.markdown }, + }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Whats-new: открыть CHANGELOG.md в системном редакторе (по кнопке в модалке) ipcMain.handle("whats-new-open-changelog", async () => { try { diff --git a/src/preload/api.js b/src/preload/api.js index de3e64a..cd710cb 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -315,6 +315,9 @@ let electronAPI = { openChangelogFile: () => { return ipcRenderer.invoke("whats-new-open-changelog"); }, + getCurrentChangelog: () => { + return ipcRenderer.invoke("whats-new-get-current"); + }, // ========== Диагностика интеграции (выполняется в контексте страницы) ========== runDiagnosticsText: async () => { const { runDiagnostics, formatReportText } = require("./dom/diagnostics"); From 1d4e442fd01c2d37637326c1f43f0d6d489bd292 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:42:18 +0300 Subject: [PATCH 7/9] feat(whats-new): add F1 hotkey and draggable wide modal Bind F1 to open the What's New modal with the current CHANGELOG. Widen the modal (92vw / max 1100px) and make it draggable by its header. --- src/preload/dom/whats-new.js | 67 +++++++++++++++++++++++++++++++++-- src/preload/overlay/events.js | 24 +++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/preload/dom/whats-new.js b/src/preload/dom/whats-new.js index 113e56a..2589592 100644 --- a/src/preload/dom/whats-new.js +++ b/src/preload/dom/whats-new.js @@ -24,8 +24,11 @@ function ensureStyles() { "#" + MODAL_ID + " .wn-card { background: #141726; border: 1px solid rgba(139,147,255,0.35);", - " border-radius: 14px; max-width: 640px; width: 92%; max-height: 80vh; display: flex;", - " flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.6); color: #dde1ff; }", + " border-radius: 14px; width: 92vw; max-width: 1100px; max-height: 85vh; display: flex;", + " flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.6); color: #dde1ff;", + " position: relative; }", + "#" + MODAL_ID + " .wn-head { cursor: grab; user-select: none; }", + "#" + MODAL_ID + " .wn-head:active { cursor: grabbing; }", "#" + MODAL_ID + " .wn-head { padding: 18px 22px; border-bottom: 1px solid rgba(255,255,255,0.07);", @@ -541,6 +544,66 @@ function show(payload) { } }); + // ---- Перетаскивание модалки за шапку ---- + // Модалка позиционируется через transform (центрирование), поэтому при + // перетаскивании переводим её в абсолютные left/top и двигаем оттуда. + const headEl = modal.querySelector(".wn-head"); + if (headEl) { + let dragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + + const onMove = (e) => { + if (!dragging) return; + const card = modal.querySelector(".wn-card"); + if (!card) return; + let left = startLeft + (e.clientX - startX); + let top = startTop + (e.clientY - startY); + // Не выпускаем за пределы окна (оставляем шапку видимой). + const w = card.offsetWidth; + const h = card.offsetHeight; + const maxLeft = window.innerWidth - 40; + const maxTop = window.innerHeight - 40; + left = Math.max(-(w - 80), Math.min(maxLeft, left)); + top = Math.max(0, Math.min(maxTop, top)); + card.style.left = left + "px"; + card.style.top = top + "px"; + }; + + const onUp = () => { + if (!dragging) return; + dragging = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + headEl.addEventListener("mousedown", (e) => { + // Не начинать перетаскивание с кнопок в шапке. + if (e.target.closest("button")) return; + const card = modal.querySelector(".wn-card"); + if (!card) return; + const rect = card.getBoundingClientRect(); + // Фиксируем текущую позицию в left/top, снимаем центрирование. + card.style.position = "absolute"; + card.style.left = rect.left + "px"; + card.style.top = rect.top + "px"; + card.style.margin = "0"; + // Переводим контейнер из flex-центрирования в свободное позиционирование. + modal.style.alignItems = "flex-start"; + modal.style.justifyContent = "flex-start"; + dragging = true; + startX = e.clientX; + startY = e.clientY; + startLeft = rect.left; + startTop = rect.top; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + e.preventDefault(); + }); + } + document.body.appendChild(modal); } diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index c05b159..c8319e5 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -879,6 +879,30 @@ function bindEvents() { // 键盘快捷键 document.addEventListener("keydown", (e) => { + // F1 — показать модалку с текущим CHANGELOG. + if (e.key === "F1") { + e.preventDefault(); + (async () => { + try { + const res = await window.electronAPI.getCurrentChangelog(); + if (res && res.success && res.payload) { + require("../dom/whats-new").show(res.payload); + } else { + showToast( + (res && res.error) || "Не удалось открыть CHANGELOG", + 3000, + ); + } + } catch (err) { + showToast( + "Не удалось открыть CHANGELOG: " + (err.message || err), + 3000, + ); + } + })(); + return; + } + // Ctrl+Shift+C 切换覆盖层显示 if (e.ctrlKey && e.shiftKey && (e.key === "C" || e.key === "c")) { e.preventDefault(); From d2623da158ec386fa8f1bef5a6a6dcffeece6f5c Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:42:26 +0300 Subject: [PATCH 8/9] fix(subagent): prevent white window from oversized argv Remove systemPrompt and tools from subagentConfig that is passed via additionalArguments (argv). The full agent systemPrompt inflated the argv string past the Windows ~32 KB command line limit, causing render-process-gone launch-failed and a white window. Only light fields (agentName, task, maxTurns, projectDir) are now passed via argv; systemPrompt arrives through initial-prompt and tools through toolsWhitelist. Also add white-window diagnostics in createWindow (did-fail-load, preload-error, render-process-gone, unresponsive/responsive, 15s watchdog) logging to /wyp/log/diagnostics.log. --- src/main/index.js | 89 +++++++++++++++++++++++++++++++++++++++++++- src/main/subagent.js | 7 +++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/main/index.js b/src/main/index.js index 49e3118..583749f 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -234,8 +234,95 @@ function createWindow(profile) { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; mainWindow.webContents.setUserAgent(userAgent); + // ========== Диагностика причин «белого окна» (особенно у субагентов) ========== + // Логируем в консоль main-процесса и в /wyp/log/.diagnostics.log + const diagLog = (msg) => { + try { + const tag = profileData.isSubagent + ? "Subagent:" + (profileData.name || "?") + : "Window:" + (profileData.name || "?"); + const line = "[" + new Date().toISOString() + "][" + tag + "] " + msg; + console.log("[Cookie Code][Diag] " + line); + if (RENDERER_LOG_DIR) { + fs.appendFileSync( + path.join(RENDERER_LOG_DIR, "diagnostics.log"), + line + "\n", + "utf-8", + ); + } + } catch (_) {} + }; + + mainWindow.webContents.on( + "did-fail-load", + (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { + diagLog( + "did-fail-load code=" + + errorCode + + " desc=" + + errorDescription + + " url=" + + validatedURL + + " mainFrame=" + + isMainFrame, + ); + }, + ); + mainWindow.webContents.on("did-finish-load", () => { + diagLog("did-finish-load url=" + mainWindow.webContents.getURL()); + }); + mainWindow.webContents.on("preload-error", (_e, preloadPath, error) => { + diagLog( + "preload-error path=" + + preloadPath + + " msg=" + + (error && error.message ? error.message : String(error)), + ); + }); + mainWindow.webContents.on("render-process-gone", (_e, details) => { + diagLog( + "render-process-gone reason=" + + (details && details.reason) + + " exitCode=" + + (details && details.exitCode), + ); + }); + mainWindow.webContents.on("unresponsive", () => { + diagLog("unresponsive"); + }); + mainWindow.webContents.on("responsive", () => { + diagLog("responsive"); + }); + // Проверка, что preload вообще отработал: ждём первый console-message от рендера. + let preloadSeen = false; + mainWindow.webContents.on("console-message", (_e, _lvl, message) => { + if (!preloadSeen && /Preload script/i.test(message)) { + preloadSeen = true; + diagLog("preload executed (первое сообщение preload получено)"); + } + }); + // Таймер-страховка: если через 15с preload не подал признаков жизни — логируем. + setTimeout(() => { + try { + if (mainWindow && !mainWindow.isDestroyed() && !preloadSeen) { + diagLog( + "ВНИМАНИЕ: за 15с preload не отправил '[Cookie Code] Preload script 开始执行'. " + + "URL=" + + mainWindow.webContents.getURL() + + " loading=" + + mainWindow.webContents.isLoading(), + ); + } + } catch (_) {} + }, 15000); + // Всегда открываем homeUrl провайдера (DeepSeek). Выбор платформы отключён. - mainWindow.loadURL(provider.homeUrl); + diagLog("loadURL старт: " + provider.homeUrl); + mainWindow.loadURL(provider.homeUrl).catch((err) => { + diagLog( + "loadURL reject: " + (err && err.message ? err.message : String(err)), + ); + }); // Инжект перехватчика токенов в ОСНОВНОЙ мир: preload в изолированном мире // (contextIsolation: true) не видит window.fetch сайта, поэтому патч ставим diff --git a/src/main/subagent.js b/src/main/subagent.js index 212c367..32a7b0f 100644 --- a/src/main/subagent.js +++ b/src/main/subagent.js @@ -208,11 +208,14 @@ async function runAgent(opts) { name: "子代理: " + (opts.agentName || "agent"), partition: parent.partition, isSubagent: true, + // ВАЖНО: subagentConfig уходит в additionalArguments (argv) дочернего окна. + // Windows-лимит командной строки ~32 КБ, поэтому тяжёлые поля + // (systemPrompt, tools) здесь НЕ передаём — их доставим отдельно: + // systemPrompt — через initial-prompt, tools — через toolsWhitelist + // в контексте окна (см. ниже). Здесь только лёгкие идентификаторы. subagentConfig: { agentName: opts.agentName, task: opts.task, - systemPrompt: opts.systemPrompt, - tools: opts.tools || null, maxTurns: opts.maxTurns || null, projectDir: projectDir || null, }, From 7a8450f03caf295f246d69bd4985e0de4407f014 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:42:36 +0300 Subject: [PATCH 9/9] docs: document two-level memory, F1, commit button and v5.0.4 Update README (ru/en) with two-level memory, hotkeys (F1), Commit button and draggable What's New window. Require changelog-writer agent to review every modified file. Add v5.0.4 changelog entry. --- CHANGELOG.md | 65 +++++++++++++++++-------------- README.en.md | 29 ++++++++++++++ README.md | 13 ++++++- README.ru.md | 27 +++++++++++++ cookie/agents/changelog-writer.md | 50 ++++++++++++++++++++---- 5 files changed, 146 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7ae5e..381898d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,47 @@ # Changelog -## [5.0.2] - 2026-10-06 +## [5.0.4] - 2026-10-07 ### Added -- 🚀 **Субагенты — теперь у AI есть свои помощники!** 😎 Представьте: просите AI «разберись с этим куском проекта», а он не тонет в тысяче файлов сам, а зовёт специалиста. Каждый субагент работает в **отдельном окне** со своим контекстом — весь его рабочий шум (поиски, чтения, пробы) остаётся там, а вам прилетает только чистый итог. Основной чат не пухнет, голова не кругом: - - 🎯 `run_agent(name, task)` — «эй, explore, найди все вызовы apiFetch». Открывается второе окно (тот же логин DeepSeek — ничего заново вводить не надо), субагент делает дело, окно само закрывается, родителю уходит резюме; - - 📋 `list_agents` — глянуть, кто вообще у нас в команде: имя, описание, какие инструменты разрешены, сколько шагов максимум; - - 🔍 `read_agent(name)` — подсмотреть полный промпт агента (удобно для отладки); - - 🧬 `create_agent({...})` — а вот самое интересное: **AI может вырастить себе специалиста прямо из чата!** «Создай агента для ревью SQL-миграций» — и через секунду файл готов, можно сразу делегировать; - - 📂 агенты лежат в `cookie/agents/*.md` (проектные — едут с репо, команда видит) или в `~/cookie/agents/*.md` (личные, для всех проектов). Формат — обычный markdown с frontmatter (`name`, `description`, `tools`, `maxTurns`); - - 🎁 из коробки уже есть четверо: `explore` (разведчик), `code-reviewer` (ревьюер), `git-committer` (гит-мастер), `changelog-writer` (летописец). -- 🔒 **Whitelist инструментов — субагент делает только то, что ему разрешили** 🛡️ Написали в frontmatter `tools: read, grep` — и всё, `bash`, `mysql`, `deleteFile` для него закрыты наглухо: - - проверка происходит **до** запуска инструмента — не «а потом откатим», а «даже не начнём»; - - попробует запрещённое — получит чёткое «Инструмент X не разрешён этому агенту, разрешены: read, grep»; - - а ещё субагент не может позвать другого субагента — никаких бесконечных матрёшек 🪆. -- 🤖 **Субагенты теперь болтают с вами в Telegram** 📱 Полный жизненный цикл на экране телефона: - - 🚀 при делегировании сразу прилетает карточка «кому и что поручили»; - - 🛠 каждое действие агента помечено «🤖 Агент: <имя>» — сразу видно, кто работает; - - 🎯 когда агент закончил — отдельное сообщение с итогом; - - 🔇 и никаких дублей: ответы из окна субагента не спамят в чат-фид; - - ⏱ финал определяется по «текст устоялся» (а не по старой ненадёжной проверке), с учётом лимита шагов и таймаута. -- 🧠 **Память AI — теперь AI вас помнит** 💭 Скажите «Запомни, что меня зовут Ваня» или «Сохрани в память: у меня Windows 11 и RTX 3060» — и он запомнит. В следующей сессии уже будет знать контекст, не придётся повторять: - - 💾 `memory_save(text)` — сохранить одну мысль (одна запись = одна строчка с датой); - - 📖 `memory_read()` — прочитать всё, что накопилось; - - 🗑 `memory_clear()` — стереть всё (только по прямой просьбе — AI сначала предупредит); - - 📁 всё живёт в одном файлике `/memory.md`, можно открыть или очистить прямо из настроек; - - 🎯 содержимое памяти **не** подмешивается в промпт автоматически — читается только когда реально нужно, чтобы не жечь токены зря; - - 🔒 в plan-mode любые изменения памяти заблокированы — на этапе планирования AI ничего не перепишет. -- 📚 **Новый раздел в README про субагентов** — как делегировать, три способа завести агента, что его защищает (whitelist, anti-recursion, `maxTurns`, timeout) и готовый пример файла агента. Читайте и заводите своих! 📖 +- 🧠 **Двухуровневая память AI** — теперь AI помнит и вас, и проект: раньше была одна общая память на всё, теперь их две, и каждая живёт там, где ей место: + + | Уровень | Где хранится | Что помнит | + | ------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | + | 🗂 Проектная | `/.cuckoo/memory/ProjectMemory.md` | Соглашения, архитектурные решения, важные пути, стек, договорённости. Едет вместе с репозиторием (папка в `.gitignore` — личные заметки AI не коммитятся) | + | 🌍 Глобальная | `/memory.md` | Общие факты о вас и вашей системе: имя, ОС, GPU, предпочтения по стилю кода | + - 📖 `memory_read(scope?)` — прочитать: `'all'` (по умолчанию — проектная + глобальная), `'project'`, `'global'`; + - 💾 `memory_save(text, scope?)` — сохранить. По умолчанию пишет в ==проектную память==, если проект инициализирован, иначе в глобальную; `scope='global'` — для фактов о пользователе; + - 🗑 `memory_clear(scope?)` — очистить (только по прямой просьбе, AI предупредит); + - 🎛 настройки памяти в UI тоже получили параметр `scope` — открытие файла, очистка и статистика работают отдельно для каждого уровня; + - 🔗 в промпт AI подставляются ==абсолютные пути== к обоим файлам, а в JS-скриптах доступны как `globalThis.memoryPath` и `globalThis.projectMemoryPath`. + +- ✅ **Кнопка «Коммит» в панели изменений** — собрать все diff разом и отправить в чат: + + | До | После | + | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | + | Нужно было вручную копировать каждый diff по файлу | Одна кнопка склеивает unified diff **всех** изменённых файлов (включая untracked) и шлёт готовое сообщение в чат | + - 📋 `git-diff-all` — новый IPC-хендлер, склеивает unified diff **всех** изменённых файлов (включая untracked); + - 🤖 в чат уходит готовое сообщение с просьбой закоммитить — можно позвать агента `git-committer` или сделать самому. Кнопка со звёздочкой в шапке панели diff, после клика панель закрывается. + +- 🛡 **`safeAsync` — безопасный запуск async-функций** в `src/preload/dom/safe.js`: + - rejected Promise больше не «роняет» вызывающий пайплайн — ошибка логируется, а Promise резолвится в `fallback`; + - учёт частоты падений по label (`failCounts`), throttle-ключ теперь включает текст сообщения — разные ошибки одного label не схлопываются; + - `fallback` может быть функцией — вызывается безопасно, её падение наружу не пробрасывается. ### Changed -- 📁 **Агенты переехали: `.cuckoo/agents` → `cookie/agents`** — теперь проектные агенты лежат в репозитории рядышком с кодом (команда видит и использует), а личные — в `~/cookie/agents` (вне репо). Сканер, типы и документация обновлены. 🚚 -- 🎛 **Окно субагента получает полный системный промпт Cookie Code** — тот же набор инструментов и формат `cuckoo`, что у основного ассистента. То есть субагент работает ровно так же умело, просто в своей песочнице. 🧰 +- 🎨 **Иконки обновления в оверлее** — эмодзи 🔄 заменены на ==SVG-иконки== (кнопки обновления MCP и панели diff). Чётче выглядит, не зависит от шрифта системы. +- 📝 **Промпт субагента: «1–2 инструмента за раз»** — в задание агенту добавлена просьба не вызывать сразу пачку инструментов. Меньше шума в логах, аккуратнее шаги. +- 🧹 **Чистка репозитория** — удалён спрайт `assets/петы.jpg` и его секция из `README.ru.md` (питомцы больше не документируются). ### Fixed -- 🐛 **Рабочая папка субагента больше не теряется** — была хитрая бага: при загрузке домашней DeepSeek (когда `sessionId` ещё не появился) выбранная папка проекта сбрасывалась в `null`, и агент начинал искать файлы «в никуда». Теперь папка кэшируется (`pendingProjectDir`) до готовности сессии и привязывается сама — относительные пути работают с первой секунды. ✅ +- 🐛 **Async-ошибки в `safe()` больше не теряются** — sync-путь работал, а Promise-результат при падении давал unhandledRejection. Теперь оба пути перехватываются одинаково. +- 🐛 **Панель diff закрывается после клика «Коммит»** — кнопка вставляла diff в чат, но окно оставалось висеть поверх. Теперь `closeDiffPanel()` вызывается сразу после отправки. +- 🐛 **Глобальная память не теряется при работе без проекта** — `getMemoryPath` теперь принимает `scope` и `projectDir`, для глобального уровня `projectDir` не нужен. IPC-хендлеры аккуратно возвращают ошибку, если проект не инициализирован, а не падают. + +### Removed + +- 🗑 **`app.setName("Cookie Code")`** из `src/main/index.js` — принудительная установка имени в dev-режиме убрана. + diff --git a/README.en.md b/README.en.md index 5a3aa85..337eea8 100644 --- a/README.en.md +++ b/README.en.md @@ -47,6 +47,7 @@ The AI can now: - 📊 **Manage tasks** — create, track, and complete todo lists - 🌐 **Fetch data** — HTTP requests, API calls, web scraping - 🔧 **MCP tools** — connect any MCP server for extended capabilities +- 🧠 **Two-level memory** — AI remembers both you and the project: **global** memory (`/memory.md`) holds user/system facts, **project** memory (`/.cuckoo/memory/ProjectMemory.md`) holds conventions, architecture and key paths **How it works:** @@ -135,6 +136,33 @@ Control everything from your phone: **Voice messages** → transcribed via local Whisper and sent to AI **Photos/files** → attached to the chat input field +### 🧠 **Two-Level Memory** + +The AI remembers **both you and the project** — memory is split into two independent levels: + +| Level | Location | What it remembers | +| --------- | ---------------------------------------------- | ---------------------------------------------------------------------- | +| 🌍 Global | `/memory.md` | User and system facts: name, OS, GPU, code style preferences | +| 🗂 Project | `/.cuckoo/memory/ProjectMemory.md` | Conventions, architectural decisions, key paths, stack of this project | + +- Tools `memorySave(text, scope?)`, `memoryRead(scope?)`, `memoryClear(scope?)` accept `scope`: `'project'` | `'global'` (`memoryRead` also `'all'`). +- By default `memorySave` writes to **project** memory if the project is initialized, otherwise to global. +- Absolute paths to both files are injected into the system prompt and exposed in JS scripts as `globalThis.memoryPath` and `globalThis.projectMemoryPath`. +- Files can be opened, cleared, and their stats viewed separately per level in **Settings → Cookie Code**. +- Project memory lives in `.gitignore` — the AI's personal notes are never committed. + +### ⌨️ **Hotkeys** + +| Key | Action | +| ---------------- | ------------------------------------------- | +| **F1** | Show the current CHANGELOG (What's New box) | +| **F6 / F7** | Statistics dashboard | +| **F8–F11** | Pet controls | +| **Ctrl+Shift+C** | Toggle the Cookie Code overlay | +| **Esc** | Close panels and modals | + +The What's New window (F1) is **draggable by its header** and adapts its width to the screen. + ### 📊 **Git Integration**

@@ -148,6 +176,7 @@ Built-in diff viewer: - **Navigation** — jump between hunks, filter added/deleted lines - **Mini-map** — visual overview of changes - **Inline diff** — character-level highlighting for precise edits +- **Commit button** — stitches the unified diff of **all** changed files and posts a ready message to the chat asking to commit (you can summon the `git-committer` agent or commit yourself) ### 📈 **Statistics Dashboard** diff --git a/README.md b/README.md index ca278b4..ba744a7 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ - 📊 **Управлять задачами** — создавать, отслеживать и завершать todo-списки - 🌐 **Получать данные** — HTTP-запросы, API-вызовы, веб-скрапинг - 🔧 **MCP-инструменты** — подключать любой MCP-сервер для расширенных возможностей -- 🧠 **Память** — AI запоминает факты о вас и вашей системе («Сохрани в память, что…»), читает их в следующих сессиях через `memoryRead()`. Файл памяти лежит в папке пользователя, открывается и очищается в настройках. +- 🧠 **Двухуровневая память** — AI помнит и вас, и проект: **глобальная** память (`/memory.md`) хранит факты о пользователе и системе, **проектная** (`/.cuckoo/memory/ProjectMemory.md`) — соглашения, архитектуру и важные пути конкретного проекта. Инструменты `memorySave`/`memoryRead`/`memoryClear` принимают `scope` ('project' | 'global' | 'all'). Файлы открываются и очищаются в настройках. **Как это работает:** @@ -141,6 +141,17 @@ maxTurns: 20 - **Навигация** — прыгайте между hunks, фильтруйте добавленные/удалённые строки - **Мини-карта** — визуальный обзор изменений - **Внутристроковый diff** — посимвольная подсветка для точных правок +- **Кнопка «Коммит»** — склеивает unified diff **всех** изменённых файлов и отправляет в чат готовое сообщение с просьбой закоммитить (можно позвать агента `git-committer` или закоммитить самому) + +### ⌨️ **Горячие клавиши** + +| Клавиша | Действие | +| ---------------- | -------------------------------------------- | +| **F1** | Показать текущий CHANGELOG (окно What's New) | +| **F6 / F7** | Дашборд статистики | +| **F8–F11** | Управление петом | +| **Ctrl+Shift+C** | Показать/скрыть оверлей Cookie Code | +| **Esc** | Закрыть панели и модалки | ### 📈 **Дашборд статистики** diff --git a/README.ru.md b/README.ru.md index 7b8b5b4..8a8740b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -122,6 +122,33 @@ Без системного меню Electron — приложение сразу открывается в DeepSeek. Все стандартные горячие клавиши (Ctrl+C/V, Ctrl+R, F12) работают. +### Двухуровневая память + +AI помнит **и вас, и проект** — память разделена на два независимых уровня: + +| Уровень | Где хранится | Что помнит | +| ------------- | ---------------------------------------------- | ------------------------------------------------------------------------ | +| 🌍 Глобальная | `/memory.md` | Факты о пользователе и системе: имя, ОС, GPU, предпочтения по стилю кода | +| 🗂 Проектная | `/.cuckoo/memory/ProjectMemory.md` | Соглашения, архитектурные решения, важные пути, стек конкретного проекта | + +- Инструменты `memorySave(text, scope?)`, `memoryRead(scope?)`, `memoryClear(scope?)` принимают `scope`: `'project'` | `'global'` (а `memoryRead` ещё `'all'`). +- По умолчанию `memorySave` пишет в **проектную** память, если проект инициализирован, иначе в глобальную. +- Абсолютные пути к обоим файлам подставляются в системный промпт и доступны в JS-скриптах как `globalThis.memoryPath` и `globalThis.projectMemoryPath`. +- Файлы открываются, очищаются и показывают статистику отдельно для каждого уровня — в **Настройки → Cookie Code**. +- Проектная память лежит в `.gitignore` — личные заметки AI не коммитятся. + +### Горячие клавиши + +| Клавиша | Действие | +| ---------------- | -------------------------------------------- | +| **F1** | Показать текущий CHANGELOG (окно What's New) | +| **F6 / F7** | Дашборд статистики | +| **F8–F11** | Управление петом | +| **Ctrl+Shift+C** | Показать/скрыть оверлей Cookie Code | +| **Esc** | Закрыть панели и модалки | + +Окно What's New (F1) можно **перетаскивать за шапку** и оно адаптируется по ширине под экран. + ### Перенос контекста Длинный чат можно продолжить в новом, не теряя наработок: diff --git a/cookie/agents/changelog-writer.md b/cookie/agents/changelog-writer.md index d5ca883..62a817d 100644 --- a/cookie/agents/changelog-writer.md +++ b/cookie/agents/changelog-writer.md @@ -103,6 +103,34 @@ log(diff); Собери из истории коммитов понимание: какие фичи, фиксы, рефакторинг. +### ОБЯЗАТЕЛЬНО: просмотри каждый обновлённый файл + +**Не ограничивайся `git diff --stat` и сообщениями коммитов.** Чтобы расписать +изменения максимально точно, ты **обязан** открыть и прочитать каждый изменённый +файл целиком (или его diff с последнего тега), а не только список путей. + +Алгоритм: + +1. Получи список изменённых файлов: + ```cuckoo + const files = await bash("git diff --name-only " + latestTag + "..HEAD"); + log(files); + ``` +2. Для **каждого** файла из списка прочитай его diff с последнего тега: + ```cuckoo + const d = await bash("git diff " + latestTag + "..HEAD -- "путь/к/файлу""); + log(d); + ``` + Если diff слишком большой или обрезан — прочитай сам файл через `read`/`readLines`, + чтобы понять контекст изменений (что делает функция/модуль, что поменялось). +3. Для ключевых файлов (новая логика, публичные API, инструменты, конфиги) — + прочитай файл целиком, чтобы описать изменение осмысленно, а не по названию. +4. Только после этого формируй пункты записи. Каждый пункт должен быть основан на + реально увиденном коде/диффе, а не на догадке из commit message. + +> Это правило существует потому, что commit message часто короче и неточнее +> реальных изменений. Точность What's New важнее скорости. + ## Составь запись Строго по шаблону из гайда: @@ -288,15 +316,19 @@ log(check); 1. **НЕ трогай `package.json`** — версию туда выставит CI из git-тега. 2. **НЕ создавай коммит и НЕ пушь** — это делает родительский диалог отдельно. 3. **НЕ выдумывай изменения** — только то, что реально видно в git history. -4. **В CHANGELOG.md должна остаться ровно ОДНА запись** — последняя версия. Все старые записи **удаляются** (перезаписывай файл целиком через `write`). -5. **НЕ меняй `CHANGELOG_GUIDE.md`** — если найдёшь проблему, сообщи в отчёте. -6. Формат заголовка — **строго** `## [X.Y.Z] - YYYY-MM-DD`. -7. **Версия = последний git-тег + 1 в patch** (по умолчанию). Не выдумывай minor/major сам. -8. **НЕ вставляй "Unreleased"** — эта секция не покажется в What's New. -9. **НЕ добавляй историю старых версий** — ни `## [5.0.0]`, ни предыдущих. Только текущая. -10. **НЕ вставляй сырой HTML** — рендер экранирует теги. Используй только markdown +4. **Просматривай каждый изменённый файл.** Перед составлением записи открой + diff/содержимое **каждого** файла из `git diff --name-only ..HEAD` (см. + раздел «ОБЯЗАТЕЛЬНО: просмотри каждый обновлённый файл»). Пункты записи, + основанные только на commit message без чтения файла, считаются невалидными. +5. **В CHANGELOG.md должна остаться ровно ОДНА запись** — последняя версия. Все старые записи **удаляются** (перезаписывай файл целиком через `write`). +6. **НЕ меняй `CHANGELOG_GUIDE.md`** — если найдёшь проблему, сообщи в отчёте. +7. Формат заголовка — **строго** `## [X.Y.Z] - YYYY-MM-DD`. +8. **Версия = последний git-тег + 1 в patch** (по умолчанию). Не выдумывай minor/major сам. +9. **НЕ вставляй "Unreleased"** — эта секция не покажется в What's New. +10. **НЕ добавляй историю старых версий** — ни `## [5.0.0]`, ни предыдущих. Только текущая. +11. **НЕ вставляй сырой HTML** — рендер экранирует теги. Используй только markdown (`==mark==`, `**bold**`, таблицы, чек-листы) — см. раздел «Возможности рендера What's New». -11. **Расширенный markdown (таблицы, `==mark==`, чек-листы, `> цитаты`) — опционально.** +12. **Расширенный markdown (таблицы, `==mark==`, чек-листы, `> цитаты`) — опционально.** Их можно использовать для выразительности, но не в ущерб читаемости. Простой список фич таблицей не оформляй. @@ -316,6 +348,8 @@ Git-тег для push: vX.Y.Z Проверка парсером: ✅ новая версия первая в списке. +Просмотрено файлов: N из N (каждый изменённый файл прочитан через git diff / read). + Что НЕ вошло (сомнительные изменения): - ... ```