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/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). + Что НЕ вошло (сомнительные изменения): - ... ``` 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/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/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/ipc.js b/src/main/ipc.js index 3e67a75..398cb0f 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); @@ -431,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 { @@ -1553,6 +1588,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 { @@ -1699,9 +1751,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 }; @@ -1711,9 +1779,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) { @@ -1722,10 +1792,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/main/subagent.js b/src/main/subagent.js index 026843f..32a7b0f 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( "Когда закончишь, дай сразу финальный результат (не вызывай больше инструментов).", ); @@ -206,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, }, 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 }; diff --git a/src/preload/api.js b/src/preload/api.js index 3495140..cd710cb 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: () => { @@ -265,6 +266,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 }); }, @@ -311,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"); 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 }; 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/i18n/i18n.js b/src/preload/i18n/i18n.js index 92a0583..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" }, @@ -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..c8319e5 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -605,6 +605,13 @@ 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(); + closeDiffPanel(); + }); + // Diff-панель: обновить (в зависимости от активной вкладки) const diffRefreshBtn = document.getElementById("cuckoo-diff-refresh"); diffRefreshBtn?.addEventListener("click", () => { @@ -872,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(); 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; }", 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 + ")."); } }