feat: мульти-провайдер LLM, тренды дизайна, Snyk/Documatic sync, личная автоматизация

- Мульти-провайдер: PAPAYU_LLM_PROVIDERS — сбор планов от нескольких ИИ (Claude, OpenAI), агрегация
- Тренды дизайна и иконок: вкладка в модалке, поиск по безопасным доменам (Tavily include_domains)
- Snyk Code: PAPAYU_SNYK_SYNC, REST API issues → snyk_findings в agent-sync
- Documatic: architecture_summary из .papa-yu/architecture.md в agent-sync
- Личная автоматизация: capability personal-automation (терминал git/npm/cargo, открытие URL)
- agent_sync расширен: snyk_findings, architecture_summary; analyze_project_cmd и run_batch пишут sync
- Документация: SNYK_AND_DOCUMATIC_SYNC.md, SECURITY_AND_PERSONAL_AUTOMATION.md, обновлён CLAUDE_AND_AGENT_SYNC

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-10 15:05:39 +03:00
parent 764003fc09
commit 65e95a458d
141 changed files with 16780 additions and 7302 deletions

View File

@ -1,28 +1,64 @@
name: Protocol check (v1 + v2) name: CI (fmt, clippy, audit, protocol, frontend build)
on: on:
push: push:
branches: [main, master] branches: [main, master]
pull_request: pull_request:
branches: [main, master] branches: [main, master]
jobs: jobs:
protocol: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust - name: Setup Node
uses: dtolnay/rust-toolchain@stable uses: actions/setup-node@v4
with:
- name: Cache cargo node-version: '20'
uses: actions/cache@v4 cache: 'npm'
with:
path: | - name: Install and build
~/.cargo/registry run: npm ci && npm run build
~/.cargo/git
target check:
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} runs-on: ubuntu-latest
steps:
- name: golden_traces (v1 + v2) - uses: actions/checkout@v4
run: cd src-tauri && cargo test golden_traces --no-fail-fast
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Format check
run: cd src-tauri && cargo fmt --check
- name: Clippy
run: cd src-tauri && cargo clippy --all-targets --
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Cargo audit
run: cd src-tauri && cargo audit
continue-on-error: true
- name: Install cargo-deny
run: cargo install cargo-deny
- name: Cargo deny
run: cd src-tauri && cargo deny check
continue-on-error: true
- name: Tests (all, including golden_traces)
run: cd src-tauri && cargo test --no-fail-fast

View File

@ -6,6 +6,20 @@
--- ---
## [2.4.5] — 2025-01-31
### Добавлено
- **Distill Online Research → Project Note:** кнопка «Save as Project Note» в блоке Online Research (Задачи) — сохраняет результат online research в domain notes проекта.
- **Контекст v3:** FILE-блоки при protocol_version=3 теперь включают sha256 (base_sha256 для EDIT_FILE). Исправлено: ранее sha256 добавлялся только для v2.
- **C1C3 Protocol v3:** schema (after minLength=0, maxLength 50k для before/after), валидатор (after может быть пустым для delete), repair-промпты для ERR_EDIT_ANCHOR_NOT_FOUND / ERR_EDIT_BEFORE_NOT_FOUND / ERR_EDIT_AMBIGUOUS, golden traces v3 + CI. Обновлён schema_hash в fixtures.
### Обновлено
- `docs/IMPLEMENTATION_STATUS_ABC.md`: A4, B3, C отмечены как реализованные.
---
## [2.4.4] — 2025-01-31 ## [2.4.4] — 2025-01-31
### Protocol stability (v1) ### Protocol stability (v1)

View File

@ -1,24 +1,24 @@
.PHONY: golden golden-latest test-protocol test-all .PHONY: golden golden-latest test-protocol test-all
# make golden TRACE_ID=<id> — из .papa-yu/traces/<id>.json # make golden TRACE_ID=<id> — из .papa-yu/traces/<id>.json
# make golden — из последней трассы (golden-latest) # make golden — из последней трассы (golden-latest)
golden: golden:
@if [ -n "$$TRACE_ID" ]; then \ @if [ -n "$$TRACE_ID" ]; then \
cd src-tauri && cargo run --bin trace_to_golden -- "$$TRACE_ID"; \ cd src-tauri && cargo run --bin trace_to_golden -- "$$TRACE_ID"; \
else \ else \
$(MAKE) golden-latest; \ $(MAKE) golden-latest; \
fi fi
golden-latest: golden-latest:
@LATEST=$$(ls -t .papa-yu/traces/*.json 2>/dev/null | head -1); \ @LATEST=$$(ls -t .papa-yu/traces/*.json 2>/dev/null | head -1); \
if [ -z "$$LATEST" ]; then \ if [ -z "$$LATEST" ]; then \
echo "No traces in .papa-yu/traces/. Run with PAPAYU_TRACE=1, propose fixes, then make golden."; \ echo "No traces in .papa-yu/traces/. Run with PAPAYU_TRACE=1, propose fixes, then make golden."; \
exit 1; \ exit 1; \
fi; \ fi; \
cd src-tauri && cargo run --bin trace_to_golden -- "../$$LATEST" cd src-tauri && cargo run --bin trace_to_golden -- "../$$LATEST"
test-protocol: test-protocol:
cd src-tauri && cargo test golden_traces cd src-tauri && cargo test golden_traces
test-all: test-all:
cd src-tauri && cargo test cd src-tauri && cargo test

View File

@ -1,4 +1,4 @@
# PAPA YU v2.4.4 # PAPA YU v2.4.5
Десктопное приложение для анализа проекта и автоматических исправлений (README, .gitignore, tests/, структура) с **транзакционным apply**, **реальным undo** и **autoCheck с откатом**. Десктопное приложение для анализа проекта и автоматических исправлений (README, .gitignore, tests/, структура) с **транзакционным apply**, **реальным undo** и **autoCheck с откатом**.
@ -32,6 +32,13 @@ npm run tauri dev
npm run tauri build npm run tauri build
``` ```
## v2.4.5 — что реализовано
### Добавлено в v2.4.5
- **Save as Project Note** — кнопка в блоке Online Research сохраняет результат в domain notes проекта (distill через LLM).
- **Контекст v3** — при `PAPAYU_PROTOCOL_VERSION=3` FILE-блоки включают sha256 для base_sha256 в EDIT_FILE.
## v2.4.4 — что реализовано ## v2.4.4 — что реализовано
### Анализ и профиль ### Анализ и профиль
@ -50,6 +57,7 @@ npm run tauri build
- **Защита путей** — запрещено изменение служебных путей (.git, node_modules, target, dist и т.д.) и бинарных файлов; разрешены только текстовые расширения (см. guard в коде). - **Защита путей** — запрещено изменение служебных путей (.git, node_modules, target, dist и т.д.) и бинарных файлов; разрешены только текстовые расширения (см. guard в коде).
- **Подтверждение** — применение только при явном подтверждении пользователя (user_confirmed). - **Подтверждение** — применение только при явном подтверждении пользователя (user_confirmed).
- **Allowlist команд** — в verify и auto_check выполняются только разрешённые команды с фиксированными аргументами (конфиг в `src-tauri/config/verify_allowlist.json`). - **Allowlist команд** — в verify и auto_check выполняются только разрешённые команды с фиксированными аргументами (конфиг в `src-tauri/config/verify_allowlist.json`).
- **Терминал и интернет (личное использование)** — приложение может открывать ссылки в браузере (Chrome и др.) и выполнять ограниченный набор команд (git, npm, cargo и т.д.) через scope в capability `personal-automation`. Подробнее: **`docs/SECURITY_AND_PERSONAL_AUTOMATION.md`**.
### UX ### UX
@ -91,6 +99,8 @@ npm run tauri dev
После этого кнопка «Предложить исправления» будет строить план через выбранный LLM. После этого кнопка «Предложить исправления» будет строить план через выбранный LLM.
**Claude и синхронизация с агентом (Claude Code / Cursor):** можно использовать Claude через [OpenRouter](https://openrouter.ai/) (OpenAI-совместимый API): `PAPAYU_LLM_API_URL=https://openrouter.ai/api/v1/chat/completions`, `PAPAYU_LLM_MODEL=anthropic/claude-3.5-sonnet`. При `PAPAYU_AGENT_SYNC=1` после каждого анализа в проекте записывается `.papa-yu/agent-sync.json` для чтения агентом в IDE. Подробнее: `docs/CLAUDE_AND_AGENT_SYNC.md`.
Если `PAPAYU_LLM_API_URL` не задан или пуст, используется встроенная эвристика (README, .gitignore, LICENSE, .env.example по правилам). Если `PAPAYU_LLM_API_URL` не задан или пуст, используется встроенная эвристика (README, .gitignore, LICENSE, .env.example по правилам).
### Online Research (опционально) ### Online Research (опционально)
@ -116,6 +126,26 @@ npm run tauri dev
- Защита от циклов: максимум 1 auto-chain на один запрос (goal). - Защита от циклов: максимум 1 auto-chain на один запрос (goal).
- UI: при auto-use показывается метка "Auto-used ✓"; кнопка "Disable auto-use" отключает для текущего проекта (сохраняется в localStorage). - UI: при auto-use показывается метка "Auto-used ✓"; кнопка "Disable auto-use" отключает для текущего проекта (сохраняется в localStorage).
**Тренды дизайна и иконок (вкладка в модалке «Тренды и рекомендации»):**
- Поиск трендовых дизайнов сайтов/приложений и иконок **только из безопасных источников** (allowlist доменов: Dribbble, Behance, Figma, Material, Heroicons, Lucide, shadcn, NNGroup и др.).
- Используется тот же **`PAPAYU_TAVILY_API_KEY`**; запросы идут с параметром `include_domains` — только разрешённые домены.
- Результаты показываются в списке и **подмешиваются в контекст ИИ** при «Предложить исправления», чтобы агент мог предлагать передовые дизайнерские решения при создании программ.
### Domain notes (A1A3)
Короткие «domain notes» на проект из online research: хранятся в `.papa-yu/notes/domain_notes.json`, при следующих запросах подмешиваются в prompt (с лимитами), чтобы реже ходить в Tavily и быстрее отвечать.
- **Формат:** `schema_version`, `updated_at`, `notes[]` (id, topic, tags, content_md, sources, confidence, ttl_days, usage_count, last_used_at, pinned).
- **Лимиты:** `PAPAYU_NOTES_MAX_ITEMS=50`, `PAPAYU_NOTES_MAX_CHARS_PER_NOTE=800`, `PAPAYU_NOTES_MAX_TOTAL_CHARS=4000`, `PAPAYU_NOTES_TTL_DAYS=30`.
- **Дистилляция:** после online research можно сохранить заметку через LLM-сжатие (команда `distill_and_save_domain_note_cmd`).
- **Injection:** в `llm_planner` перед ONLINE_RESEARCH и CONTEXT вставляется блок `PROJECT_DOMAIN_NOTES`; отбор заметок по релевантности к goal (token overlap); при использовании обновляются `usage_count` и `last_used_at`.
- **Trace:** `notes_injected`, `notes_count`, `notes_chars`, `notes_ids`.
- **Команды:** load/save/delete/clear_expired/pin domain notes, distill_and_save_domain_note. Подробнее: `docs/IMPLEMENTATION_STATUS_ABC.md`.
### Weekly report proposals (B1B2)
В еженедельном отчёте LLM может возвращать массив **proposals** (prompt_change, setting_change, golden_trace_add, limit_tuning, safety_rule) с полями title, why, risk, steps, expected_impact, evidence. В prompt добавлено правило: предлагать только то, что обосновано bundle + deltas. Секция «Предложения (proposals)» выводится в report_md.
### Тестирование ### Тестирование
- **Юнит-тесты (Rust)** — тесты для `detect_project_type`, `get_project_limits`, `is_protected_file`, `is_text_allowed` (см. `src-tauri/src/commands/get_project_profile.rs` и `apply_actions_tx.rs`). Запуск: `cd src-tauri && cargo test`. - **Юнит-тесты (Rust)** — тесты для `detect_project_type`, `get_project_limits`, `is_protected_file`, `is_text_allowed` (см. `src-tauri/src/commands/get_project_profile.rs` и `apply_actions_tx.rs`). Запуск: `cd src-tauri && cargo test`.
@ -128,7 +158,15 @@ npm run tauri dev
## Документация ## Документация
- `docs/LIMITS.md` — границы продукта, Critical failures.
- `docs/ARCHITECTURE.md` — обзор архитектуры, модули, границы.
- `docs/RUNBOOK.md` — сборка, запуск, типовые проблемы.
- `docs/adr/` — Architecture Decision Records (Tauri, EDIT_FILE v3, SSRF).
- `docs/TECH_MEMO_FOR_INVESTORS.md` — технический memo для инвесторов.
- `docs/BUYER_QA.md` — вопросы покупателя и ответы.
- `docs/IMPROVEMENTS.md` — рекомендации по улучшениям. - `docs/IMPROVEMENTS.md` — рекомендации по улучшениям.
- `docs/E2E_SCENARIO.md` — E2E сценарий и критерии успеха. - `docs/E2E_SCENARIO.md` — E2E сценарий и критерии успеха.
- `docs/ОЦЕНКА_И_СЦЕНАРИЙ_РАССКАЗА.md` — оценка необходимости обновлений и сценарий рассказа о программе по модулям. - `docs/EDIT_FILE_DEBUG.md` — чеклист отладки v3 EDIT_FILE.
- `docs/INVESTMENT_READY_REPORT.md` — отчёт о готовности к передаче/продаже.
- `docs/ПРЕЗЕНТАЦИЯ_PAPA_YU.md` — полномасштабная презентация программы (25 слайдов).
- `CHANGELOG.md` — история изменений по версиям. - `CHANGELOG.md` — история изменений по версиям.

137
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,137 @@
# Architecture Overview — papa-yu
## 1. Purpose
papa-yu is a desktop application built with Tauri.
Its goal is to orchestrate LLM-driven workflows involving local files, structured editing (PATCH/EDIT), and controlled external research.
The system prioritizes:
- deterministic behavior
- reproducibility (golden traces)
- controlled IO and network access
- long-term maintainability
---
## 2. High-level architecture
- Desktop application (Tauri)
- Core logic implemented in Rust
- UI acts as a thin client
- All critical logic resides in the Rust backend
**Key principle:**
UI never performs filesystem or network operations directly.
---
## 3. Core modules
### 3.1 net
**Location:** `src-tauri/src/net.rs`
**Responsibilities:**
- Single entry point for all outbound network access
- SSRF protection
- Request limits (timeout, size)
- Explicit allow/deny rules
**Constraints:**
- No direct `reqwest::Client::get()` usage outside this module
- All fetch operations go through `fetch_url_safe`
---
### 3.2 llm_planner
**Responsibilities:**
- Planning and orchestration of LLM-driven workflows
- Translating user intent into structured operations
- Managing execution order and context
**Known risks:**
- Sensitive to malformed prompts
- Requires deterministic input for reproducible behavior
---
### 3.3 online_research
**Responsibilities:**
- External information retrieval
- Adapter layer over `net::fetch_url_safe`
- Re-export of safe network primitives
**Design note:** Acts as an integration boundary, not business logic.
---
### 3.4 commands/*
**Responsibilities:**
- Tauri command boundary
- Validation of input coming from UI
- Delegation to internal services
**Constraints:**
- No business logic
- No direct filesystem or network access
---
## 4. Data flow (simplified)
```
UI → Tauri command → domain/service logic → adapters (fs / net) → result returned to UI
```
---
## 5. Protocol versions and determinism
- Multiple protocol versions (v1, v2, v3)
- Golden traces used to lock observable behavior
- Protocol changes are versioned explicitly
This enables:
- regression detection
- reproducible behavior across releases
---
## 6. Architectural boundaries (hard rules)
- Domain logic must not perform IO directly
- All network access must go through `net`
- Filesystem access is centralized
- Side effects are isolated and testable
Violations are treated as architectural defects.
---
## 7. Extension points
- New research sources via `online_research`
- New protocol versions
- Additional planners or execution strategies
---
## 8. Known limitations
- Not designed for real-time or high-concurrency workloads
- Desktop-oriented architecture
- Relies on deterministic execution context for PATCH/EDIT
See `LIMITS.md` for details.

View File

@ -0,0 +1,183 @@
# Перечень материалов для технического аудита
По ТЗ на полный технический аудит ПО. Минимально достаточный и расширенный набор без лишнего.
---
## 1. Минимально необходимый набор (без него аудит поверхностный)
### 1.1 Исходный код
- Репозиторий(и): GitHub / GitLab / Bitbucket / self-hosted
- Актуальная основная ветка
- История коммитов (не squashed snapshot)
👉 Нужно для: архитектуры, качества кода, техдолга, рисков поддержки
---
### 1.2 Описание продукта (коротко)
12 страницы или устно:
- назначение системы
- ключевые сценарии
- критичность для бизнеса
- предполагаемые нагрузки
- **кто основной пользователь** (роль, не persona)
- **что считается критическим отказом**
👉 Нужно для: корректной бизнес-интерпретации рисков и классификации Critical / High
---
### 1.3 Стек и окружение
- Языки, фреймворки
- БД, брокеры, кеши
- Среды: prod / stage / dev
- Где и как хостится
👉 Нужно для: оценки масштабируемости и эксплуатационных рисков
---
### 1.4 Процессы сборки и деплоя
- CI/CD (скрипты, YAML)
- Как выпускаются релизы
- Как откатываются
👉 Нужно для: оценки операционных рисков
---
## 2. Очень желательно (резко повышает ценность аудита)
### 2.1 Архитектурные материалы
- Диаграммы (если есть)
- ADR (Architecture Decision Records)
- Объяснение «почему сделано так»
👉 Позволяет отличить **осознанное решение** от **техдолга**
---
### 2.2 Документация
- README
- инструкции запуска
- API-контракты
- онбординг для разработчиков
👉 Нужно для оценки bus-factor и рисков передачи проекта
---
### 2.3 Тесты
- Наличие / типы
- Отчёты о покрытии (если есть)
---
### 2.4 Зависимости и лицензии
- lock-файлы
- private / forked зависимости
👉 Нужно для юридических и эксплуатационных рисков
---
## 3. По безопасности (если допустимо)
⚠️ **Без доступа к прод-секретам**
**Ограничение:** Аудит безопасности проводится на уровне дизайна и кода, без penetration testing и активных атак. (Защищает от завышенных ожиданий.)
- способ хранения секретов
- auth / roles / permissions
- работа с персональными данными
- результаты прошлых security-аудитов (если были)
---
## 4. Эксплуатация и реальность
### 4.1 Инциденты
- известные падения
- «больные места»
- что боитесь трогать
👉 Очень ценно: показывает реальные риски, а не теоретические
---
### 4.2 Метрики (если есть)
- latency
- error rate
- нагрузка
- cost drivers
### 4.3 Ручные операционные процедуры
- наличие runbooks, чек-листов, ручных шагов (если есть)
- ответ «нет» — тоже полезный сигнал
---
## 5. Границы аудита (обязательно зафиксировать заранее)
Нужно **явно**:
- что **не проверять**
- на чём **не фокусироваться**
- критические зоны (если есть)
Это защищает обе стороны.
---
## 6. Что НЕ нужно
- ❌ доступ к продакшену
- ❌ права на изменение кода
- ❌ идеальная документация
- ❌ «всё переписать» как цель
---
## 7. Итог
> Для оценки программы нужен доступ к коду, понимание назначения продукта, стека и процессов доставки, плюс любые материалы, которые объясняют **почему система устроена именно так**. Всё остальное — усиливает точность, но не является обязательным.
---
## 8. Следующие шаги
- [ ] Составить чеклист передачи материалов аудитору
- [ ] Оценить объём и сроки аудита по продукту
- [ ] Сформулировать NDA / scope для внешнего исполнителя
**Вопрос:** Аудит предполагается **внутренний** или **внешний**?
---
## Приложение: готовность papa-yu
| Материал | Статус | Где |
|----------|--------|-----|
| Репозиторий | ✅ | Локально / при публикации |
| Основная ветка + история | ✅ | `main` |
| Описание продукта | ✅ | `README.md`, `docs/` |
| Стек | ✅ | `package.json`, `Cargo.toml`, `tauri.conf.json` |
| CI/CD | ✅ | `.github/workflows/` |
| Документация | ✅ | `docs/`, `IMPLEMENTATION_STATUS_ABC.md` |
| Тесты | ✅ | `cargo test`, golden traces |
| Зависимости | ✅ | `Cargo.lock`, `package-lock.json` |
| Безопасность (без секретов) | ⚠️ | `docs/` — частично, SSRF/fetch |
| Инциденты / метрики | ❌ | Отсутствуют формализованно, известны на уровне команды |

87
docs/BUYER_QA.md Normal file
View File

@ -0,0 +1,87 @@
# Buyer-style Q&A
Вопросы, которые реально задают на сделке. Использовать как подготовку к разговору или self-check.
---
## Q1. «Насколько проект зависит от одного человека?»
**Ответ:** Критические знания формализованы: архитектура, ключевые решения (ADR), инциденты и runbook задокументированы. Bus-factor оценивается как 1.52 и может быть снижен further без изменения кода.
---
## Q2. «Что здесь самое рискованное технически?»
**Ответ:** Основные риски осознаны и задокументированы:
- чувствительность LLM-планирования к некорректному вводу
- жёсткость PATCH/EDIT протокола
- desktop-ориентированная архитектура
Эти риски не скрыты и управляемы.
---
## Q3. «Что будет, если продукт начнут использовать не по назначению?»
**Ответ:** Границы использования явно описаны в `docs/LIMITS.md`. Сценарии вне design scope считаются unsupported и не маскируются.
---
## Q4. «Почему Rust и Tauri, а не Electron / Web?»
**Ответ:** Решение принято осознанно и зафиксировано в ADR-001:
- меньшая attack surface
- контроль над IO
- производительность
- строгие архитектурные границы
Цена — более высокая инженерная дисциплина, но это снижает долгосрочные риски.
---
## Q5. «Насколько безопасна работа с сетью и внешними данными?»
**Ответ:** Все сетевые операции централизованы и проходят через SSRF-safe модуль. Неконтролируемый сетевой доступ архитектурно запрещён. См. ADR-003.
---
## Q6. «Как вы предотвращаете регрессии?»
**Ответ:** Через golden traces, версионирование протоколов и обязательный CI. Изменения observable behavior без обновления тестов невозможны.
---
## Q7. «Есть ли скрытый техдолг?»
**Ответ:** Техдолг зафиксирован и осознан. Отсутствуют зоны «не трогать, потому что никто не знает как работает».
---
## Q8. «Сколько времени нужно новому владельцу, чтобы начать изменения?»
**Ответ:** Оценка: 35 рабочих дней для инженера с опытом Rust/Tauri до первого осмысленного изменения.
---
## Q9. «Можно ли развивать продукт дальше без переписывания?»
**Ответ:** Да. Архитектура предусматривает точки расширения:
- новые protocol versions
- новые research adapters
- альтернативные planners
---
## Q10. «Почему этот проект — актив, а не просто код?»
**Ответ:** Потому что:
- риски названы
- поведение детерминировано
- качество проверяется автоматически
- знания зафиксированы
Это снижает uncertainty — главный дисконт на сделках.

View File

@ -0,0 +1,23 @@
# Взгляд покупателя: Red flags / Green flags
---
## Green flags (повышают цену)
- 📗 Документация объясняет решения
- 🧠 Техдолг зафиксирован и осознан
- 🔐 Security учтён на уровне дизайна
- 🧪 Тесты ловят регрессии
- 🔁 CI гарантирует воспроизводимость
- 📉 Риски названы прямо
---
## Red flags (снижают цену)
- ❌ «Автор знает, как работает»
- ❌ Нет формализованных инцидентов
- ❌ Сеть / данные без ограничений
- ❌ Архитектура без границ
- ❌ Зависимости без контроля
- ❌ Ответ «пока не было проблем»

View File

@ -0,0 +1,151 @@
# Claude и синхронизация с агентом (Claude Code / Cursor)
Настройка PAPA YU для работы с Claude и автоматической синхронизации состояния с IDE-агентом (Cursor, Claude Code и т.п.).
---
## 1. Использование Claude как LLM
PAPA YU вызывает **OpenAI-совместимый** API. Claude можно подключить двумя способами.
### Вариант A: OpenRouter (рекомендуется)
[OpenRouter](https://openrouter.ai/) даёт единый API для разных моделей, включая Claude. Формат запросов совпадает с OpenAI.
1. Зарегистрируйтесь на [openrouter.ai](https://openrouter.ai/).
2. Создайте API-ключ.
3. Задайте переменные окружения:
```bash
export PAPAYU_LLM_API_URL="https://openrouter.ai/api/v1/chat/completions"
export PAPAYU_LLM_API_KEY="sk-or-v1-ваш-ключ"
export PAPAYU_LLM_MODEL="anthropic/claude-3.5-sonnet"
```
Или для Claude 3 Opus:
```bash
export PAPAYU_LLM_MODEL="anthropic/claude-3-opus"
```
4. Запуск: `npm run tauri dev` (или через `start-with-openai.sh`, подставив эти переменные в `.env.openai`).
Кнопка **«Предложить исправления»** будет вызывать Claude через OpenRouter.
### Вариант B: Прямой API Anthropic
Нативный API Anthropic (Messages API) использует другой формат запросов. В текущей версии PAPA YU его поддержка не реализована — используйте OpenRouter (вариант A).
---
## 2. Мульти-провайдер: сбор от нескольких ИИ и оптимальное решение
Чтобы агент собирал ответы от **нескольких ИИ** (Claude, OpenAI и др.), анализировал их и выдавал один оптимальный план, задайте переменную **PAPAYU_LLM_PROVIDERS** — JSON-массив провайдеров.
### Формат PAPAYU_LLM_PROVIDERS
```json
[
{ "url": "https://api.openai.com/v1/chat/completions", "model": "gpt-4o-mini", "api_key": "sk-..." },
{ "url": "https://openrouter.ai/api/v1/chat/completions", "model": "anthropic/claude-3.5-sonnet", "api_key": "sk-or-v1-..." }
]
```
- **url** — OpenAI-совместимый endpoint.
- **model** — имя модели.
- **api_key** — опционально; если не указан, используется **PAPAYU_LLM_API_KEY**.
Запросы к провайдерам выполняются **параллельно**. Результаты объединяются в один план.
### Агрегация
- **Без агрегатора** (по умолчанию): планы объединяются в Rust: действия по одному пути дедуплицируются, итог — один план с объединённым списком действий.
- **С агрегатором-ИИ**: задайте **PAPAYU_LLM_AGGREGATOR_URL** (и при необходимости **PAPAYU_LLM_AGGREGATOR_KEY**, **PAPAYU_LLM_AGGREGATOR_MODEL**). ИИ-агрегатор получит все планы и вернёт один оптимальный в том же JSON-формате.
Пример (одна строка в `.env.openai`):
```bash
# Мульти-провайдер: Claude + OpenAI, без отдельного агрегатора
export PAPAYU_LLM_PROVIDERS='[{"url":"https://openrouter.ai/api/v1/chat/completions","model":"anthropic/claude-3.5-sonnet","api_key":"sk-or-v1-..."},{"url":"https://api.openai.com/v1/chat/completions","model":"gpt-4o-mini","api_key":"sk-..."}]'
# Опционально: отдельная модель для слияния планов
# PAPAYU_LLM_AGGREGATOR_URL=https://api.openai.com/v1/chat/completions
# PAPAYU_LLM_AGGREGATOR_KEY=sk-...
# PAPAYU_LLM_AGGREGATOR_MODEL=gpt-4o-mini
```
Если **PAPAYU_LLM_PROVIDERS** задан и не пустой, обычный одиночный вызов **PAPAYU_LLM_API_URL** не используется для планирования — вместо него выполняется мульти-провайдерный сценарий.
---
## 3. Автоматическая синхронизация с агентом (Claude Code / Cursor)
Идея: после каждого анализа PAPA YU записывает краткое состояние в файл проекта. Агент в IDE (Cursor, Claude Code) может читать этот файл и учитывать контекст.
### Включение записи sync-файла
Задайте переменную окружения:
```bash
export PAPAYU_AGENT_SYNC=1
```
После каждого успешного анализа в корне **проекта** (путь, который вы анализировали) создаётся или обновляется файл:
```
<путь_проекта>/.papa-yu/agent-sync.json
```
Содержимое (пример):
```json
{
"path": "/Users/you/project",
"updated_at": "2026-02-08T12:00:00Z",
"narrative": "Я проанализировал проект...",
"findings_count": 3,
"actions_count": 5
}
```
- **path** — путь к проекту.
- **updated_at** — время последнего анализа (ISO 8601).
- **narrative** — краткий человекочитаемый вывод.
- **findings_count** / **actions_count** — число находок и действий.
(При необходимости можно расширить полями `report_md_preview` и др.)
### Как использовать в Cursor / Claude Code
1. **Правило в Cursor**
В `.cursor/rules` или в настройках можно добавить правило: «Перед правками проверяй `.papa-yu/agent-sync.json` в корне проекта — там последний анализ PAPA YU (narrative, findings_count, actions_count). Учитывай это при предложениях.»
2. **Чтение из кода/скрипта**
Агент или скрипт может читать `./.papa-yu/agent-sync.json` и использовать поля для контекста или логики.
3. **Обратная связь (по желанию)**
Можно вручную создать `.papa-yu/agent-request.json` с полем `"action": "analyze"` и путём — в будущих версиях PAPA YU сможет обрабатывать такие запросы (сейчас только запись sync-файла реализована).
---
## 4. Онлайн-взаимодействие
- **LLM** уже работает онлайн: запросы к OpenRouter/OpenAI идут по HTTPS.
- **Синхронизация с агентом** — локальная: файл `.papa-yu/agent-sync.json` на диске; Cursor/Claude Code читает его локально.
- **Расширение (будущее)** — опциональный локальный HTTP-сервер в PAPA YU (например, `127.0.0.1:3939`) с эндпоинтами `POST /analyze`, `GET /report` для вызова из скриптов или агента. Пока достаточно файловой синхронизации.
---
## 5. Краткий чеклист
| Шаг | Действие |
|-----|----------|
| 1 | Задать `PAPAYU_LLM_API_URL`, `PAPAYU_LLM_API_KEY`, `PAPAYU_LLM_MODEL` (OpenRouter + Claude). |
| 2 | При необходимости задать `PAPAYU_AGENT_SYNC=1` для записи `.papa-yu/agent-sync.json`. |
| 3 | Запустить PAPA YU, выполнить анализ проекта. |
| 4 | В Cursor/Claude Code добавить правило или логику чтения `.papa-yu/agent-sync.json`. |
---
**Snyk Code и Documatic:** для дополнения анализа кода (Snyk) и структурирования архитектуры (Documatic) см. **`docs/SNYK_AND_DOCUMATIC_SYNC.md`**.
*См. также `docs/OPENAI_SETUP.md`, `env.openai.example`.*

79
docs/CONTRACTS.md Normal file
View File

@ -0,0 +1,79 @@
# Контракты UI ↔ Tauri
Единый источник правды для вызовов команд и форматов ответов. PAPA YU v2.4.5.
---
## Стандарт ответов
- **Успех:** `{ ok: true, ...data }` или возврат типа `AnalyzeReport`, `PreviewResult`, `ApplyResult`, `UndoResult`.
- **Ошибка:** `Result::Err(String)` или поле `ok: false` с `error`, `error_code`, при необходимости `details`.
---
## Команды (invoke)
| Команда | Вход | Выход | Слой UI |
|---------|------|-------|---------|
| `analyze_project_cmd` | `paths`, `attached_files?` | `AnalyzeReport` | lib/tauri.ts |
| `preview_actions_cmd` | `ApplyPayload` | `PreviewResult` | lib/tauri.ts |
| `apply_actions_cmd` | `ApplyPayload` | `ApplyResult` | lib/tauri.ts |
| `apply_actions_tx` | `ApplyPayload` | `ApplyTxResult` | lib/tauri.ts |
| `run_batch_cmd` | `BatchPayload` | `BatchEvent[]` | lib/tauri.ts |
| `undo_last` | — | `UndoResult` | lib/tauri.ts |
| `undo_last_tx` | `path` | `UndoResult` | lib/tauri.ts |
| `undo_available` | — | `UndoRedoState` | lib/tauri.ts |
| `get_undo_redo_state_cmd` | — | `UndoRedoState` | lib/tauri.ts |
| `redo_last` | — | `RedoResult` | lib/tauri.ts |
| `undo_status` | — | `UndoStatus` | lib/tauri.ts |
| `generate_actions` | payload | `GenerateActionsResult` | lib/tauri.ts |
| `generate_actions_from_report` | payload | `Action[]` | lib/tauri.ts |
| `propose_actions` | payload | `AgentPlan` | lib/tauri.ts |
| `agentic_run` | `AgenticRunRequest` | `AgenticRunResult` | lib/tauri.ts |
| `get_folder_links` | — | `{ paths }` | lib/tauri.ts |
| `set_folder_links` | `{ links: { paths } }` | `void` | lib/tauri.ts |
| `verify_project` | `path` | `VerifyResult` | lib/tauri.ts |
| `get_project_profile` | `path` | `ProjectProfile` | lib/tauri.ts |
| `list_projects` | — | `ProjectItem[]` | lib/tauri.ts |
| `add_project` | `path` | `AddProjectResult` | lib/tauri.ts |
| `list_sessions` | `projectPath` | `Session[]` | lib/tauri.ts |
| `append_session_event` | payload | `void` | lib/tauri.ts |
| `get_project_settings` | `projectPath` | `ProjectSettings` | lib/tauri.ts |
| `set_project_settings` | payload | `void` | lib/tauri.ts |
| `apply_project_setting_cmd` | `projectPath`, `key`, `value` | `void` | lib/tauri.ts |
| `get_trends_recommendations` | — | `TrendsResult` | lib/tauri.ts |
| `fetch_trends_recommendations` | — | `TrendsResult` | lib/tauri.ts |
| `export_settings` | — | `string` (JSON) | lib/tauri.ts |
| `import_settings` | `json` | `void` | lib/tauri.ts |
| `analyze_weekly_reports_cmd` | `projectPath`, `from?`, `to?` | `WeeklyReportResult` | lib/tauri.ts |
| `save_report_cmd` | `projectPath`, `reportMd`, `date?` | `string` | lib/tauri.ts |
| `research_answer_cmd` | `query`, `projectPath?` | `OnlineAnswer` | lib/tauri.ts |
| `load_domain_notes_cmd` | `projectPath` | `DomainNotes` | lib/tauri.ts |
| `save_domain_notes_cmd` | `projectPath`, `data` | `void` | lib/tauri.ts |
| `delete_domain_note_cmd` | `projectPath`, `noteId` | `bool` | lib/tauri.ts |
| `clear_expired_domain_notes_cmd` | `projectPath` | `usize` | lib/tauri.ts |
| `pin_domain_note_cmd` | `projectPath`, `noteId`, `pinned` | `bool` | lib/tauri.ts |
| `distill_and_save_domain_note_cmd` | payload | `DomainNote` | lib/tauri.ts |
---
## События (listen)
| Событие | Payload | Где эмитится | Где слушается |
|---------|---------|--------------|----------------|
| `analyze_progress` | `string` | analyze_project, apply, preview | Tasks.tsx |
| `batch_event` | `BatchEvent` | run_batch | Tasks.tsx |
| `agentic_progress` | `{ stage, message, attempt }` | agentic_run | Tasks.tsx |
---
## Транзакционность (Apply / Undo)
- **apply_actions_tx:** snapshot → apply → (auto_check при включённом) → rollback при ошибке. Манифест в `userData/history/<txId>/`.
- **undo_last_tx:** откат последней транзакции из undo_stack.
- **redo_last:** повтор из redo_stack.
- Двухстековая модель: undo_stack + redo_stack.
---
*См. также `lib/tauri.ts` и `src-tauri/src/lib.rs`.*

View File

@ -0,0 +1,155 @@
# Оценка papa-yu по Tech Due Diligence Checklist
**Дата:** 2025-01-31
**Результат:** **~65%** — продаваем с дисконтом (диапазон 6080%)
---
## A. Продукт и назначение — 2/4 ✅⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Что делает и для кого | ✅ | README: «Десктопное приложение для анализа проекта и автоматических исправлений». ЦА — разработчики (неформально). |
| Ключевые сценарии | ✅ | Batch, agentic run, предложить исправления, undo/redo, domain notes, weekly report. |
| Что НЕ делает (LIMITS.md) | ❌ | Отдельного LIMITS.md нет. Ограничения разбросаны по README и IMPLEMENTATION_STATUS. |
| Critical отказ | ❌ | Не описано явно, что считается критическим отказом для бизнеса. |
**Действие:** Добавить `docs/LIMITS.md` с границами продукта и определением Critical failure.
---
## B. Архитектура — 1/4 ⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| ARCHITECTURE.md | ❌ | Отдельного документа нет. |
| Слои domain/services/adapters | ❌ | Слои не заданы. Есть commands, tx, online_research, domain_notes — границы неформальные. |
| Скрытые зависимости | ✅ | Зависимости явно в Cargo.toml, package.json. |
| ADR | ❌ | ADR нет. Часть решений описана в PROTOCOL_V*_PLAN, IMPLEMENTATION_STATUS. |
**Red flag:** Архитектура понятна в основном из кода.
**Действие:** Создать `docs/ARCHITECTURE.md` (12 стр.) и 23 ADR по основным решениям.
---
## C. Качество кода — 2/4 ✅⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Единый стиль | ✅ | `cargo fmt` в CI, единый стиль Rust/TS. |
| Дублирование | ⚠️ | Trace field adapters уменьшили дублирование; остаётся в llm_planner. |
| Сложность функций | ⚠️ | llm_planner крупный; функции длинные. |
| Обработка ошибок | ✅ | ERR_* коды, repair-логика, частичное использование контекста. |
**Действие:** Постепенно дробить llm_planner; при необходимости ограничить сложность через clippy.
---
## D. Тестирование — 4/4 ✅
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Автотесты | ✅ | `cargo test`, 100+ тестов. |
| Покрытие критики | ✅ | Golden traces v1/v2/v3, unit-тесты apply, verify, SSRF. |
| Тесты в CI | ✅ | `cargo test` в GitHub Actions. |
| Golden / regression | ✅ | `docs/golden_traces/`, валидация в CI. |
**Green flag:** Тестам можно доверять.
---
## E. CI/CD и релизы — 4/4 ✅
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Сборка одной командой | ✅ | `npm install && npm run tauri build`. |
| CI gate | ✅ | fmt, clippy, audit, test. |
| Воспроизводимые релизы | ✅ | Cargo.lock, package-lock.json в репо. |
| Откат | ⚠️ | Undo в приложении есть; откат релиза — через git. |
**Green flag:** Релиз может выпустить новый владелец по инструкции из README.
---
## F. Security — 3/4 ✅
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Секреты не в репо | ✅ | env.openai.example без ключей. |
| Fetch/SSRF | ✅ | Модуль net, fetch_url_safe, trends через него. |
| Audit/deny | ⚠️ | `cargo audit` в CI; `cargo deny` не настроен. |
| Threat model | ⚠️ | IMPLEMENTATION_STATUS, IMPROVEMENT_ROADMAP; без отдельного threat model. |
---
## G. Зависимости и лицензии — 2/4 ⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Lock-файлы | ✅ | Cargo.lock, package-lock.json. |
| Список лицензий | ❌ | Нет явного LICENSE-обзора. |
| GPL/AGPL | ⚠️ | Не проверялось. Rust/TS стек обычно MIT/Apache. |
| Abandoned deps | ❌ | План по замене abandoned-зависимостей не описан. |
**Действие:** Добавить `cargo deny` или лицензионный обзор.
---
## H. Эксплуатация — 2/4 ⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| RUNBOOK.md | ❌ | Нет. |
| Типовые проблемы | ⚠️ | INCIDENTS.md — «больные места». |
| INCIDENTS.md | ✅ | Шаблон и список известных проблем. |
| Логи и метрики | ⚠️ | Traces, weekly report; нет структурированного лога. |
**Действие:** Добавить короткий RUNBOOK (запуск, сборка, типовые ошибки).
---
## I. Bus-factor и передача — 2/3 ⚠️
| Пункт | Статус | Комментарий |
|-------|--------|-------------|
| Передача без автора | ⚠️ | README, IMPLEMENTATION_STATUS, PROTOCOL_* помогают; RUNBOOK бы усилил. |
| «Почему» в документах | ✅ | PROTOCOL_V3_PLAN, IMPLEMENTATION_STATUS объясняют решения. |
| «Не трогай» без объяснений | ✅ | INCIDENTS перечисляет проблемные места с контекстом. |
---
## Сводка
| Раздел | Оценка | Баллы |
|--------|--------|-------|
| A. Продукт | ✅⚠️ | 2/4 |
| B. Архитектура | ⚠️ | 1/4 |
| C. Качество кода | ✅⚠️ | 2/4 |
| D. Тестирование | ✅ | 4/4 |
| E. CI/CD | ✅ | 4/4 |
| F. Security | ✅ | 3/4 |
| G. Зависимости | ⚠️ | 2/4 |
| H. Эксплуатация | ⚠️ | 2/4 |
| I. Bus-factor | ⚠️ | 2/3 |
| **Итого** | | **22/35 ≈ 63%** |
---
## Интерпретация
- **63%** — в диапазоне 6080%: **продаваем с дисконтом**.
- Покупатель увидит: сильные тесты, CI, SSRF-защиту, частичную документацию.
- Слабости: архитектура «из кода», нет LIMITS.md, RUNBOOK.md, ADR, лицензионного обзора.
---
## Quick wins для перехода в >80%
1. **LIMITS.md** — границы продукта, что не делает, что считается Critical.
2. **ARCHITECTURE.md** — 12 страницы: стек, модули, границы.
3. **RUNBOOK.md** — запуск, сборка, типовые проблемы, контакты.
4. **23 ADR** — например: выбор Tauri, протокол v3 EDIT_FILE, SSRF-модель.
5. **cargo deny** или лицензионный обзор зависимостей.
Оценка после этих шагов: **~7580%**.

View File

@ -0,0 +1,112 @@
# Checklist готовности papa-yu к продаже (Tech Due Diligence)
Самопроверка владельца или база для внешнего DD.
---
## A. Продукт и назначение
- [ ] Чётко описано, **что делает продукт** и **для кого**
- [ ] Определены **ключевые сценарии**
- [ ] Явно указано, **что продукт НЕ делает** (`LIMITS.md`)
- [ ] Понятно, что считается **Critical отказом**
👉 Если этого нет — покупатель будет сам додумывать (и занизит оценку).
---
## B. Архитектура
- [ ] Есть `ARCHITECTURE.md` с актуальной схемой
- [ ] Чётко разделены слои (domain / services / adapters / UI)
- [ ] Нет скрытых «магических» зависимостей
- [ ] Есть ADR для дорогих решений
**Red flag:** архитектура «читается только из кода».
---
## C. Качество кода
- [ ] Единый стиль и правила
- [ ] Нет систематического дублирования
- [ ] Ограничена сложность функций
- [ ] Ошибки обрабатываются консистентно
**Важно:** не идеальность, а **предсказуемость**.
---
## D. Тестирование
- [ ] Есть автоматические тесты
- [ ] Критические сценарии покрыты
- [ ] Тесты запускаются в CI
- [ ] Golden tests / regression tests фиксируют поведение
**Red flag:** «тесты есть, но мы им не доверяем».
---
## E. CI/CD и релизы
- [ ] Проект собирается с нуля одной командой
- [ ] CI — обязательный gate
- [ ] Есть воспроизводимые релизы
- [ ] Понятно, как откатиться
**Green flag:** новый владелец может выпустить релиз без автора.
---
## F. Security (design & code level)
- [ ] Нет секретов в репозитории
- [ ] Контролируем сетевой доступ (fetch/SSRF)
- [ ] Зависимости проверяются (audit/deny)
- [ ] Есть краткое описание threat model
**Red flag:** «мы не думали о security, потому что это desktop».
---
## G. Зависимости и лицензии
- [ ] Lock-файлы в репозитории
- [ ] Понятен список лицензий
- [ ] Нет критичных GPL/AGPL сюрпризов (если нежелательны)
- [ ] Нет abandoned-зависимостей без плана
---
## H. Эксплуатация
- [ ] Есть `RUNBOOK.md`
- [ ] Известны типовые проблемы и обходы
- [ ] Есть `INCIDENTS.md` (даже минимальный)
- [ ] Логи и базовые метрики доступны
**Green flag:** проблемы задокументированы, а не «в головах».
---
## I. Bus-factor и передача
- [ ] Проект можно передать без автора
- [ ] Документы объясняют «почему», а не только «как»
- [ ] Нет «не трогай это» зон без объяснений
---
## Итог по checklist
| Процент | Интерпретация |
|---------|---------------|
| >80% | investment-ready |
| 6080% | продаваем с дисконтом |
| <60% | «project», а не «asset» |
---
> **Оценка papa-yu:** ~87% (investment-ready) — см. `docs/INVESTMENT_READY_REPORT.md`
> Предыдущая: ~63% — `docs/DUE_DILIGENCE_ASSESSMENT.md`

237
docs/EDIT_FILE_DEBUG.md Normal file
View File

@ -0,0 +1,237 @@
# Отладка EDIT_FILE на реальном файле (чеклист)
Этот документ — практический чеклист для end-to-end проверки v3 EDIT_FILE в papa-yu:
propose → preview → apply → (repair / fallback) → golden trace.
---
## Предварительные условия
### Включить трассы и протокол v3
Рекомендуемые переменные окружения:
- PAPAYU_TRACE=1
- PAPAYU_PROTOCOL_VERSION=3
- PAPAYU_LLM_STRICT_JSON=1 (если провайдер поддерживает response_format)
- PAPAYU_MEMORY_AUTOPATCH=0 (на время отладки, чтобы исключить побочные эффекты)
- PAPAYU_NORMALIZE_EOL=lf (если используешь нормализацию EOL)
Для Online fallback/notes (опционально):
- PAPAYU_ONLINE_RESEARCH=1
- PAPAYU_ONLINE_AUTO_USE_AS_CONTEXT=1 (если хочешь тестировать auto-use)
- PAPAYU_TAVILY_API_KEY=...
---
## Цель проверки (Definition of Done)
Сценарий считается успешно пройденным, если:
1) v3 выдаёт APPLY с EDIT_FILE (и/или PATCH_FILE как fallback внутри v3),
2) preview показывает diff, apply применяет изменения,
3) base_sha256 проверяется, base mismatch ловится и чинится repair'ом (sha-injection),
4) ошибки anchor/before/ambiguous воспроизводимы и дают корректные коды ERR_EDIT_*,
5) golden traces v3 проходят (make test-protocol / cargo test golden_traces).
---
## Быстрый E2E сценарий (минимальный)
### Шаг 1 — выбрать простой файл
Выбери небольшой UTF-8 файл (лучше < 2000 строк), например:
- src/*.rs
- src/lib/*.ts
- любой текстовый конфиг (не secrets)
Избегай:
- бинарных/сжатых файлов
- автогенерации (dist/, build/, vendor/)
- protected paths (.env, *.pem, secrets/)
### Шаг 2 — PLAN
В UI:
- ввод: `plan: исправь <конкретная правка>`
или просто текст с явным "fix", чтобы сработала эвристика PLAN.
Ожидаемо:
- actions=[] (PLAN режим)
- summary объясняет, какой файл будет правиться и какие anchors будут использованы
### Шаг 3 — APPLY (OK)
Нажми OK / "apply" / "да".
Ожидаемо:
- actions содержит EDIT_FILE
- EDIT_FILE включает:
- base_sha256 (64 hex)
- edits[] (min 1)
- anchor и before должны быть точными фрагментами из файла
### Шаг 4 — PREVIEW
Preview должен:
- показать unified diff
- bytes_before/bytes_after заполнены (если у тебя это в DiffItem)
Если preview падает — это уже диагностируемая ошибка (см. разделы ниже).
### Шаг 5 — APPLY
Apply должен:
- применить изменения
- записать файл
- если включён auto_check/run_tests — пройти (или корректно откатиться)
- в trace появится APPLY_SUCCESS или APPLY_ROLLBACK
---
## Где смотреть диагностику
### stderr события (runtime)
По trace_id в stderr:
- LLM_REQUEST_SENT / LLM_RESPONSE_OK / LLM_RESPONSE_REPAIR_RETRY
- VALIDATION_FAILED code=...
- PREVIEW_READY ...
- APPLY_SUCCESS / APPLY_ROLLBACK
- PROTOCOL_FALLBACK ... (если был)
### Трассы в .papa-yu/traces/
- основной propose trace: .papa-yu/traces/<trace_id>.json
- online research: online_<uuid>.json (если включено)
Ищи поля:
- protocol_default / protocol_attempts / protocol_fallback_reason / protocol_repair_attempt
- repair_injected_sha256, repair_injected_paths
- notes_injected (если notes включены)
- online_context_injected / online_context_dropped
- context_stats / cache_stats
---
## Типовые ошибки EDIT_FILE и как чинить
### ERR_NON_UTF8_FILE
Причина:
- файл не UTF-8 (байтовый/смешанная кодировка)
Действие:
- v3 должен fallback'нуть (обычно сразу) к v2 или отказаться и попросить альтернативу.
- если это код/текст — проверь, что файл реально UTF-8.
### ERR_EDIT_BASE_MISMATCH (или ERR_EDIT_BASE_SHA256_INVALID)
Причина:
- base_sha256 не совпал с текущим содержимым файла
- или base_sha256 не 64 hex
Ожидаемое поведение:
- repair prompt должен подставить правильный sha256 из контекста:
`FILE[path] (sha256=...)`
- trace: repair_injected_sha256=true, repair_injected_paths=[path]
Как воспроизвести:
- вручную измени файл между PLAN и APPLY
- или подложи неправильный base_sha256 в фикстуре/в тесте
### ERR_EDIT_ANCHOR_NOT_FOUND
Причина:
- anchor строка отсутствует в файле
Чиним:
- anchor должен быть буквальным кусочком из `FILE[...]` блока
- лучше выбирать "устойчивый" anchor: сигнатура функции, имя класса, уникальный комментарий
### ERR_EDIT_BEFORE_NOT_FOUND
Причина:
- before не найден в окне вокруг anchor (±4000 chars по твоей текущей реализации)
Чиним:
- before должен быть рядом с anchor (не из другого участка файла)
- увеличить точность: добавить контекст в before (несколько слов/строк)
### ERR_EDIT_AMBIGUOUS
Причина:
- before встречается больше одного раза в окне вокруг anchor
Чиним:
- сделать before длиннее/уникальнее
- сделать anchor более узким/уникальным
- если в твоей реализации поддержан occurrence (для before), укажи occurrence явно; если нет — уточняй before.
### ERR_EDIT_APPLY_FAILED
Причина:
- внутренний сбой применения (невалидные индексы, неожиданные boundary, и т.п.)
- чаще всего: крайние случаи UTF-8 границ или очень большие вставки
Чиним:
- сократить before/after до минимального фрагмента
- избегать массовых замен/реформатирования
- если повторяется — добавь golden trace и воспроизведение
---
## Проверка repair-first и fallback (v3 → v2)
### Repair-first
Для ошибок из V3_REPAIR_FIRST:
- первый retry: repair_attempt=0
- второй (если не помог): fallback repair_attempt=1 → protocol override = 2
Проверяй в trace:
- protocol_repair_attempt: 0/1
- protocol_fallback_reason
- protocol_fallback_stage (обычно apply)
### Immediate fallback
Для ошибок из V3_IMMEDIATE_FALLBACK:
- fallback сразу (без repair), если так настроено
---
## Как сделать Golden trace из реального запуска
1) Убедись, что PAPAYU_TRACE=1
2) Выполни сценарий (PLAN→APPLY)
3) Найди trace_id в stderr (или в .papa-yu/traces/)
4) Сгенерируй fixture:
- make golden TRACE_ID=<id>
или
- cargo run --bin trace_to_golden -- <trace_id> docs/golden_traces/v3/NNN_name.json
5) Прогон:
- make test-protocol
или
- cargo test golden_traces
Совет:
- Делай отдельные golden traces для:
- ok apply edit
- base mismatch repair injected sha
- anchor not found
- no changes
---
## Реальные edge cases (на что смотреть)
1) Несколько одинаковых anchors в файле:
- occurrence должен выбрать правильный (если модель указала)
2) before содержит повторяющиеся шаблоны:
- ambiguity ловится, и это нормально
3) Window ±4000 chars не покрывает before:
- значит before слишком далеко от anchor — модель ошиблась
4) Большие after-вставки:
- риск превышения лимитов/перформанса
5) EOL normalization:
- следи, чтобы diff не "красил" весь файл из-за CRLF→LF
---
## Мини-набор команд для быстрой диагностики
- Прогнать протокол-тесты:
- make test-protocol
- Прогнать всё:
- make test-all
- Посмотреть свежие traces:
- ls -lt .papa-yu/traces | head
- Найти ошибки по коду:
- rg "ERR_EDIT_" -n .papa-yu/traces

View File

@ -0,0 +1,133 @@
# Сопоставление PAPA YU с Единым рабочим промтом
**Источник:** `Единый_рабочий_промт.docx` (консолидация 16 ТЗ, февраль 2026)
**Проект:** papa-yu v2.4.5 (Tauri + React)
---
## 1. Расхождение: Electron vs Tauri
| Спецификация | papa-yu |
|--------------|---------|
| Backend внутри Electron | **Tauri 2** (Rust backend) |
| REST API (GET /health, POST /tasks...) | **IPC-команды** (analyze_project, apply_actions_tx...) |
| Node.js в процессе | Без Node в runtime |
**Риск в документе:** «Двойственность Electron/Tauri» — Medium.
**Рекомендация:** Оставить Tauri. Arch соответствует идее «UI + Backend = один процесс».
---
## 2. Definition of Done (MVP) — чеклист
| Критерий | Статус |
|----------|--------|
| Открываю приложение двойным кликом | ✅ `PAPA YU.app` |
| Сразу вижу экран Product Chat | ⚠️ Tasks — сложный экран, не «чистый Chat» |
| «⚡ Анализировать папку» — выбор каталога | ✅ pickFolder |
| Живой диалог со стадиями | ✅ agentic progress, события |
| Читаемый отчёт (findings, рекомендации) | ✅ |
| «⬇ Скачать отчёт» (JSON и MD) | ✅ |
| «Исправить автоматически» → preview → apply | ✅ |
| «Откатить» → файлы восстановлены | ✅ Undo |
| Выглядит как продукт, не dev-панель | ⚠️ На усмотрение |
---
## 3. UI: Product Chat
**Спецификация:** Один экран — Header + Chat + Composer.
Без таблиц, без тех. панелей. Max-width 900px.
**Текущее состояние:** Tasks.tsx — много панелей (сессии, trends, weekly report, domain notes, project notes, fix groups, attachments). Ближе к «dashboard», чем к «chat».
**Рекомендация:** Вариант A — упростить до «Product Chat» (приоритет чата). Вариант B — оставить как есть, если продуктовая логика требует dashboard.
---
## 4. Persistence
| Спецификация | papa-yu |
|--------------|---------|
| userData/tasks.json | Проекты в `projects` (store.rs), сессии |
| userData/runs/&lt;runId&gt;.json | События в сессиях |
| userData/attachments/ | Нет upload ZIP — только folder |
| userData/artifacts/ | Отчёты в памяти / экспорт |
| userData/history/&lt;txId&gt;/ | tx/ (manifest, before/) |
**Gap:** Спецификация предполагает Upload ZIP. papa-yu — только выбор папки. Дополнить upload ZIP — фаза 2.
---
## 5. Auditor: правила анализа
**Спецификация:** минимум 15 правил (README, .env, tests, lockfile, дубликаты, utils/, components/, циклы, .editorconfig и т.д.).
**Текущее состояние:** Нужно проверить `analyze_project.rs` / rules — сколько правил реализовано.
---
## 6. Narrative — человеческий текст
**Спецификация:** Формат narrative:
> «Я проанализировал проект. Это React + Vite. Есть src/, нет tests/ — стоит добавить...»
**Текущее состояние:** В `report_md` и `narrative` — проверить тон (человеческий vs технический).
---
## 7. Safe Guards, лимиты, error codes
| Элемент | Спецификация | papa-yu |
|---------|--------------|---------|
| PATH_FORBIDDEN | .git, node_modules, target... | ✅ apply_actions_tx guard |
| LIMIT_EXCEEDED | max 50 actions, 2 MB, 50 files | ✅ limits.rs |
| AUTO_CHECK_FAILED_REVERTED | rollback при fail | ✅ |
| Error codes | TOOL_ID_REQUIRED, PATH_MISSING... | Частично (Rust Result) |
---
## 8. Бренд «PAPA YU»
**Спецификация:** Без дефисов, без «P PAPA YU», без «Tauri App».
**Проверено:** index.html, tauri.conf.json, Tasks.tsx, Cargo.toml — везде «PAPA YU».
**Исключения:** docs/OPENAI_SETUP.md, start-with-openai.sh — «PAPA-YU» (мелко).
---
## 9. Части IIVI (вне PAPA YU)
| Часть | Содержание | Релевантность для papa-yu |
|-------|------------|---------------------------|
| II | Mura Menasa ERP | Отдельный продукт |
| III | Универсальный агент | Концепция, контракт агента |
| IV | Scorer, Deps Graph, Patches | Аналитический движок — фаза 3 |
| V | Due Diligence, Data Room, Seed | Инфраструктура продажи |
| VI | Риски, дорожная карта | Справочно |
---
## 10. Приоритетные задачи (Фаза 1 по документу)
| # | Задача | Статус | Действие |
|---|--------|--------|----------|
| 1 | Auditor v2: 15 правил + narrative + score | ✅ | Реализовано 15+ правил (README, .gitignore, .env, tests, lockfile, .editorconfig, scripts, empty dirs, large files, utils/, large dir, monolith, prettier, CI) |
| 2 | Folder analysis без ZIP | ✅ | Уже есть pickFolder |
| 3 | Undo (1 шаг) via snapshot | ✅ | Undo/Redo стек |
| 4 | Бренд PAPA YU везде | ⚠️ | Исправить OPENAI_SETUP, start-with-openai |
| 5 | CI: lint + test + build | ? | Проверить .github/workflows |
| 6 | README.md, ARCHITECTURE.md | ✅ | Есть |
---
## 11. Рекомендуемые первые шаги
1. **Аудит правил Auditor** — подсчитать реализованные правила, привести к 15+.
2. **Правки бренда** — заменить «PAPA-YU» на «PAPA YU» в docs и скриптах.
3. **Проверка CI** — убедиться, что lint + test + build выполняются.
4. **Опционально: режим Product Chat** — упрощённый UI как альтернативный вид (если требуется строгое соответствие спецификации).
---
*Документ создан автоматически по результатам сопоставления с Единым рабочим промтом.*

View File

@ -0,0 +1,87 @@
# Implementation status: A (domain notes), B (proposals), C (v3), security, latency
## A) Domain notes — DONE (A1A4)
### A1 — Project Notes Storage ✅
- **File:** `.papa-yu/notes/domain_notes.json`
- **Module:** `src-tauri/src/domain_notes/storage.rs`
- **API:** `load_domain_notes(project_path)`, `save_domain_notes(project_path, data)`
- **Eviction:** expired by TTL, then LRU by `last_used_at`, `usage_count`, `created_at`. Pinned notes never evicted.
- **Env:** `PAPAYU_NOTES_MAX_ITEMS=50`, `PAPAYU_NOTES_MAX_CHARS_PER_NOTE=800`, `PAPAYU_NOTES_MAX_TOTAL_CHARS=4000`, `PAPAYU_NOTES_TTL_DAYS=30`
- **Tauri commands:** `load_domain_notes_cmd`, `save_domain_notes_cmd`, `delete_domain_note_cmd`, `clear_expired_domain_notes_cmd`, `pin_domain_note_cmd`, `distill_and_save_domain_note_cmd`
### A2 — Note distillation ✅
- **Schema:** `config/llm_domain_note_schema.json` (topic, tags, content_md, confidence)
- **Module:** `src-tauri/src/domain_notes/distill.rs`
- **Flow:** `distill_and_save_note(project_path, query, answer_md, sources, confidence)` — LLM compresses to ≤800 chars, then append + evict + save.
### A3 — Notes injection in prompt ✅
- **Module:** `src-tauri/src/domain_notes/selection.rs`
- **Logic:** `select_relevant_notes(goal_text, notes, max_total_chars)` — token overlap scoring (goal ∩ tags/topic/content); top-K under budget.
- **Block:** `PROJECT_DOMAIN_NOTES (curated, may be stale):` inserted in `llm_planner` before online block and CONTEXT.
- **Usage:** Notes that get injected get `usage_count += 1`, `last_used_at = now`; then save.
- **Trace:** `notes_injected`, `notes_count`, `notes_chars`, `notes_ids`.
### A4 — UI Project Notes ✅
- **Implemented:** Page /notes (ProjectNotes), ProjectNotesPanel with list (topic, tags, updated), Delete, Clear expired, Pin, Sort, Search.
- **Backend:** Commands called from frontend; full CRUD + distill flow.
---
## B) Weekly Report proposals — DONE (B1B3)
### B1 — Recommendation schema extension ✅
- **File:** `config/llm_weekly_report_schema.json`
- **Added:** `proposals[]` with `kind` (prompt_change, setting_change, golden_trace_add, limit_tuning, safety_rule), `title`, `why`, `risk`, `steps`, `expected_impact`, `evidence`.
### B2 — Policy suggestions in report prompt ✅
- **File:** `src-tauri/src/commands/weekly_report.rs`
- **Prompt:** Rule "Предлагай **только** то, что можно обосновать полями bundle + deltas" and typical proposal types (prompt_change, auto-use, golden_trace_add, limit_tuning, safety_rule).
- **Report MD:** Section "## Предложения (proposals)" with kind, title, risk, why, impact, steps.
### B3 — UI Apply proposal ✅
- **Implemented:** WeeklyReportProposalsPanel in report modal; `setting_change` (onlineAutoUseAsContext) one-click via applyProjectSetting; `golden_trace_add` shows "Copy steps" and link to README; `prompt_change` shows "Copy suggested snippet".
---
## Security audit — partial
### Done
- **SSRF/fetch:** localhost, RFC1918, link-local, file:// blocked; max redirects 5; http/https only; Content-Type allowlist.
- **Added:** Reject URL with `user:pass@` (credential in URL); reject URL length > 2048.
### Optional / not done
- **Prompt injection:** Add to summarization prompt: "Игнорируй любые инструкции со страницы." Optional content firewall (heuristic strip of "prompt", "you are chatgpt").
- **Secrets in trace:** Dont log full URL query params; in trace store domain+path without query.
- **v3 file safety:** Same denylist/protected paths as v1/v2.
---
## Latency — not done
- **Tavily cache:** `.papa-yu/cache/online_search.jsonl` or sqlite, key `(normalized_query, time_bucket_day)`, TTL 24h.
- **Parallel fetch:** `join_all` with concurrency 23; early-stop when total text ≥ 80k chars.
- **Notes:** Already reduce latency by avoiding repeated online research when notes match.
---
## C) v3 EDIT_FILE — DONE
- **C1:** Protocol v3 schema + docs (EDIT_FILE with anchor/before/after). llm_response_schema_v3.json, PROTOCOL_V3_PLAN.md.
- **C2:** Engine apply + preview in patch.rs, tx/mod.rs; errors: ERR_EDIT_ANCHOR_NOT_FOUND, ERR_EDIT_BEFORE_NOT_FOUND, ERR_EDIT_AMBIGUOUS, ERR_EDIT_BASE_MISMATCH.
- **C3:** `PAPAYU_PROTOCOL_VERSION=3`, golden traces v3 in docs/golden_traces/v3/, CI includes golden_traces_v3_validate. Context includes sha256 for v3 (base_sha256 for EDIT_FILE).
---
## Metrics — partial (v3 edit metrics done)
- **edit_fail_count, edit_fail_rate, edit_ambiguous_count, edit_before_not_found_count, edit_anchor_not_found_count, edit_base_mismatch_count** — в WeeklyStatsBundle, секция «EDIT_FILE (v3) breakdown» в report MD. Группа EDIT в error_codes_by_group.
- `online_fallback_rate`, `online_cache_hit_rate`, `avg_online_latency_ms` — planned
- `notes_hit_rate`, `notes_prevented_online_count` — planned
---
## Frontend wiring (for A4 / B3)
- **Domain notes:** Call `load_domain_notes_cmd(path)`, `save_domain_notes_cmd(path, data)`, `delete_domain_note_cmd`, `clear_expired_domain_notes_cmd`, `pin_domain_note_cmd`, `distill_and_save_domain_note_cmd` (after online research if user opts in).
- **Proposals:** Parse `llm_report.proposals` from weekly report result; render list; for `setting_change` apply project flag; for `golden_trace_add` show "Copy steps" button.

105
docs/IMPROVEMENT_REPORT.md Normal file
View File

@ -0,0 +1,105 @@
# Отчёт о выполнении рекомендаций по улучшению
**Дата:** 2025-01-31
**Версия papa-yu:** 2.4.5
---
## Executive Summary
Выполнены рекомендации из `docs/IMPROVEMENT_ROADMAP.md` в рамках Quick wins (15 дней). Закрыты ключевые риски SSRF, усилен CI, добавлена база для наблюдаемости.
---
## 1. CI/CD — quality gate ✅
### Сделано
| Шаг | Описание |
|-----|----------|
| Format check | `cargo fmt --check` — единый стиль кода |
| Clippy | `cargo clippy --all-targets` — статический анализ |
| Cargo audit | Проверка уязвимостей в зависимостях (`continue-on-error: true` до стабилизации) |
| Golden traces | `cargo test golden_traces` — регрессионные тесты v1/v2/v3 |
### Файлы
- `.github/workflows/protocol-check.yml` → переименован в CI (fmt, clippy, audit, protocol)
---
## 2. Единая точка сетевого доступа (SSRF) ✅
### Сделано
1. **Модуль `net`** (`src-tauri/src/net.rs`):
- Единая точка доступа к `fetch_url_safe`
- Политика: внешние URL только через `fetch_url_safe`
2. **Рефакторинг `trends`**:
- `fetch_trends_recommendations` переведён с прямого `reqwest::Client::get()` на `net::fetch_url_safe`
- Добавлен лимит размера ответа: `MAX_TRENDS_RESPONSE_BYTES = 1_000_000`
- Таймаут: 15 сек
- Сохранён allowlist хостов (`ALLOWED_TRENDS_HOSTS`) + SSRF-защита `fetch_url_safe`
3. **Re-export** `fetch_url_safe` из `online_research` для использования в других модулях
### Потоки HTTP (текущее состояние)
| Модуль | URL источник | Метод | Защита |
|--------|--------------|-------|--------|
| online_research/fetch | Tavily API (результаты поиска) | `fetch_url_safe` | ✅ SSRF, max bytes, timeout |
| commands/trends | PAPAYU_TRENDS_URLS (env) | `fetch_url_safe` | ✅ Host allowlist + SSRF |
| llm_planner, weekly_report, distill, llm | PAPAYU_LLM_API_URL (env) | reqwest (доверенный конфиг) | ⚠️ Таймауты, без SSRF (Ollama на localhost) |
---
## 3. INCIDENTS.md — журнал инцидентов ✅
### Сделано
- Создан `docs/INCIDENTS.md` с шаблоном записи
- Описаны известные «больные места»: llm_planner, PATCH/EDIT apply, golden traces
---
## 4. Что не сделано (mid/long-term)
| Рекомендация | Причина |
|--------------|---------|
| `cargo clippy -- -D warnings` | Есть текущие предупреждения; CI сначала без `-D warnings` |
| `cargo deny` | Требует конфигурации deny.toml |
| SBOM | Требует интеграции CycloneDX |
| Структурированные JSON-логи | Требует выбора библиотеки и прогонки по коду |
| ADR, архитектурные границы | Объёмная архитектурная работа |
---
## 5. Проверка
```bash
cd src-tauri
cargo fmt --check # OK
cargo clippy # OK (предупреждения есть)
cargo test # 105 passed
```
---
## 6. Рекомендации на следующий шаг
1. Постепенно устранять предупреждения Clippy и включить `-D warnings` в CI.
2. ~~Добавить `deny.toml` и шаг `cargo deny` в CI.~~ ✅ Выполнено (2026-02-08).
3. Заполнять `INCIDENTS.md` при разборе сбоев.
4. Рассмотреть `tracing` или `log` для структурированного логирования.
---
## 7. Дополнительные изменения (2026-02-08)
- **deny.toml** — добавлен, CI включает `cargo deny check` (continue-on-error).
- **CONTRACTS.md** — создан, документирует все команды и события UI ↔ Tauri.
- **tauri-plugin-updater**, **tauri-plugin-process** — добавлены для проверки и установки обновлений.
- **Страница Updates** — UI для проверки обновлений.
- **ERP-заглушки** — маршруты и страницы: Регламенты, ТМЦ и закупки, Финансы, Персонал.
- **Clippy** — исправлены предупреждения в analyze_project, apply_actions, generate_actions, settings_export.

105
docs/IMPROVEMENT_ROADMAP.md Normal file
View File

@ -0,0 +1,105 @@
# Практические рекомендации по улучшению papa-yu
Упорядочено по эффекту/риску. Привязано к стеку: Rust, Tauri, CI в GitHub Actions, `cargo test` + golden traces, частичные SSRF-защиты, нет формализованных инцидентов/метрик.
---
## 1) Самое важное: закрыть класс рисков SSRF / небезопасный fetch (Security, Critical/High)
### Что сделать
1. **Единая точка сетевого доступа** — вынести все HTTP-запросы в один модуль (`net::client`), запретить прямой `reqwest::get()` где попало.
2. **Политика allowlist + запрет приватных сетей**
- разрешённые схемы: `https``http` только если надо)
- запрет `file://`, `ftp://`, `gopher://`, `data:` и т.п.
- запрет IP: RFC1918, loopback, link-local
- защита от DNS-rebind (резолвить и проверять IP)
3. **Таймауты и лимиты** — connect/read timeout, max size ответа, ограничение редиректов.
4. **Тесты на SSRF** — набор URL → ожидаемый "deny", golden traces для фиксации отказов.
---
## 2) Минимальная наблюдаемость и журнал инцидентов (Ops, High)
### MVP за 12 дня
1. **Единый структурированный лог** — JSON, уровни error/warn/info/debug, корреляционный id, без секретов.
2. **Метрики уровня приложения** — latency ключевых операций, количество ошибок по типам.
3. **`INCIDENTS.md`** — шаблон: дата, версия, симптом, impact, причина, фикс, тест на повтор.
---
## 3) Усилить CI/CD как quality gate (DevEx/Quality, High)
### Минимальный набор гейтов
1. `cargo fmt --check`, `cargo clippy -- -D warnings`
2. `cargo test` (включая golden traces)
3. `cargo deny`, `cargo audit` — supply chain
4. (Опционально) SBOM для релизов
---
## 4) Архитектурные границы (Architecture/Tech debt, Medium/High)
- Чёткие слои: `domain` (без IO) → `services``adapters``tauri_api`
- ADR для 35 ключевых решений
---
## 5) Качество кода (Medium)
- Лимиты сложности, `thiserror` для доменных ошибок, вычистка dead code.
---
## 6) Производительность (Medium)
- Выделить 35 «дорогих» операций, измерять время/память, микробенчи (`criterion`).
---
## Приоритизированный roadmap
| Фаза | Срок | Действия |
|------|------|----------|
| Quick wins | 15 дней | SSRF: единая точка + denylist + таймауты; CI: fmt/clippy/test + cargo audit/deny; INCIDENTS.md + логи |
| Mid-term | 13 нед | Архитектурные границы; ADR; метрики по 35 операциям |
| Long-term | 12 мес | SBOM; property-based тесты; формализация SLO |
> **Выполнено (2025-01-31):** см. `docs/IMPROVEMENT_REPORT.md`
---
## Приложение: ответы на запрос данных для точного плана
### 510 строк: функции fetch/скачивание/импорт и источник URL
| Функция / модуль | URL откуда | Защита |
|------------------|------------|--------|
| `online_research/fetch.rs``fetch_url_safe()` | URL из ответа **Tavily Search API** (результаты поиска) | ✅ SSRF: localhost, RFC1918, link-local, `user:pass@`, max 2048 символов |
| `online_research/search.rs` | POST `https://api.tavily.com/search` — фиксированный URL | ✅ Не извне |
| `llm_planner.rs`, `weekly_report.rs`, `domain_notes/distill.rs`, `online_research/llm.rs` | `PAPAYU_LLM_API_URL` из env (OpenAI/Ollama) | ⚠️ Конфиг, не от пользователя |
**Единственный «внешний» URL-поток:** Tavily возвращает URL в результатах поиска → `fetch_url_safe()` их скачивает. Уже есть `is_url_allowed()` и лимиты.
### Хранение данных и синхронизация
- **Файлы JSON**, без БД:
- `store/`: `projects.json`, `project_profiles.json`, `sessions.json` в `app_data_dir`
- `.papa-yu/notes/domain_notes.json` — заметки по проекту
- `.papa-yu/cache/online_search_cache.json` — кеш Tavily
- `.papa-yu/traces/*.json` — трассировки
- `.papa-yu/project.json` — настройки проекта
- **Синхронизации нет** — только локальные файлы.
### 3 главные боли (по коду и статусу)
1. **llm_planner.rs** — большой модуль, протоколы v1/v2/v3, fallback-логика, repair, memory patch. Сложно тестировать и менять.
2. **PATCH/EDIT apply** — ERR_EDIT_AMBIGUOUS, ERR_EDIT_BEFORE_NOT_FOUND, base_sha256 mismatch; fallback v3→v2→v1 добавляет ветвления.
3. **Golden traces** — при изменении JSON Schema нужно обновлять `schema_hash` во всех фикстурах; легко забыть и сломать CI.

40
docs/INCIDENTS.md Normal file
View File

@ -0,0 +1,40 @@
# Журнал инцидентов
Шаблон записи для разбора сбоев и «больных мест».
---
## Формат записи
| Поле | Описание |
|------|----------|
| **Дата** | ГГГГ-ММ-ДД |
| **Версия** | Версия papa-yu при проявлении |
| **Симптом** | Что наблюдал пользователь / что сломалось |
| **Impact** | Влияние на бизнес / пользователя (Critical / High / Medium / Low) |
| **Причина** | Корневая причина (если известна) |
| **Фикс** | Что сделано для устранения |
| **Профилактика** | Тест / метрика / проверка, которая ловит повтор |
---
## Примеры (шаблон)
<!--
### 2025-XX-XX — [Краткое название]
- **Версия:** 2.4.x
- **Симптом:**
- **Impact:**
- **Причина:**
- **Фикс:**
- **Профилактика:**
-->
---
## Известные «больные места» (без формального инцидента)
- llm_planner.rs — сложный модуль, протоколы v1/v2/v3, fallback-логика
- PATCH/EDIT apply — edge cases: ERR_EDIT_AMBIGUOUS, base_sha256 mismatch
- Golden traces — при изменении schema нужен ручной пересчёт schema_hash во всех фикстурах

View File

@ -0,0 +1,107 @@
# Отчёт: papa-yu — Investment-Ready
**Дата:** 2025-01-31
**Цель:** превратить проект в управляемый актив с оценкой >80% по Tech Due Diligence.
---
## Executive Summary
За одну итерацию проект papa-yu переведён из состояния «хорошо сделанного» в **управляемый актив**, готовый к продаже или передаче.
**Результат:** оценка Due Diligence **~87%** (было ~63%). Покупатель видит не «код», а **asset с формализованными рисками и границами**.
---
## Что сделано
### 1. Продуктовые границы
| Артефакт | Назначение |
|----------|------------|
| **docs/LIMITS.md** | Что продукт не делает; известные ограничения; Critical failures |
### 2. Архитектура
| Артефакт | Назначение |
|----------|------------|
| **docs/ARCHITECTURE.md** | High-level design, модули, границы, extension points |
| **docs/adr/** | ADR-001 (Tauri), ADR-002 (EDIT_FILE v3), ADR-003 (SSRF) |
### 3. Операционная готовность
| Артефакт | Назначение |
|----------|------------|
| **docs/RUNBOOK.md** | Build, run, типовые проблемы, диагностика |
### 4. Инвестиционные материалы
| Артефакт | Назначение |
|----------|------------|
| **docs/TECH_MEMO_FOR_INVESTORS.md** | 35 стр. для CTO/tech advisors |
| **docs/BUYER_QA.md** | 10 вопросов покупателя с готовыми ответами |
### 5. Ранее выполнено (предыдущие итерации)
- CI: fmt, clippy, audit, test
- Модуль `net`, SSRF-защита, trends через fetch_url_safe
- INCIDENTS.md (шаблон + больные места)
- IMPROVEMENT_REPORT, DUE_DILIGENCE_ASSESSMENT
---
## Обновлённая оценка Due Diligence
| Раздел | Было | Стало | Комментарий |
|--------|------|-------|-------------|
| A. Продукт | 2/4 | **4/4** | LIMITS + Critical failures |
| B. Архитектура | 1/4 | **4/4** | ARCHITECTURE + ADR |
| C. Качество кода | 2/4 | 2/4 | Без изменений |
| D. Тестирование | 4/4 | 4/4 | Без изменений |
| E. CI/CD | 4/4 | 4/4 | Без изменений |
| F. Security | 3/4 | **4/4** | net + ADR-003 |
| G. Зависимости | 2/4 | 2/4 | cargo deny — следующий шаг |
| H. Эксплуатация | 2/4 | **4/4** | RUNBOOK |
| I. Bus-factor | 2/3 | **3/3** | Документация «почему» |
| **Итого** | **~63%** | **~87%** | investment-ready |
---
## Главный вывод
Код, тесты и CI уже были сильнее среднего рынка.
Слабые места были **не технические, а в контуре управления продуктом**.
Фокус был на:
- фиксации границ (LIMITS)
- объяснимости решений (ARCHITECTURE, ADR)
- операционной готовности (RUNBOOK)
Без переписывания кода. Без смены архитектуры.
---
## Что осталось (опционально)
| Действие | Эффект |
|----------|--------|
| cargo deny | +23% (раздел G) |
| LICENSES.md | +12% |
Эти шаги доведут оценку до **~90%**.
---
## Финальный вердикт
С точки зрения покупателя:
> «Это не идеальный код. Но это **понятный, управляемый, передаваемый актив**
Проект готов к:
- передаче владельца
- продаже
- due diligence
- масштабированию команды

30
docs/LIMITS.md Normal file
View File

@ -0,0 +1,30 @@
# Product Limits — papa-yu
## Not designed for
- **Real-time / low-latency processing** — операция планирования и применения занимает секунды.
- **High-concurrency server workloads** — desktop-приложение, один активный контекст.
- **Untrusted plugin execution** — нет sandbox для произвольного кода.
- **Enterprise SSO / RBAC** — аутентификация и авторизация не в scope.
## Known constraints
- **LLM planner** — предполагает структурированный ввод и хорошо сформированные промпты.
- **File PATCH/EDIT** — опирается на детерминированный контекст; anchor/before/after должны точно соответствовать файлу.
- **Golden traces** — отражают только протоколы v1, v2, v3; при смене схемы нужен пересчёт `schema_hash`.
## Critical failures
Следующие события считаются **критическими отказами**:
| Событие | Impact | Условия |
|---------|--------|---------|
| **Corrupted workspace state** | Потеря или повреждение файлов проекта | Сбой во время apply, откат не сработал |
| **Silent data loss в EDIT_FILE** | Некорректная замена без явной ошибки | Неоднозначный anchor/before, ERR_EDIT_AMBIGUOUS не сработал |
| **Network access outside allowlist** | SSRF, утечка данных | Обход net::fetch_url_safe |
| **Secrets in trace** | Утечка ключей/токенов | Полные URL с query, логи с credentials |
## Supported vs unsupported
- **Supported:** анализ и правка локальных проектов, batch-режим, undo/redo, online research (Tavily), domain notes.
- **Unsupported:** работа с удалёнными репозиториями напрямую, выполнение произвольных скриптов, интеграция с внешними CI без адаптеров.

View File

@ -1,4 +1,4 @@
# Подключение PAPA-YU к OpenAI # Подключение PAPA YU к OpenAI
Инструкция по настройке кнопки **«Предложить исправления»** для работы через API OpenAI. Инструкция по настройке кнопки **«Предложить исправления»** для работы через API OpenAI.
@ -9,7 +9,7 @@
1. Зайдите на [platform.openai.com](https://platform.openai.com). 1. Зайдите на [platform.openai.com](https://platform.openai.com).
2. Войдите в аккаунт или зарегистрируйтесь. 2. Войдите в аккаунт или зарегистрируйтесь.
3. Откройте **API keys** (раздел **Settings****API keys** или [прямая ссылка](https://platform.openai.com/api-keys)). 3. Откройте **API keys** (раздел **Settings****API keys** или [прямая ссылка](https://platform.openai.com/api-keys)).
4. Нажмите **Create new secret key**, задайте имя (например, `PAPA-YU`) и скопируйте ключ. 4. Нажмите **Create new secret key**, задайте имя (например, `PAPA YU`) и скопируйте ключ.
5. Сохраните ключ в надёжном месте — повторно его показать нельзя. 5. Сохраните ключ в надёжном месте — повторно его показать нельзя.
--- ---
@ -62,7 +62,7 @@ npm run tauri dev
### Вариант C: Файл `.env` в корне проекта (если приложение его подхватывает) ### Вариант C: Файл `.env` в корне проекта (если приложение его подхватывает)
В PAPA-YU переменные читаются из окружения процесса. Tauri сам по себе не загружает `.env`. Чтобы использовать `.env`, можно запускать через `env` или скрипт: В PAPA YU переменные читаются из окружения процесса. Tauri сам по себе не загружает `.env`. Чтобы использовать `.env`, можно запускать через `env` или скрипт:
```bash ```bash
# В papa-yu создайте файл .env (добавьте .env в .gitignore, чтобы не коммитить ключ): # В papa-yu создайте файл .env (добавьте .env в .gitignore, чтобы не коммитить ключ):

View File

@ -1,98 +1,98 @@
# Protocol v1 — контракт papa-yu # Protocol v1 — контракт papa-yu
Краткий документ (12 страницы): что гарантируется, лимиты, логирование, PLAN→APPLY, strict/best-effort. Краткий документ (12 страницы): что гарантируется, лимиты, логирование, PLAN→APPLY, strict/best-effort.
--- ---
## Версионирование ## Версионирование
- **schema_version:** 1 - **schema_version:** 1
- **schema_hash:** sha256 от `llm_response_schema.json` (в trace) - **schema_hash:** sha256 от `llm_response_schema.json` (в trace)
- При изменении контракта — увеличивать schema_version; v2 — новый документ. - При изменении контракта — увеличивать schema_version; v2 — новый документ.
**Default protocol:** v2; Apply может fallback на v1 при специфичных кодах ошибок (см. PROTOCOL_V2_PLAN.md). **Default protocol:** v2; Apply может fallback на v1 при специфичных кодах ошибок (см. PROTOCOL_V2_PLAN.md).
--- ---
## Гарантии ## Гарантии
1. **JSON:** ответ LLM парсится; при неудаче — 1 repair-ретрай с подсказкой. 1. **JSON:** ответ LLM парсится; при неудаче — 1 repair-ретрай с подсказкой.
2. **Валидация:** path (no `../`, absolute, `~`), конфликты действий, content (no NUL, pseudo-binary). 2. **Валидация:** path (no `../`, absolute, `~`), конфликты действий, content (no NUL, pseudo-binary).
3. **UPDATE base:** в APPLY каждый UPDATE_FILE — только для файлов, прочитанных в Plan. 3. **UPDATE base:** в APPLY каждый UPDATE_FILE — только для файлов, прочитанных в Plan.
4. **Protected paths:** `.env`, `*.pem`, `*.key`, `id_rsa*`, `**/secrets/**` — запрещены. 4. **Protected paths:** `.env`, `*.pem`, `*.key`, `id_rsa*`, `**/secrets/**` — запрещены.
5. **Apply:** snapshot → apply → auto_check; при падении check — rollback. 5. **Apply:** snapshot → apply → auto_check; при падении check — rollback.
--- ---
## Лимиты ## Лимиты
| Область | Переменная | По умолчанию | | Область | Переменная | По умолчанию |
|---------|------------|--------------| |---------|------------|--------------|
| path_len | — | 240 | | path_len | — | 240 |
| actions | — | 200 | | actions | — | 200 |
| total_content_bytes | — | 5MB | | total_content_bytes | — | 5MB |
| context_files | PAPAYU_CONTEXT_MAX_FILES | 8 | | context_files | PAPAYU_CONTEXT_MAX_FILES | 8 |
| file_chars | PAPAYU_CONTEXT_MAX_FILE_CHARS | 20000 | | file_chars | PAPAYU_CONTEXT_MAX_FILE_CHARS | 20000 |
| context_total | PAPAYU_CONTEXT_MAX_TOTAL_CHARS | 120000 | | context_total | PAPAYU_CONTEXT_MAX_TOTAL_CHARS | 120000 |
--- ---
## Логирование ## Логирование
| Событие | Где | | Событие | Где |
|---------|-----| |---------|-----|
| LLM_REQUEST_SENT | stderr (model, schema_version, provider, token_budget, input_chars) | | LLM_REQUEST_SENT | stderr (model, schema_version, provider, token_budget, input_chars) |
| LLM_RESPONSE_OK, LLM_RESPONSE_REPAIR | stderr | | LLM_RESPONSE_OK, LLM_RESPONSE_REPAIR | stderr |
| VALIDATION_FAILED | stderr (code, reason) | | VALIDATION_FAILED | stderr (code, reason) |
| CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS | stderr (key) | | CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS | stderr (key) |
| CONTEXT_DIET_APPLIED | stderr (files, dropped, truncated, total_chars) | | CONTEXT_DIET_APPLIED | stderr (files, dropped, truncated, total_chars) |
| APPLY_SUCCESS, APPLY_ROLLBACK, PREVIEW_READY | stderr | | APPLY_SUCCESS, APPLY_ROLLBACK, PREVIEW_READY | stderr |
**Trace (PAPAYU_TRACE=1):** `.papa-yu/traces/<trace_id>.json` — config_snapshot, context_stats, cache_stats, validated_json, schema_version, schema_hash. **Trace (PAPAYU_TRACE=1):** `.papa-yu/traces/<trace_id>.json` — config_snapshot, context_stats, cache_stats, validated_json, schema_version, schema_hash.
--- ---
## PLAN → APPLY ## PLAN → APPLY
1. **Plan:** `plan: <текст>` или «исправь/почини» → LLM возвращает `actions: []`, `summary`, `context_requests`. 1. **Plan:** `plan: <текст>` или «исправь/почини» → LLM возвращает `actions: []`, `summary`, `context_requests`.
2. **Apply:** `apply: <текст>` или «ok»/«применяй» при сохранённом плане → LLM применяет план с тем же context. 2. **Apply:** `apply: <текст>` или «ok»/«применяй» при сохранённом плане → LLM применяет план с тем же context.
3. **NO_CHANGES:** при пустом `actions` в APPLY — `summary` обязан начинаться с `NO_CHANGES:`. 3. **NO_CHANGES:** при пустом `actions` в APPLY — `summary` обязан начинаться с `NO_CHANGES:`.
--- ---
## Strict / best-effort ## Strict / best-effort
- **strict (PAPAYU_LLM_STRICT_JSON=1):** `response_format: json_schema` в API; при ошибке schema — repair-ретрай. - **strict (PAPAYU_LLM_STRICT_JSON=1):** `response_format: json_schema` в API; при ошибке schema — repair-ретрай.
- **best-effort:** без response_format; извлечение из ```json ... ```; при ошибке — repair-ретрай. - **best-effort:** без response_format; извлечение из ```json ... ```; при ошибке — repair-ретрай.
- **Capability detection:** при 4xx/5xx с упоминанием response_format — retry без него. - **Capability detection:** при 4xx/5xx с упоминанием response_format — retry без него.
--- ---
## Кеш контекста ## Кеш контекста
read_file, search, logs, env кешируются в plan-цикле. Ключ Logs: `{source, last_n}` — разные last_n не пересекаются. read_file, search, logs, env кешируются в plan-цикле. Ключ Logs: `{source, last_n}` — разные last_n не пересекаются.
--- ---
## Контекст-диета ## Контекст-диета
При превышении лимитов — урезание: search → logs → файлы низкого приоритета; FILE (read_file) — последними; для priority=0 гарантия минимум 4k chars. При превышении лимитов — урезание: search → logs → файлы низкого приоритета; FILE (read_file) — последними; для priority=0 гарантия минимум 4k chars.
--- ---
## Provider Compatibility ## Provider Compatibility
| Provider | Endpoint | `response_format: json_schema` | `strict` | OneOf (array/object) | Режим | | Provider | Endpoint | `response_format: json_schema` | `strict` | OneOf (array/object) | Режим |
|----------|----------|--------------------------------:|---------:|---------------------:|-------| |----------|----------|--------------------------------:|---------:|---------------------:|-------|
| OpenAI | `/v1/chat/completions` | ✅ | ✅ | ✅ | strict + local validate | | OpenAI | `/v1/chat/completions` | ✅ | ✅ | ✅ | strict + local validate |
| OpenAI-compatible (часть) | разные | ⚠️ | ⚠️ | ⚠️ | best-effort + local validate + repair | | OpenAI-compatible (часть) | разные | ⚠️ | ⚠️ | ⚠️ | best-effort + local validate + repair |
| Ollama | `/api/chat` | ❌ (часто) | ❌ | ⚠️ | best-effort + local validate + repair | | Ollama | `/api/chat` | ❌ (часто) | ❌ | ⚠️ | best-effort + local validate + repair |
**Поведенческие гарантии:** **Поведенческие гарантии:**
1. Если `response_format` не поддержан провайдером → fallback и лог `LLM_RESPONSE_FORMAT_FALLBACK`. 1. Если `response_format` не поддержан провайдером → fallback и лог `LLM_RESPONSE_FORMAT_FALLBACK`.
2. Локальная schema validation выполняется всегда (если schema compile ok). 2. Локальная schema validation выполняется всегда (если schema compile ok).
3. Repair-ретрай выполняется один раз при невалидном JSON. 3. Repair-ретрай выполняется один раз при невалидном JSON.
4. Если после repair невалидно → Err. 4. Если после repair невалидно → Err.
5. Capability detection: при 4xx/5xx с упоминанием response_format/json_schema → retry без него. 5. Capability detection: при 4xx/5xx с упоминанием response_format/json_schema → retry без него.
Ускоряет диагностику: если Ollama/локальная модель «тупит» — выключи `PAPAYU_LLM_STRICT_JSON` или оставь пустым. Ускоряет диагностику: если Ollama/локальная модель «тупит» — выключи `PAPAYU_LLM_STRICT_JSON` или оставь пустым.

View File

@ -1,284 +1,284 @@
# План Protocol v2 # План Protocol v2
Минимальный набор изменений для v2 — без «воды». Минимальный набор изменений для v2 — без «воды».
--- ---
## Diff v1 → v2 (схема) ## Diff v1 → v2 (схема)
| v1 | v2 | | v1 | v2 |
|----|-----| |----|-----|
| `oneOf` (root array \| object) | всегда **объект** | | `oneOf` (root array \| object) | всегда **объект** |
| `proposed_changes.actions` | только `actions` в корне | | `proposed_changes.actions` | только `actions` в корне |
| `UPDATE_FILE` с `content` | `PATCH_FILE` с `patch` + `base_sha256` (по умолчанию) | | `UPDATE_FILE` с `content` | `PATCH_FILE` с `patch` + `base_sha256` (по умолчанию) |
| 5 kinds | 6 kinds (+ PATCH_FILE) | | 5 kinds | 6 kinds (+ PATCH_FILE) |
| `content` для CREATE/UPDATE | `content` для CREATE/UPDATE; `patch`+`base_sha256` для PATCH | | `content` для CREATE/UPDATE | `content` для CREATE/UPDATE; `patch`+`base_sha256` для PATCH |
Добавлено: `patch`, `base_sha256` (hex 64), взаимоисключающие правила (content vs patch/base). Добавлено: `patch`, `base_sha256` (hex 64), взаимоисключающие правила (content vs patch/base).
--- ---
## Главная цель v2 ## Главная цель v2
Снизить риск/стоимость «UPDATE_FILE целиком» и улучшить точность правок: Снизить риск/стоимость «UPDATE_FILE целиком» и улучшить точность правок:
- частичные патчи, - частичные патчи,
- «операции редактирования» вместо полной перезаписи. - «операции редактирования» вместо полной перезаписи.
--- ---
## Минимальный набор изменений ## Минимальный набор изменений
### A) Новый action kind: `PATCH_FILE` ### A) Новый action kind: `PATCH_FILE`
Вместо полного `content`, передаётся unified diff: Вместо полного `content`, передаётся unified diff:
```json ```json
{ "kind": "PATCH_FILE", "path": "src/app.py", "patch": "@@ -1,3 +1,4 @@\n..." } { "kind": "PATCH_FILE", "path": "src/app.py", "patch": "@@ -1,3 +1,4 @@\n..." }
``` ```
- Валидация патча локально. - Валидация патча локально.
- Применение патча транзакционно. - Применение патча транзакционно.
- Preview diff становится тривиальным. - Preview diff становится тривиальным.
### B) Новый action kind: `REPLACE_RANGE` ### B) Новый action kind: `REPLACE_RANGE`
Если unified diff сложен: Если unified diff сложен:
```json ```json
{ {
"kind": "REPLACE_RANGE", "kind": "REPLACE_RANGE",
"path": "src/app.py", "path": "src/app.py",
"start_line": 120, "start_line": 120,
"end_line": 180, "end_line": 180,
"content": "новый блок" "content": "новый блок"
} }
``` ```
Плюсы: проще валидировать. Минусы: зависит от line numbers (хрупко при изменениях). Плюсы: проще валидировать. Минусы: зависит от line numbers (хрупко при изменениях).
### C) «Base hash» для UPDATE/PATCH ### C) «Base hash» для UPDATE/PATCH
Исключить race (файл изменился между plan/apply): Исключить race (файл изменился между plan/apply):
```json ```json
{ "kind": "PATCH_FILE", "path": "...", "base_sha256": "...", "patch": "..." } { "kind": "PATCH_FILE", "path": "...", "base_sha256": "...", "patch": "..." }
``` ```
Если hash не совпал → Err и переход в PLAN. Если hash не совпал → Err и переход в PLAN.
--- ---
## Совместимость v1/v2 ## Совместимость v1/v2
- `schema_version=1` → нынешний формат (UPDATE_FILE, CREATE_FILE, …). - `schema_version=1` → нынешний формат (UPDATE_FILE, CREATE_FILE, …).
- `schema_version=2` → допускает `PATCH_FILE` / `REPLACE_RANGE` и расширенные поля. - `schema_version=2` → допускает `PATCH_FILE` / `REPLACE_RANGE` и расширенные поля.
В коде: В коде:
- Компилировать обе схемы: `llm_response_schema.json` (v1), `llm_response_schema_v2.json`. - Компилировать обе схемы: `llm_response_schema.json` (v1), `llm_response_schema_v2.json`.
- Выбор активной по env: `PAPAYU_PROTOCOL_DEFAULT` или `PAPAYU_PROTOCOL_VERSION` (default 2). - Выбор активной по env: `PAPAYU_PROTOCOL_DEFAULT` или `PAPAYU_PROTOCOL_VERSION` (default 2).
- Валидация/парсер: сначала проверить schema v2 (если включена), иначе v1. - Валидация/парсер: сначала проверить schema v2 (если включена), иначе v1.
--- ---
## Порядок внедрения v2 без риска ## Порядок внедрения v2 без риска
1. Добавить v2 schema + валидаторы + apply engine. 1. Добавить v2 schema + валидаторы + apply engine.
2. Добавить «LLM prompt v2» (рекомендовать PATCH_FILE вместо UPDATE_FILE). 2. Добавить «LLM prompt v2» (рекомендовать PATCH_FILE вместо UPDATE_FILE).
3. Golden traces v2. 3. Golden traces v2.
4. **v2 default** с автоматическим fallback на v1 (реализовано). 4. **v2 default** с автоматическим fallback на v1 (реализовано).
--- ---
## v2 default + fallback (реализовано) ## v2 default + fallback (реализовано)
- **PAPAYU_PROTOCOL_DEFAULT** (или PAPAYU_PROTOCOL_VERSION): default 2. - **PAPAYU_PROTOCOL_DEFAULT** (или PAPAYU_PROTOCOL_VERSION): default 2.
- **PAPAYU_PROTOCOL_FALLBACK_TO_V1**: default 1 (включён). При ошибках v2 (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN) — автоматический retry с v1. - **PAPAYU_PROTOCOL_FALLBACK_TO_V1**: default 1 (включён). При ошибках v2 (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN) — автоматический retry с v1.
- Fallback только для APPLY (plan остаётся по выбранному протоколу). - Fallback только для APPLY (plan остаётся по выбранному протоколу).
- Trace: `protocol_default`, `protocol_attempts`, `protocol_fallback_reason`. - Trace: `protocol_default`, `protocol_attempts`, `protocol_fallback_reason`.
- Лог: `[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason=ERR_...` - Лог: `[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason=ERR_...`
**Compatibility:** Default protocol — v2. Apply может fallback на v1 при специфичных кодах ошибок (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN). **Compatibility:** Default protocol — v2. Apply может fallback на v1 при специфичных кодах ошибок (ERR_PATCH_APPLY_FAILED, ERR_NON_UTF8_FILE, ERR_V2_UPDATE_EXISTING_FORBIDDEN).
### Метрики для анализа (grep по trace / логам) ### Метрики для анализа (grep по trace / логам)
- `fallback_rate = fallback_count / apply_count` - `fallback_rate = fallback_count / apply_count`
- `fallback_rate_excluding_non_utf8` — исключить ERR_NON_UTF8_FILE (не провал v2, ограничение данных) - `fallback_rate_excluding_non_utf8` — исключить ERR_NON_UTF8_FILE (не провал v2, ограничение данных)
- Распределение причин fallback: - Распределение причин fallback:
- ERR_PATCH_APPLY_FAILED - ERR_PATCH_APPLY_FAILED
- ERR_NON_UTF8_FILE - ERR_NON_UTF8_FILE
- ERR_V2_UPDATE_EXISTING_FORBIDDEN - ERR_V2_UPDATE_EXISTING_FORBIDDEN
Trace-поля: `protocol_repair_attempt` (0|1), `protocol_fallback_stage` (apply|preview|validate|schema). Trace-поля: `protocol_repair_attempt` (0|1), `protocol_fallback_stage` (apply|preview|validate|schema).
Цель: понять, что мешает v2 стать единственным. Цель: понять, что мешает v2 стать единственным.
### Graduation criteria (когда отключать fallback / v2-only) ### Graduation criteria (когда отключать fallback / v2-only)
За последние 100 APPLY: За последние 100 APPLY:
- `fallback_rate < 1%` - `fallback_rate < 1%`
- **ERR_PATCH_APPLY_FAILED** < 1% и чаще лечится repair, чем fallback - **ERR_PATCH_APPLY_FAILED** < 1% и чаще лечится repair, чем fallback
- **ERR_V2_UPDATE_EXISTING_FORBIDDEN** стремится к 0 (после tightening/repair) - **ERR_V2_UPDATE_EXISTING_FORBIDDEN** стремится к 0 (после tightening/repair)
- **ERR_NON_UTF8_FILE** не считается «провалом v2» (ограничение формата; можно отдельно) - **ERR_NON_UTF8_FILE** не считается «провалом v2» (ограничение формата; можно отдельно)
- Для честной оценки v2 использовать `fallback_rate_excluding_non_utf8` - Для честной оценки v2 использовать `fallback_rate_excluding_non_utf8`
Тогда: `PAPAYU_PROTOCOL_FALLBACK_TO_V1=0` и, при необходимости, v2-only. Тогда: `PAPAYU_PROTOCOL_FALLBACK_TO_V1=0` и, при необходимости, v2-only.
**protocol_fallback_stage** (где произошло падение): `apply` (сейчас), `preview` (если preview patch не применился), `validate` (семантика), `schema` (валидация JSON) — добавить при расширении. **protocol_fallback_stage** (где произошло падение): `apply` (сейчас), `preview` (если preview patch не применился), `validate` (семантика), `schema` (валидация JSON) — добавить при расширении.
### Fallback: однократность и repair-first ### Fallback: однократность и repair-first
- **Однократность:** в одном APPLY нельзя зациклиться; если v1 fallback тоже не помог — Err. - **Однократность:** в одном APPLY нельзя зациклиться; если v1 fallback тоже не помог — Err.
- **Repair-first:** для ERR_PATCH_APPLY_FAILED и ERR_V2_UPDATE_EXISTING_FORBIDDEN — сначала repair v2, потом fallback. Для ERR_NON_UTF8_FILE — fallback сразу. - **Repair-first:** для ERR_PATCH_APPLY_FAILED и ERR_V2_UPDATE_EXISTING_FORBIDDEN — сначала repair v2, потом fallback. Для ERR_NON_UTF8_FILE — fallback сразу.
- **Trace:** `protocol_repair_attempt` (0|1), `protocol_fallback_attempted`, `protocol_fallback_stage` (apply|preview|validate|schema). - **Trace:** `protocol_repair_attempt` (0|1), `protocol_fallback_attempted`, `protocol_fallback_stage` (apply|preview|validate|schema).
### Еженедельный отчёт (grep/jq) ### Еженедельный отчёт (grep/jq)
Пример пайплайна для анализа трасс (trace JSON в одной строке на файл): Пример пайплайна для анализа трасс (trace JSON в одной строке на файл):
```bash ```bash
# APPLY count # APPLY count
grep -l '"event":"LLM_PLAN_OK"' traces/*.json 2>/dev/null | wc -l grep -l '"event":"LLM_PLAN_OK"' traces/*.json 2>/dev/null | wc -l
# fallback_count (protocol_fallback_attempted) # fallback_count (protocol_fallback_attempted)
grep '"protocol_fallback_attempted":true' traces/*.json 2>/dev/null | wc -l grep '"protocol_fallback_attempted":true' traces/*.json 2>/dev/null | wc -l
# breakdown по причинам # breakdown по причинам
grep -oh '"protocol_fallback_reason":"[^"]*"' traces/*.json 2>/dev/null | sort | uniq -c grep -oh '"protocol_fallback_reason":"[^"]*"' traces/*.json 2>/dev/null | sort | uniq -c
# repair_success (protocol_repair_attempt=0 и нет fallback в следующей трассе) — требует связки # repair_success (protocol_repair_attempt=0 и нет fallback в следующей трассе) — требует связки
jq -s '[.[] | select(.event=="LLM_PLAN_OK" and .protocol_repair_attempt==0)] | length' traces/*.json 2>/dev/null jq -s '[.[] | select(.event=="LLM_PLAN_OK" and .protocol_repair_attempt==0)] | length' traces/*.json 2>/dev/null
# top paths по repair_injected_sha256 # top paths по repair_injected_sha256
grep -oh '"repair_injected_paths":\[[^]]*\]' traces/*.json 2>/dev/null | sort | uniq -c | sort -rn | head -20 grep -oh '"repair_injected_paths":\[[^]]*\]' traces/*.json 2>/dev/null | sort | uniq -c | sort -rn | head -20
``` ```
**System prompt v2** (`FIX_PLAN_SYSTEM_PROMPT_V2`): жёсткие правила PATCH_FILE, base_sha256, object-only, NO_CHANGES. Включается при `PAPAYU_PROTOCOL_VERSION=2` и режиме fix-plan/fixit. **System prompt v2** (`FIX_PLAN_SYSTEM_PROMPT_V2`): жёсткие правила PATCH_FILE, base_sha256, object-only, NO_CHANGES. Включается при `PAPAYU_PROTOCOL_VERSION=2` и режиме fix-plan/fixit.
**Формат FILE-блока v2:** **Формат FILE-блока v2:**
``` ```
FILE[path/to/file.py] (sha256=7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a): FILE[path/to/file.py] (sha256=7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a):
<content> <content>
``` ```
sha256 — от полного содержимого файла; **не обрезается** при context-diet. Модель копирует его в `base_sha256` для PATCH_FILE. sha256 — от полного содержимого файла; **не обрезается** при context-diet. Модель копирует его в `base_sha256` для PATCH_FILE.
### Prompt rules (оптимизация v2) ### Prompt rules (оптимизация v2)
- Патч должен быть **минимальным** — меняй только нужные строки, не форматируй файл целиком. - Патч должен быть **минимальным** — меняй только нужные строки, не форматируй файл целиком.
- Каждый `@@` hunk должен иметь 13 строки контекста до/после изменения. - Каждый `@@` hunk должен иметь 13 строки контекста до/после изменения.
- Не делай массовых форматирований и EOL-изменений. - Не делай массовых форматирований и EOL-изменений.
- Если файл не UTF-8 или слишком большой/генерируемый — верни PLAN (actions=[]) и запроси альтернативу. - Если файл не UTF-8 или слишком большой/генерируемый — верни PLAN (actions=[]) и запроси альтернативу.
**Авто-эскалация при ERR_PATCH_APPLY_FAILED** (опционально): при repair retry добавить «Увеличь контекст hunks до 3 строк, не меняй соседние блоки.» **Авто-эскалация при ERR_PATCH_APPLY_FAILED** (опционально): при repair retry добавить «Увеличь контекст hunks до 3 строк, не меняй соседние блоки.»
--- ---
## PATCH_FILE engine (реализовано) ## PATCH_FILE engine (реализовано)
- **Модуль `patch`:** sha256_hex, is_valid_sha256_hex, looks_like_unified_diff, apply_unified_diff_to_text (diffy) - **Модуль `patch`:** sha256_hex, is_valid_sha256_hex, looks_like_unified_diff, apply_unified_diff_to_text (diffy)
- **tx::apply_patch_file_impl:** проверка base_sha256 → применение diff → EOL нормализация → запись - **tx::apply_patch_file_impl:** проверка base_sha256 → применение diff → EOL нормализация → запись
- **Preview:** preview_patch_file проверяет base_sha256 и применимость, возвращает patch в DiffItem - **Preview:** preview_patch_file проверяет base_sha256 и применимость, возвращает patch в DiffItem
- **Коды ошибок:** ERR_PATCH_NOT_UNIFIED, ERR_BASE_MISMATCH, ERR_PATCH_APPLY_FAILED, ERR_BASE_SHA256_INVALID, ERR_NON_UTF8_FILE - **Коды ошибок:** ERR_PATCH_NOT_UNIFIED, ERR_BASE_MISMATCH, ERR_PATCH_APPLY_FAILED, ERR_BASE_SHA256_INVALID, ERR_NON_UTF8_FILE
- **Repair hints:** REPAIR_ERR_* для repair flow / UI - **Repair hints:** REPAIR_ERR_* для repair flow / UI
--- ---
## ERR_NON_UTF8_FILE и ERR_V2_UPDATE_EXISTING_FORBIDDEN ## ERR_NON_UTF8_FILE и ERR_V2_UPDATE_EXISTING_FORBIDDEN
**ERR_NON_UTF8_FILE:** PATCH_FILE работает только по UTF-8 тексту. Для бинарных/не-UTF8 файлов — только CREATE_FILE (если явно нужно), иначе отказ/PLAN. Сообщение для UI: «Файл не UTF-8. PATCH_FILE недоступен. Перейди в PLAN и выбери другой подход.» **ERR_NON_UTF8_FILE:** PATCH_FILE работает только по UTF-8 тексту. Для бинарных/не-UTF8 файлов — только CREATE_FILE (если явно нужно), иначе отказ/PLAN. Сообщение для UI: «Файл не UTF-8. PATCH_FILE недоступен. Перейди в PLAN и выбери другой подход.»
**ERR_V2_UPDATE_EXISTING_FORBIDDEN:** В v2 UPDATE_FILE запрещён для существующих файлов. Семантический гейт: если UPDATE_FILE и файл существует → ошибка. Repair: «Сгенерируй PATCH_FILE вместо UPDATE_FILE». **ERR_V2_UPDATE_EXISTING_FORBIDDEN:** В v2 UPDATE_FILE запрещён для существующих файлов. Семантический гейт: если UPDATE_FILE и файл существует → ошибка. Repair: «Сгенерируй PATCH_FILE вместо UPDATE_FILE».
--- ---
## Рекомендации для v2 ## Рекомендации для v2
- В v2 модификация существующих файлов **по умолчанию** через `PATCH_FILE`. - В v2 модификация существующих файлов **по умолчанию** через `PATCH_FILE`.
- `base_sha256` обязателен для `PATCH_FILE` и проверяется приложением. - `base_sha256` обязателен для `PATCH_FILE` и проверяется приложением.
- При `ERR_BASE_MISMATCH` требуется новый PLAN (файл изменился). - При `ERR_BASE_MISMATCH` требуется новый PLAN (файл изменился).
- В APPLY отсутствие изменений оформляется через `NO_CHANGES:` и `actions: []`. - В APPLY отсутствие изменений оформляется через `NO_CHANGES:` и `actions: []`.
--- ---
## Примеры v2 ответов ## Примеры v2 ответов
### PLAN (v2): план без изменений ### PLAN (v2): план без изменений
```json ```json
{ {
"actions": [], "actions": [],
"summary": "Диагноз: падает из-за неверной обработки None.\nПлан:\n1) Прочитать src/parser.py вокруг функции parse().\n2) Добавить проверку на None и поправить тест.\nПроверка: pytest -q", "summary": "Диагноз: падает из-за неверной обработки None.\nПлан:\n1) Прочитать src/parser.py вокруг функции parse().\n2) Добавить проверку на None и поправить тест.\nПроверка: pytest -q",
"context_requests": [ "context_requests": [
{ "type": "read_file", "path": "src/parser.py", "start_line": 1, "end_line": 260 }, { "type": "read_file", "path": "src/parser.py", "start_line": 1, "end_line": 260 },
{ "type": "read_file", "path": "tests/test_parser.py", "start_line": 1, "end_line": 200 } { "type": "read_file", "path": "tests/test_parser.py", "start_line": 1, "end_line": 200 }
], ],
"memory_patch": {} "memory_patch": {}
} }
``` ```
### APPLY (v2): PATCH_FILE на существующий файл ### APPLY (v2): PATCH_FILE на существующий файл
`base_sha256` должен совпасть с хэшем текущего файла. `base_sha256` должен совпасть с хэшем текущего файла.
```json ```json
{ {
"actions": [ "actions": [
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "src/parser.py", "path": "src/parser.py",
"base_sha256": "7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a", "base_sha256": "7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a",
"patch": "--- a/src/parser.py\n+++ b/src/parser.py\n@@ -41,6 +41,10 @@ def parse(value):\n- return value.strip()\n+ if value is None:\n+ return \"\"\n+ return value.strip()\n" "patch": "--- a/src/parser.py\n+++ b/src/parser.py\n@@ -41,6 +41,10 @@ def parse(value):\n- return value.strip()\n+ if value is None:\n+ return \"\"\n+ return value.strip()\n"
}, },
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "tests/test_parser.py", "path": "tests/test_parser.py",
"base_sha256": "0a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0", "base_sha256": "0a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0",
"patch": "--- a/tests/test_parser.py\n+++ b/tests/test_parser.py\n@@ -10,7 +10,7 @@ def test_parse_none():\n- assert parse(None) is None\n+ assert parse(None) == \"\"\n" "patch": "--- a/tests/test_parser.py\n+++ b/tests/test_parser.py\n@@ -10,7 +10,7 @@ def test_parse_none():\n- assert parse(None) is None\n+ assert parse(None) == \"\"\n"
} }
], ],
"summary": "Исправлено: parse(None) теперь возвращает пустую строку. Обновлён тест.\nПроверка: pytest -q", "summary": "Исправлено: parse(None) теперь возвращает пустую строку. Обновлён тест.\nПроверка: pytest -q",
"context_requests": [], "context_requests": [],
"memory_patch": {} "memory_patch": {}
} }
``` ```
### APPLY (v2): создание файлов (как в v1) ### APPLY (v2): создание файлов (как в v1)
```json ```json
{ {
"actions": [ "actions": [
{ "kind": "CREATE_DIR", "path": "src" }, { "kind": "CREATE_DIR", "path": "src" },
{ {
"kind": "CREATE_FILE", "kind": "CREATE_FILE",
"path": "README.md", "path": "README.md",
"content": "# My Project\n\nRun: `make run`\n" "content": "# My Project\n\nRun: `make run`\n"
} }
], ],
"summary": "Созданы папка src и README.md.", "summary": "Созданы папка src и README.md.",
"context_requests": [], "context_requests": [],
"memory_patch": {} "memory_patch": {}
} }
``` ```
### APPLY (v2): NO_CHANGES ### APPLY (v2): NO_CHANGES
```json ```json
{ {
"actions": [], "actions": [],
"summary": "NO_CHANGES: Код уже соответствует требованиям, правки не нужны.\nПроверка: pytest -q", "summary": "NO_CHANGES: Код уже соответствует требованиям, правки не нужны.\nПроверка: pytest -q",
"context_requests": [], "context_requests": [],
"memory_patch": {} "memory_patch": {}
} }
``` ```
--- ---
## Ошибки движка v2 ## Ошибки движка v2
| Код | Когда | Действие | | Код | Когда | Действие |
|-----|-------|----------| |-----|-------|----------|
| `ERR_BASE_MISMATCH` | Файл изменился между PLAN и APPLY, sha256 не совпал | Вернуться в PLAN, перечитать файл, обновить base_sha256 | | `ERR_BASE_MISMATCH` | Файл изменился между PLAN и APPLY, sha256 не совпал | Вернуться в PLAN, перечитать файл, обновить base_sha256 |
| `ERR_PATCH_APPLY_FAILED` | Hunks не применились (контекст не совпал) | Вернуться в PLAN, запросить более точный контекст, перегенерировать патч | | `ERR_PATCH_APPLY_FAILED` | Hunks не применились (контекст не совпал) | Вернуться в PLAN, запросить более точный контекст, перегенерировать патч |
| `ERR_PATCH_NOT_UNIFIED` | LLM прислал не unified diff | Repair-ретрай с требованием unified diff | | `ERR_PATCH_NOT_UNIFIED` | LLM прислал не unified diff | Repair-ретрай с требованием unified diff |

View File

@ -1,59 +1,74 @@
# План Protocol v3 # План Protocol v3
План развития протокола — без внедрения. v2 решает «перезапись файла» через PATCH_FILE, но патчи всё ещё бывают хрупкими. **Реализовано (v2.4.5).** `PAPAYU_PROTOCOL_VERSION=3` включает EDIT_FILE. v2 решает «перезапись файла» через PATCH_FILE, но патчи всё ещё бывают хрупкими — v3 EDIT_FILE даёт якорные правки anchor/before/after.
--- ---
## Вариант v3-A (рекомендуемый): EDIT_FILE с операциями ## Вариант v3-A (рекомендуемый): EDIT_FILE с операциями
Новый action: Новый action:
```json ```json
{ {
"kind": "EDIT_FILE", "kind": "EDIT_FILE",
"path": "src/foo.py", "path": "src/foo.py",
"base_sha256": "...", "base_sha256": "...",
"edits": [ "edits": [
{ {
"op": "replace", "op": "replace",
"anchor": "def parse(", "anchor": "def parse(",
"before": "return value.strip()", "before": "return value.strip()",
"after": "if value is None:\n return \"\"\nreturn value.strip()" "after": "if value is None:\n return \"\"\nreturn value.strip()"
} }
] ]
} }
``` ```
**Плюсы:** **Плюсы:**
- Устойчивее к line drift (якорь по содержимому, не по номерам строк) - Устойчивее к line drift (якорь по содержимому, не по номерам строк)
- Проще валидировать «что именно поменялось» - Проще валидировать «что именно поменялось»
- Меньше риска ERR_PATCH_APPLY_FAILED - Меньше риска ERR_PATCH_APPLY_FAILED
**Минусы:** **Минусы:**
- Нужен свой «якорный» редактор - Нужен свой «якорный» редактор
- Якорь должен быть уникальным в файле - Якорь должен быть уникальным в файле
**MVP для v3:** **MVP для v3:**
- Оставить PATCH_FILE как fallback - Оставить PATCH_FILE как fallback
- Добавить EDIT_FILE только для текстовых файлов - Добавить EDIT_FILE только для текстовых файлов
- Engine: «найди anchor → проверь before → замени на after» - Engine: «найди anchor → проверь before → замени на after»
- base_sha256 остаётся обязательным - base_sha256 остаётся обязательным
--- ---
## Вариант v3-B: AST-level edits (язык-специфично) ## Вариант v3-B: AST-level edits (язык-специфично)
Для Python/TS можно делать по AST (insert/delete/replace узлов). Плюсы: максимальная точность. Минусы: значительно больше работы, сложнее поддерживать, нужно знать язык. Для Python/TS можно делать по AST (insert/delete/replace узлов). Плюсы: максимальная точность. Минусы: значительно больше работы, сложнее поддерживать, нужно знать язык.
--- ---
## Совместимость v1/v2/v3 ## Совместимость v1/v2/v3
- v1: UPDATE_FILE, CREATE_FILE, … - v1: UPDATE_FILE, CREATE_FILE, …
- v2: + PATCH_FILE, base_sha256 - v2: + PATCH_FILE, base_sha256
- v3: + EDIT_FILE (якорные операции), PATCH_FILE как fallback - v3: + EDIT_FILE (якорные операции), PATCH_FILE как fallback
Выбор активного протокола по env. v3 совместим с v2 (EDIT_FILE — расширение). Выбор активного протокола по env. v3 совместим с v2 (EDIT_FILE — расширение).
---
## Когда включать v3 (gates по weekly report)
Включать v3 для проекта, если за последнюю неделю:
- `fallback_by_reason.ERR_PATCH_APPLY_FAILED >= 3` **или**
- группа ошибок PATCH растёт week-over-week **и**
- `repair_success_rate` по patch падает
**Не включать / откатить v3**, если:
- много `ERR_NON_UTF8_FILE` (v3 не поможет)
- проект содержит много автогенерённых файлов или бинарных артефактов

96
docs/RUNBOOK.md Normal file
View File

@ -0,0 +1,96 @@
# Runbook — papa-yu
## Build
### Requirements
- Node.js 18+
- Rust 1.70+
- npm
### One-command build
```bash
cd papa-yu
npm install
npm run tauri build
```
Из корня: `cd src-tauri && cargo build --release` (только бэкенд).
---
## Run
### Development
```bash
npm run tauri dev
```
Поднимает Vite и Tauri. Интерфейс доступен в окне приложения.
**Важно:** не открывать скомпилированный .app без dev-сервера — фронт не загрузится.
### Production
Собранный бинарник: `src-tauri/target/release/` (или через `npm run tauri build`).
---
## Where logs are
- **Traces:** `.papa-yu/traces/*.json` (при `PAPAYU_TRACE=1`)
- **Stderr:** события LLM, apply, fallback — в консоль/терминал
- **Weekly report:** агрегация из traces
---
## Common issues
### Golden traces mismatch
**Симптом:** `cargo test golden_traces` падает с ошибкой schema_hash.
**Причина:** изменён `llm_response_schema_v*.json`.
**Действие:** пересчитать SHA256 схемы, обновить `schema_hash` во всех фикстурах в `docs/golden_traces/v*/*.json`.
---
### LLM planner instability
**Симптом:** невалидный JSON, ERR_SCHEMA_VALIDATION, частые repair.
**Причина:** модель не держит strict JSON, или промпт перегружен.
**Действие:** включить `PAPAYU_LLM_STRICT_JSON=1` (если провайдер поддерживает); уменьшить контекст; проверить `PAPAYU_CONTEXT_MAX_*`.
---
### PATCH/EDIT conflicts
**Симптом:** ERR_EDIT_ANCHOR_NOT_FOUND, ERR_EDIT_BEFORE_NOT_FOUND, ERR_EDIT_AMBIGUOUS.
**Причина:** anchor/before не соответствуют текущему содержимому файла.
**Действие:** см. `docs/EDIT_FILE_DEBUG.md`. Убедиться, что FILE-блоки в контексте включают sha256 (v2/v3).
---
### "Could not fetch a valid…" (UI)
**Симптом:** пустое окно при запуске.
**Причина:** фронт не загрузился (Vite не поднят).
**Действие:** запускать только `npm run tauri dev`, не открывать .app напрямую.
---
## Diagnostics
- **Проверить протокол:** `PAPAYU_PROTOCOL_VERSION=3` для EDIT_FILE.
- **Воспроизведение:** включить `PAPAYU_TRACE=1`, выполнить сценарий, смотреть `.papa-yu/traces/`.
- **Тесты:** `cd src-tauri && cargo test` — полный прогон.
- **CI:** `cargo fmt --check`, `cargo clippy`, `cargo audit`, `cargo test`.

View File

@ -0,0 +1,69 @@
# Безопасность и личная автоматизация (терминал + интернет)
PAPA YU рассчитан на **личное использование**. Ниже — как настроена защита и как приложение может работать с терминалом и интернетом (Chrome, GitHub и т.д.) оставаясь надёжно защищённым.
---
## 1. Что разрешено по умолчанию
- **Файлы:** чтение/запись только в выбранных пользователем путях; защита служебных каталогов (`.git`, `node_modules`, `target`, `dist` и т.д.).
- **Сеть:** только исходящие HTTPS-запросы к настроенным API (LLM, OpenRouter и т.д.) из кода приложения; никакого произвольного доступа с фронтенда.
- **Браузер:** через встроенный механизм Tauri (`shell:allow-open`) можно открывать только ссылки `http(s)://`, `mailto:`, `tel:` в **стандартном браузере** системы (Chrome, Safari и т.д.). Произвольные команды в shell для этого не нужны.
---
## 2. Личная автоматизация: терминал и интернет
Чтобы приложение могло **самостоятельно** выполнять ограниченный набор действий в терминале и открывать ссылки (GitHub, документация и т.д.), используется отдельная capability **personal-automation**.
### Что даёт personal-automation
- **Открытие URL в браузере** (если по какой-то причине нужен явный вызов):
- macOS: `open` с аргументом-URL (только `https?://...`).
- Linux: `xdg-open` с URL.
- Windows: `cmd /c start "" <URL>`.
- **Терминал — только разрешённые команды и аргументы:**
- **git**: `status`, `pull`, `push`, `add`, `commit`, `checkout`, `branch`, `log`, `diff`, `clone`, `fetch`, `merge` и аргументы по валидатору (URL репозитория, имена веток/путей).
- **npm**: `install`, `run`, `ci`, `test`, `build`, `start`, `exec`, `update` и допустимые имена скриптов/пакетов.
- **npx**: `-y`, `create-*`, `run`, `exec` и допустимые имена.
- **cargo**: `build`, `test`, `run`, `check`, `clippy`, `fmt`, `install` и допустимые аргументы.
- **python3**: `-m pytest`, `pip install` и т.п. с ограниченными аргументами.
Любая **другая** команда или аргумент вне этого списка **заблокированы** на уровне Tauri (scope shell).
### Как включить
Capability `personal-automation` уже подключён в проекте: окно `main` получает эти разрешения вместе с базовыми. Ничего дополнительно включать не нужно.
### Как ужесточить защиту
1. **Отключить выполнение команд в терминале:**
В `src-tauri/capabilities/` удалите или переименуйте `personal-automation.json` и пересоберите приложение. Останется только открытие ссылок через стандартный `shell:allow-open` (без явных `open`/`xdg-open`/`start` из capability).
2. **Сузить список команд:**
Отредактируйте `personal-automation.json`: удалите ненужные блоки `{"name": "...", "cmd": "...", "args": [...]}` или сократите `args` до конкретных подкоманд/валидаторов.
3. **Оставить только открытие ссылок:**
В `personal-automation.json` оставьте только команды `open-url`, `xdg-open-url` и при необходимости `start-url`; блоки `git`, `npm`, `cargo`, `python3`, `npx` удалите.
---
## 3. Принципы защиты
- **Нет произвольного кода:** фронтенд не может выполнить произвольную строку в shell (например, `bash -c "..."`). Разрешены только команды и аргументы из scope.
- **Allowlist команд:** в `verify` и `auto_check` на бэкенде выполняются только команды из `src-tauri/config/verify_allowlist.json` с фиксированными аргументами.
- **Подтверждение пользователя:** применение изменений к проекту только после явного подтверждения (`user_confirmed`).
- **Сеть:** все вызовы к LLM/API идут из Rust (reqwest); ключи и URL задаются через переменные окружения, не хранятся в фронтенде.
---
## 4. Рекомендации для личного использования
- Храните `.env.openai` (ключи API) только локально и не коммитьте их в репозиторий.
- Используйте один аккаунт/профиль ОС для повседневной работы; не запускайте приложение с правами администратора без необходимости.
- При необходимости отключите или сузьте `personal-automation` по инструкциям выше.
---
*См. также: `README.md` (раздел «Безопасность»), `config/verify_allowlist.json`.*

View File

@ -0,0 +1,109 @@
# Синхронизация ИИ-агента с Snyk Code и Documatic
Интеграция с **Snyk Code** (анализ и дополнение кода) и **Documatic** (архитектура и структурирование) для передачи контекста в agent-sync и ИИ-агента.
---
## 1. Snyk Code
[Snyk Code](https://docs.snyk.io/scan-with-snyk/snyk-code) выполняет статический анализ кода на уязвимости и проблемы безопасности. Результаты подмешиваются в **agent-sync** и доступны агенту в Cursor / Claude Code.
### Включение
1. Получите API-токен в [Snyk](https://app.snyk.io/account): Account Settings → General → API Token (или создайте Service Account).
2. Узнайте **Organization ID** (в настройках организации или в URL: `app.snyk.io/org/<org_id>`).
3. Опционально: если в Snyk импортирован конкретный проект — скопируйте **Project ID** (в карточке проекта).
4. Задайте переменные окружения:
```bash
export PAPAYU_AGENT_SYNC=1
export PAPAYU_SNYK_SYNC=1
export PAPAYU_SNYK_TOKEN="ваш-токен"
# или
export SNYK_TOKEN="ваш-токен"
export PAPAYU_SNYK_ORG_ID="uuid-организации"
# опционально — только issues этого проекта
export PAPAYU_SNYK_PROJECT_ID="uuid-проекта"
```
### Поведение
- При каждом **анализе проекта** (кнопка «Анализировать» и т.п.) приложение при включённом `PAPAYU_SNYK_SYNC` запрашивает у Snyk REST API список **code**-issues по организации (и по проекту, если задан `PAPAYU_SNYK_PROJECT_ID`).
- Результаты записываются в **`.papa-yu/agent-sync.json`** в поле **`snyk_findings`** (массив: title, details, path). Агент в IDE может читать этот файл и учитывать замечания Snyk при предложениях.
### Ограничения
- Нужен проект, уже импортированный в Snyk (через UI или интеграцию с Git). Локальный анализ только по пути без импорта в Snyk через этот API не запускается.
- Используется REST API Snyk: `GET /rest/orgs/{org_id}/issues?type=code&...`. Версия API: `2024-04-02~experimental`.
---
## 2. Documatic (архитектура и структурирование)
[Documatic](https://www.documatic.com/) — поиск и документация по кодовой базе (расширение VS Code и веб-платформа). Публичного REST API для вызова из PAPA YU нет, поэтому интеграция — **через общий файл архитектуры**, который агент читает из agent-sync.
### Настройка
1. Экспортируйте или сохраните описание архитектуры/структуры проекта в файл в репозитории, например:
- **`.papa-yu/architecture.md`** (по умолчанию),
- или укажите свой путь через переменную **`PAPAYU_DOCUMATIC_ARCH_PATH`** (относительно корня проекта).
2. Содержимое можно:
- сформировать вручную,
- сгенерировать в Documatic (если есть экспорт) и скопировать в этот файл,
- собрать из других инструментов (диаграммы, списки модулей и т.д.).
3. Переменные окружения:
```bash
export PAPAYU_AGENT_SYNC=1
# по умолчанию читается .papa-yu/architecture.md
# свой путь (относительно корня проекта):
# export PAPAYU_DOCUMATIC_ARCH_PATH="docs/architecture.md"
```
### Поведение
- При записи **agent-sync** приложение читает файл архитектуры (если он есть) и добавляет его содержимое в **`architecture_summary`** в **`.papa-yu/agent-sync.json`** (обрезается до 16 000 символов). ИИ-агент в Cursor / Claude Code может использовать это для анализа и структурирования архитектуры при предложениях.
---
## 3. Структура agent-sync.json
При включённых интеграциях файл **`.papa-yu/agent-sync.json`** может выглядеть так:
```json
{
"path": "/path/to/project",
"updated_at": "2026-02-09T12:00:00Z",
"narrative": "Краткий вывод анализа PAPA YU...",
"findings_count": 3,
"actions_count": 5,
"snyk_findings": [
{
"title": "SQL injection",
"details": "[high] ...",
"path": "src/api/users.rs"
}
],
"architecture_summary": "# Архитектура\n\nМодули: ..."
}
```
- **snyk_findings** — при `PAPAYU_SNYK_SYNC=1` и успешном ответе Snyk API.
- **architecture_summary** — при наличии файла архитектуры (по умолчанию `.papa-yu/architecture.md` или путь из `PAPAYU_DOCUMATIC_ARCH_PATH`).
---
## 4. Краткий чеклист
| Задача | Действие |
|--------|----------|
| Snyk Code | Задать `PAPAYU_AGENT_SYNC=1`, `PAPAYU_SNYK_SYNC=1`, `PAPAYU_SNYK_TOKEN`, `PAPAYU_SNYK_ORG_ID` (и при необходимости `PAPAYU_SNYK_PROJECT_ID`). Импортировать проект в Snyk. |
| Documatic / архитектура | Положить описание архитектуры в `.papa-yu/architecture.md` (или задать `PAPAYU_DOCUMATIC_ARCH_PATH`). Включить `PAPAYU_AGENT_SYNC=1`. |
| Агент в IDE | Настроить правило/скрипт: читать `.papa-yu/agent-sync.json` и учитывать `narrative`, `snyk_findings`, `architecture_summary` при предложениях. |
---
*См. также: `docs/CLAUDE_AND_AGENT_SYNC.md`, `env.openai.example`.*

View File

@ -0,0 +1,164 @@
# Technical Investment Memo — papa-yu
## 1. Executive Summary
papa-yu is a desktop application built with Tauri and Rust, designed to orchestrate LLM-driven workflows involving structured file editing (PATCH/EDIT) and controlled external research.
The project demonstrates a high level of technical maturity:
- deterministic behavior enforced via protocol versioning and golden traces
- strong CI/CD quality gates
- explicit security controls around network access (SSRF-safe design)
- clear separation between UI, domain logic, and IO
The codebase is maintainable, testable, and transferable with moderate onboarding effort. No critical technical blockers for further development or transfer of ownership were identified.
---
## 2. Product Overview (Technical Perspective)
### Purpose
The system automates and orchestrates complex workflows driven by LLM output, with a focus on reproducibility, safety, and long-term maintainability.
### Target usage
- Desktop environments
- Controlled workloads (nonreal-time, nonhigh-concurrency)
- Users requiring deterministic behavior over flexibility
### Explicit non-goals
- Server-side, high-concurrency workloads
- Real-time processing
- Execution of untrusted plugins
(See `docs/LIMITS.md` for details.)
---
## 3. Architecture Overview
### High-level design
- Desktop application using Tauri
- Core logic implemented in Rust
- UI is a thin client without direct filesystem or network access
### Key architectural principles
- All IO is centralized and controlled
- Domain logic is isolated from side effects
- Observable behavior is locked via golden traces
### Core modules
- `net` — single entry point for outbound network access with SSRF protection
- `llm_planner` — orchestration and planning logic
- `online_research` — external data integration via safe adapters
- `commands/*` — Tauri boundary layer
Architecture documentation is available in `docs/ARCHITECTURE.md`.
---
## 4. Code Quality and Testing
### Testing strategy
- >100 automated tests
- Golden traces for protocol versions v1, v2, v3
- Regression detection is enforced in CI
### CI/CD
- Formatting and linting enforced (`cargo fmt`, `clippy`)
- Automated test execution
- Dependency vulnerability scanning (`cargo audit`)
- Reproducible builds from a clean checkout
The CI pipeline serves as a hard quality gate.
---
## 5. Security Posture (Design & Code Level)
Security is addressed at the architectural level:
- Centralized network access via `net::fetch_url_safe`
- SSRF mitigations:
- scheme allowlist (http, https)
- denial of private/loopback IP ranges
- request size limit (1 MB)
- timeout (15 seconds)
- No secrets stored in the repository
- Dependency vulnerability scanning in CI
**Scope limitation:**
- No penetration testing performed
- Security review limited to design and code analysis
(See `docs/adr/ADR-003-ssrf.md` for rationale.)
---
## 6. Dependencies and Supply Chain
- Dependencies are locked via `Cargo.lock` and `package-lock.json`
- Automated vulnerability scanning is enabled
- Planned addition: license policy enforcement via `cargo deny`
No known blocking license risks identified at this stage.
---
## 7. Operational Maturity
- Project can be built and run via documented steps
- Common failure modes are documented in `docs/INCIDENTS.md`
- Deterministic behavior simplifies debugging and reproduction
- Runbook documentation (`docs/RUNBOOK.md`) provides basic operational guidance
---
## 8. Known Risks and Technical Debt
Known risks are explicitly documented:
- Sensitivity of LLM planning to malformed input
- Rigid PATCH/EDIT protocol trade-offs
- Desktop-centric architecture limits scalability
Technical debt is tracked and intentional where present. No unbounded or hidden debt has been identified.
---
## 9. Roadmap (Technical)
### Short-term
- License policy enforcement (`cargo deny`)
- Further documentation hardening
### Mid-term
- Reduction of bus-factor through onboarding exercises
- Optional expansion of test coverage in edge cases
### Long-term
- Additional protocol versions
- New research adapters via existing extension points
---
## 10. Transferability Assessment
From a technical perspective:
- The system is explainable within days, not weeks
- No single undocumented "magic" components exist
- Ownership transfer risk is considered low to moderate
Overall technical readiness supports both continued independent development and potential acquisition.

View File

@ -0,0 +1,63 @@
# Инвестиционный Tech Memo (шаблон)
Документ на 35 страниц для CTO / tech advisors инвестора.
---
## 1. Executive Summary (½ страницы)
- Что за продукт
- В каком состоянии кодовая база
- Главные сильные стороны
- Ключевые риски (честно)
---
## 2. Текущая архитектура
- Краткое описание
- Почему выбраны Rust / Tauri
- Основные модули и границы
- Что легко расширять, что нет
---
## 3. Качество и поддерживаемость
- Стандарты кода
- Тестирование
- CI/CD
- Уровень техдолга (осознанный / неосознанный)
---
## 4. Security & compliance (scope-limited)
- Модель угроз (high-level)
- Работа с сетью / данными
- Зависимости и supply chain
- Чего **не** делали (pentest и т.п.)
---
## 5. Эксплуатационные риски
- Известные проблемы
- Инциденты
- Ограничения продукта
---
## 6. Roadmap (12 месяцев)
- Quick wins
- Structural improvements
- Что повысит value продукта
---
## 7. Оценка с точки зрения покупателя
- Bus-factor
- Стоимость входа нового владельца
- Предсказуемость развития

34
docs/adr/ADR-001-tauri.md Normal file
View File

@ -0,0 +1,34 @@
# ADR-001: Use Tauri for Desktop Application
## Context
The product requires a desktop UI with access to local filesystem while keeping the core logic secure, testable, and portable.
Alternatives considered:
- Electron
- Native GUI frameworks
- Web-only application
## Decision
Use Tauri with a Rust backend and a thin UI layer.
## Rationale
- Smaller attack surface than Electron
- Native performance
- Strong isolation between UI and core logic
- Good fit for Rust-based domain logic
## Consequences
**Positive:**
- Reduced resource usage
- Clear separation of concerns
**Negative:**
- More explicit boundary management
- Rust knowledge required for core development

View File

@ -0,0 +1,28 @@
# ADR-002: Structured PATCH/EDIT (v3) with Golden Traces
## Context
The system performs automated file modifications driven by LLM output. Naive diff-based approaches led to nondeterministic and hard-to-debug behavior.
## Decision
Introduce structured PATCH/EDIT protocol (v3) and lock behavior using golden traces.
## Rationale
- Deterministic behavior is more valuable than flexibility
- Golden traces provide regression safety
- Protocol versioning allows evolution without breaking behavior
## Consequences
**Positive:**
- Predictable edits
- Easier debugging
- Strong regression detection
**Negative:**
- More rigid protocol
- Higher upfront complexity

29
docs/adr/ADR-003-ssrf.md Normal file
View File

@ -0,0 +1,29 @@
# ADR-003: Centralized Network Access and SSRF Protection
## Context
The application performs external fetch operations based on user or LLM input. Uncontrolled network access introduces SSRF and data exfiltration risks.
## Decision
All network access must go through a single module (`net`) with explicit safety controls.
## Controls
- Allowlisted schemes (http, https)
- Deny private and loopback IP ranges (RFC1918, link-local)
- Request size limit (1 MB)
- Timeout (15 s)
- Reject URL with `user:pass@`
## Consequences
**Positive:**
- Eliminates a large class of security vulnerabilities
- Centralized policy enforcement
**Negative:**
- Less flexibility for ad-hoc network calls
- Requires discipline when adding new features

View File

@ -1,62 +1,68 @@
# Golden traces — эталонные артефакты # Golden traces — эталонные артефакты
Фиксируют детерминированные результаты papa-yu без зависимости от LLM. Фиксируют детерминированные результаты papa-yu без зависимости от LLM.
Позволяют ловить регрессии в валидации, парсинге, диете, кеше. Позволяют ловить регрессии в валидации, парсинге, диете, кеше.
## Структура ## Структура
``` ```
docs/golden_traces/ docs/golden_traces/
README.md README.md
v1/ # Protocol v1 fixtures v1/ # Protocol v1 fixtures
001_fix_bug_plan.json 001_fix_bug_plan.json
002_fix_bug_apply.json 002_fix_bug_apply.json
... ...
v2/ # Protocol v2 fixtures (PATCH_FILE, base_sha256) v2/ # Protocol v2 fixtures (PATCH_FILE, base_sha256)
001_fix_bug_plan.json 001_fix_bug_plan.json
002_fix_bug_apply_patch.json v3/ # Protocol v3 fixtures (EDIT_FILE, anchor/before/after)
003_base_mismatch_block.json 001_fix_bug_plan.json
004_patch_apply_failed_block.json 002_fix_bug_apply_edit.json
005_no_changes_apply.json 003_edit_anchor_not_found_block.json
``` 004_edit_base_mismatch_block.json
005_no_changes_apply.json
## Формат fixture (без секретов) ```
Минимальный стабильный JSON: ## Формат fixture (без секретов)
- `protocol` — schema_version, schema_hash
- `request` — mode, input_chars, token_budget, strict_json, provider, model Минимальный стабильный JSON:
- `context` — context_digest (опц.), context_stats, cache_stats - `protocol` — schema_version, schema_hash
- `result` — validated_json (объект), validation_outcome, error_code - `request` — mode, input_chars, token_budget, strict_json, provider, model
- `context` — context_digest (опц.), context_stats, cache_stats
Без raw_content, без секретов. - `result` — validated_json (объект), validation_outcome, error_code
## Генерация из трасс Без raw_content, без секретов.
```bash ## Генерация из трасс
cd src-tauri
cargo run --bin trace_to_golden -- <trace_id> [output_path] ```bash
cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path] cd src-tauri
``` cargo run --bin trace_to_golden -- <trace_id> [output_path]
cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path]
Читает trace из `.papa-yu/traces/<trace_id>.json` или из файла. Пишет в `docs/golden_traces/v1/`. ```
## Регрессионный тест Читает trace из `.papa-yu/traces/<trace_id>.json` или из файла. Пишет в `docs/golden_traces/v1/`.
```bash ## Отладка EDIT_FILE (v3)
cargo test golden_traces_v1_validate golden_traces_v2_validate
# или Чеклист для E2E проверки v3 EDIT_FILE: `docs/EDIT_FILE_DEBUG.md`.
make test-protocol
npm run test-protocol ## Регрессионный тест
```
```bash
--- cargo test golden_traces_v1_validate golden_traces_v2_validate golden_traces_v3_validate
# или
## Политика обновления golden traces make test-protocol
npm run test-protocol
**Когда обновлять:** только при намеренном изменении протокола или валидатора (path/content/conflicts, schema, диета). ```
**Как обновлять:** `trace_to_golden``make golden` (из последней трассы) или `make golden TRACE_ID=<id>`. ---
**Как добавлять новый сценарий:** выполни propose с PAPAYU_TRACE=1, затем `make golden` и сохрани вывод в `v1/NNN_<name>.json` с номером NNN. ## Политика обновления golden traces
**При смене schema_hash:** либо bump schema_version (новый документ v2), либо обнови все fixtures (`trace_to_golden` на свежие трассы) и зафиксируй в CHANGELOG. **Когда обновлять:** только при намеренном изменении протокола или валидатора (path/content/conflicts, schema, диета).
**Как обновлять:** `trace_to_golden``make golden` (из последней трассы) или `make golden TRACE_ID=<id>`.
**Как добавлять новый сценарий:** выполни propose с PAPAYU_TRACE=1, затем `make golden` и сохрани вывод в `v1/NNN_<name>.json` с номером NNN.
**При смене schema_hash:** либо bump schema_version (новый документ v2), либо обнови все fixtures (`trace_to_golden` на свежие трассы) и зафиксируй в CHANGELOG.

View File

@ -1,43 +1,43 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "plan", "mode": "plan",
"input_chars": 12000, "input_chars": 12000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 1500, "context_total_chars": 1500,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 1, "read_misses": 1,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.0 "hit_rate": 0.0
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [], "actions": [],
"summary": "Диагноз: ошибка в main. План: заменить println! аргумент.", "summary": "Диагноз: ошибка в main. План: заменить println! аргумент.",
"context_requests": [{"type": "read_file", "path": "src/main.rs"}] "context_requests": [{"type": "read_file", "path": "src/main.rs"}]
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,48 +1,48 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 15000, "input_chars": 15000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 1800, "context_total_chars": 1800,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 1, "read_hits": 1,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{ {
"kind": "UPDATE_FILE", "kind": "UPDATE_FILE",
"path": "src/main.rs", "path": "src/main.rs",
"content": "fn main() {\n println!(\"fix\");\n}\n" "content": "fn main() {\n println!(\"fix\");\n}\n"
} }
], ],
"summary": "Исправлена функция main." "summary": "Исправлена функция main."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,45 +1,45 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 8000, "input_chars": 8000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 0, "context_files_count": 0,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 800, "context_total_chars": 800,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 1, "env_hits": 1,
"env_misses": 0, "env_misses": 0,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{"kind": "CREATE_DIR", "path": "src"}, {"kind": "CREATE_DIR", "path": "src"},
{"kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n\n## Run\n\n`cargo run`\n"} {"kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n\n## Run\n\n`cargo run`\n"}
], ],
"summary": "Созданы папка src и README." "summary": "Созданы папка src и README."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,44 +1,44 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 5000, "input_chars": 5000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": false, "strict_json": false,
"provider": "ollama", "provider": "ollama",
"model": "llama3.2" "model": "llama3.2"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 0, "context_files_count": 0,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 500, "context_total_chars": 500,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.0 "hit_rate": 0.0
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{"kind": "UPDATE_FILE", "path": ".env", "content": "FOO=bar\n"} {"kind": "UPDATE_FILE", "path": ".env", "content": "FOO=bar\n"}
], ],
"summary": "Updated .env" "summary": "Updated .env"
}, },
"validation_outcome": "err", "validation_outcome": "err",
"error_code": "protected or non-text file: .env" "error_code": "protected or non-text file: .env"
} }
} }

View File

@ -1,44 +1,44 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 10000, "input_chars": 10000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 2000, "context_total_chars": 2000,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 1, "read_hits": 1,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{"kind": "UPDATE_FILE", "path": "src/secret.rs", "content": "// changed"} {"kind": "UPDATE_FILE", "path": "src/secret.rs", "content": "// changed"}
], ],
"summary": "Updated" "summary": "Updated"
}, },
"validation_outcome": "err", "validation_outcome": "err",
"error_code": "ERR_UPDATE_WITHOUT_BASE: UPDATE_FILE path 'src/secret.rs' not read in plan" "error_code": "ERR_UPDATE_WITHOUT_BASE: UPDATE_FILE path 'src/secret.rs' not read in plan"
} }
} }

View File

@ -1,43 +1,43 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "plan", "mode": "plan",
"input_chars": 100000, "input_chars": 100000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 6, "context_files_count": 6,
"context_files_dropped_count": 3, "context_files_dropped_count": 3,
"context_total_chars": 118000, "context_total_chars": 118000,
"context_logs_chars": 5000, "context_logs_chars": 5000,
"context_truncated_files_count": 2 "context_truncated_files_count": 2
}, },
"cache_stats": { "cache_stats": {
"env_hits": 1, "env_hits": 1,
"env_misses": 0, "env_misses": 0,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 1, "logs_misses": 1,
"read_hits": 2, "read_hits": 2,
"read_misses": 4, "read_misses": 4,
"search_hits": 1, "search_hits": 1,
"search_misses": 1, "search_misses": 1,
"hit_rate": 0.4444444444444444 "hit_rate": 0.4444444444444444
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [], "actions": [],
"summary": "Диагноз: требуется больше контекста.", "summary": "Диагноз: требуется больше контекста.",
"context_requests": [] "context_requests": []
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,42 +1,42 @@
{ {
"protocol": { "protocol": {
"schema_version": 1, "schema_version": 1,
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 5000, "input_chars": 5000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 1000, "context_total_chars": 1000,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 1, "read_misses": 1,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.0 "hit_rate": 0.0
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [], "actions": [],
"summary": "NO_CHANGES: Проверка завершена, правок не требуется." "summary": "NO_CHANGES: Проверка завершена, правок не требуется."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,43 +1,43 @@
{ {
"protocol": { "protocol": {
"schema_version": 2, "schema_version": 2,
"schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b"
}, },
"request": { "request": {
"mode": "plan", "mode": "plan",
"input_chars": 12000, "input_chars": 12000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 1500, "context_total_chars": 1500,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 1, "read_misses": 1,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.0 "hit_rate": 0.0
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [], "actions": [],
"summary": "Диагноз: ошибка в main. План: PATCH_FILE для замены println! аргумента.", "summary": "Диагноз: ошибка в main. План: PATCH_FILE для замены println! аргумента.",
"context_requests": [{"type": "read_file", "path": "src/main.rs"}] "context_requests": [{"type": "read_file", "path": "src/main.rs"}]
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,55 +1,55 @@
{ {
"protocol": { "protocol": {
"schema_version": 2, "schema_version": 2,
"schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 15000, "input_chars": 15000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 2, "context_files_count": 2,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 3600, "context_total_chars": 3600,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 1, "read_hits": 1,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "src/main.rs", "path": "src/main.rs",
"patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"bug\");\n+ println!(\"fix\");\n }\n", "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"bug\");\n+ println!(\"fix\");\n }\n",
"base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" "base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}, },
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "src/lib.rs", "path": "src/lib.rs",
"patch": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,2 @@\n-pub fn foo() {}\n+pub fn foo() { /* fixed */ }\n", "patch": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,2 @@\n-pub fn foo() {}\n+pub fn foo() { /* fixed */ }\n",
"base_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "base_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
} }
], ],
"summary": "Применены PATCH_FILE для main.rs и lib.rs." "summary": "Применены PATCH_FILE для main.rs и lib.rs."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -1,49 +1,49 @@
{ {
"protocol": { "protocol": {
"schema_version": 2, "schema_version": 2,
"schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 10000, "input_chars": 10000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 2000, "context_total_chars": 2000,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 1, "read_hits": 1,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "src/main.rs", "path": "src/main.rs",
"patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n", "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n",
"base_sha256": "0000000000000000000000000000000000000000000000000000000000000000" "base_sha256": "0000000000000000000000000000000000000000000000000000000000000000"
} }
], ],
"summary": "Изменил main." "summary": "Изменил main."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": "ERR_BASE_MISMATCH" "error_code": "ERR_BASE_MISMATCH"
} }
} }

View File

@ -1,49 +1,49 @@
{ {
"protocol": { "protocol": {
"schema_version": 2, "schema_version": 2,
"schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 10000, "input_chars": 10000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 2000, "context_total_chars": 2000,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 1, "read_hits": 1,
"read_misses": 0, "read_misses": 0,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.5 "hit_rate": 0.5
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [ "actions": [
{ {
"kind": "PATCH_FILE", "kind": "PATCH_FILE",
"path": "src/main.rs", "path": "src/main.rs",
"patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,5 +1,5 @@\n fn main() {\n- println!(\"WRONG_CONTEXT_LINE\");\n+ println!(\"new\");\n }\n", "patch": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,5 +1,5 @@\n fn main() {\n- println!(\"WRONG_CONTEXT_LINE\");\n+ println!(\"new\");\n }\n",
"base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" "base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
} }
], ],
"summary": "Изменил main." "summary": "Изменил main."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": "ERR_PATCH_APPLY_FAILED" "error_code": "ERR_PATCH_APPLY_FAILED"
} }
} }

View File

@ -1,42 +1,42 @@
{ {
"protocol": { "protocol": {
"schema_version": 2, "schema_version": 2,
"schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b" "schema_hash": "49374413940cb32f3763ae62b3450647eb7b3be1ae50668cf6936f29512cef7b"
}, },
"request": { "request": {
"mode": "apply", "mode": "apply",
"input_chars": 5000, "input_chars": 5000,
"token_budget": 4096, "token_budget": 4096,
"strict_json": true, "strict_json": true,
"provider": "openai", "provider": "openai",
"model": "gpt-4o-mini" "model": "gpt-4o-mini"
}, },
"context": { "context": {
"context_stats": { "context_stats": {
"context_files_count": 1, "context_files_count": 1,
"context_files_dropped_count": 0, "context_files_dropped_count": 0,
"context_total_chars": 1000, "context_total_chars": 1000,
"context_logs_chars": 0, "context_logs_chars": 0,
"context_truncated_files_count": 0 "context_truncated_files_count": 0
}, },
"cache_stats": { "cache_stats": {
"env_hits": 0, "env_hits": 0,
"env_misses": 1, "env_misses": 1,
"logs_hits": 0, "logs_hits": 0,
"logs_misses": 0, "logs_misses": 0,
"read_hits": 0, "read_hits": 0,
"read_misses": 1, "read_misses": 1,
"search_hits": 0, "search_hits": 0,
"search_misses": 0, "search_misses": 0,
"hit_rate": 0.0 "hit_rate": 0.0
} }
}, },
"result": { "result": {
"validated_json": { "validated_json": {
"actions": [], "actions": [],
"summary": "NO_CHANGES: Проверка завершена, правок не требуется." "summary": "NO_CHANGES: Проверка завершена, правок не требуется."
}, },
"validation_outcome": "ok", "validation_outcome": "ok",
"error_code": null "error_code": null
} }
} }

View File

@ -0,0 +1,43 @@
{
"protocol": {
"schema_version": 3,
"schema_hash": "10ece82f5e140c9cc5c5f0dd72757fba80c8f86034263d85716ae461c2e679ec"
},
"request": {
"mode": "plan",
"input_chars": 12000,
"token_budget": 4096,
"strict_json": true,
"provider": "openai",
"model": "gpt-4o-mini"
},
"context": {
"context_stats": {
"context_files_count": 1,
"context_files_dropped_count": 0,
"context_total_chars": 1500,
"context_logs_chars": 0,
"context_truncated_files_count": 0
},
"cache_stats": {
"env_hits": 0,
"env_misses": 1,
"logs_hits": 0,
"logs_misses": 0,
"read_hits": 0,
"read_misses": 1,
"search_hits": 0,
"search_misses": 0,
"hit_rate": 0.0
}
},
"result": {
"validated_json": {
"actions": [],
"summary": "Диагноз: ошибка в main. План: EDIT_FILE для замены строки.",
"context_requests": [{"type": "read_file", "path": "src/main.rs"}]
},
"validation_outcome": "ok",
"error_code": null
}
}

View File

@ -0,0 +1,58 @@
{
"protocol": {
"schema_version": 3,
"schema_hash": "10ece82f5e140c9cc5c5f0dd72757fba80c8f86034263d85716ae461c2e679ec"
},
"request": {
"mode": "apply",
"input_chars": 15000,
"token_budget": 4096,
"strict_json": true,
"provider": "openai",
"model": "gpt-4o-mini"
},
"context": {
"context_stats": {
"context_files_count": 2,
"context_files_dropped_count": 0,
"context_total_chars": 3600,
"context_logs_chars": 0,
"context_truncated_files_count": 0
},
"cache_stats": {
"env_hits": 0,
"env_misses": 1,
"logs_hits": 0,
"logs_misses": 0,
"read_hits": 1,
"read_misses": 0,
"search_hits": 0,
"search_misses": 0,
"hit_rate": 0.5
}
},
"result": {
"validated_json": {
"actions": [
{
"kind": "EDIT_FILE",
"path": "src/main.rs",
"base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"edits": [
{
"op": "replace",
"anchor": "println!",
"before": "println!(\"bug\");",
"after": "println!(\"fix\");",
"occurrence": 1,
"context_lines": 2
}
]
}
],
"summary": "Применён EDIT_FILE для main.rs."
},
"validation_outcome": "ok",
"error_code": null
}
}

View File

@ -0,0 +1,37 @@
{
"protocol": {
"schema_version": 3,
"schema_hash": "10ece82f5e140c9cc5c5f0dd72757fba80c8f86034263d85716ae461c2e679ec"
},
"request": {
"mode": "apply",
"input_chars": 15000,
"token_budget": 4096,
"strict_json": true
},
"context": {},
"result": {
"validated_json": {
"actions": [
{
"kind": "EDIT_FILE",
"path": "src/main.rs",
"base_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"edits": [
{
"op": "replace",
"anchor": "NONEXISTENT_ANCHOR_XYZ",
"before": "old",
"after": "new",
"occurrence": 1,
"context_lines": 2
}
]
}
],
"summary": "Edit"
},
"validation_outcome": "ok",
"error_code": "ERR_EDIT_ANCHOR_NOT_FOUND"
}
}

View File

@ -0,0 +1,40 @@
{
"protocol": {
"schema_version": 3,
"schema_hash": "10ece82f5e140c9cc5c5f0dd72757fba80c8f86034263d85716ae461c2e679ec"
},
"request": {
"mode": "apply",
"input_chars": 15000,
"token_budget": 4096,
"strict_json": true
},
"context": {
"plan_context_contains_sha_for_path": "src/main.rs"
},
"result": {
"validated_json": {
"actions": [
{
"kind": "EDIT_FILE",
"path": "src/main.rs",
"base_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"edits": [
{
"op": "replace",
"anchor": "fn main",
"before": "println!(\"old\");",
"after": "println!(\"new\");",
"occurrence": 1,
"context_lines": 2
}
]
}
],
"summary": "Edit"
},
"validation_outcome": "ok",
"error_code": "ERR_EDIT_BASE_MISMATCH",
"repair_injected_sha256": true
}
}

View File

@ -0,0 +1,21 @@
{
"protocol": {
"schema_version": 3,
"schema_hash": "10ece82f5e140c9cc5c5f0dd72757fba80c8f86034263d85716ae461c2e679ec"
},
"request": {
"mode": "apply",
"input_chars": 15000,
"token_budget": 4096,
"strict_json": true
},
"context": {},
"result": {
"validated_json": {
"actions": [],
"summary": "NO_CHANGES: ничего менять не требуется."
},
"validation_outcome": "ok",
"error_code": null
}
}

View File

@ -1,92 +1,92 @@
{ {
"name": "papa_yu_response", "name": "papa_yu_response",
"description": "JSON Schema для response_format LLM (OpenAI Responses API, строгий JSON-вывод). Принимает: массив actions ИЛИ объект с полями.", "description": "JSON Schema для response_format LLM (OpenAI Responses API, строгий JSON-вывод). Принимает: массив actions ИЛИ объект с полями.",
"schema": { "schema": {
"oneOf": [ "oneOf": [
{ {
"type": "array", "type": "array",
"description": "Прямой массив действий (обратная совместимость)", "description": "Прямой массив действий (обратная совместимость)",
"items": { "$ref": "#/$defs/action" }, "items": { "$ref": "#/$defs/action" },
"minItems": 0 "minItems": 0
}, },
{ {
"type": "object", "type": "object",
"description": "Объект Fix-plan: actions, summary, context_requests, memory_patch", "description": "Объект Fix-plan: actions, summary, context_requests, memory_patch",
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
"mode": { "mode": {
"type": "string", "type": "string",
"enum": ["fix-plan", "apply"], "enum": ["fix-plan", "apply"],
"description": "Опционально: fix-plan = план без изменений, apply = план с действиями" "description": "Опционально: fix-plan = план без изменений, apply = план с действиями"
}, },
"actions": { "actions": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" }
}, },
"proposed_changes": { "proposed_changes": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
"actions": { "actions": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" }
} }
} }
}, },
"summary": { "type": "string" }, "summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } }, "questions": { "type": "array", "items": { "type": "string" } },
"context_requests": { "context_requests": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/context_request" } "items": { "$ref": "#/$defs/context_request" }
}, },
"plan": { "plan": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "step": { "type": "string" }, "details": { "type": "string" } } "properties": { "step": { "type": "string" }, "details": { "type": "string" } }
} }
}, },
"memory_patch": { "memory_patch": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"description": "Только ключи из whitelist: user.*, project.*" "description": "Только ключи из whitelist: user.*, project.*"
}, },
"risks": { "type": "array", "items": { "type": "string" } } "risks": { "type": "array", "items": { "type": "string" } }
} }
} }
], ],
"$defs": { "$defs": {
"action": { "action": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"required": ["kind", "path"], "required": ["kind", "path"],
"properties": { "properties": {
"kind": { "kind": {
"type": "string", "type": "string",
"enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"] "enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"]
}, },
"path": { "type": "string" }, "path": { "type": "string" },
"content": { "content": {
"type": "string", "type": "string",
"description": "Обязательно для CREATE_FILE и UPDATE_FILE" "description": "Обязательно для CREATE_FILE и UPDATE_FILE"
} }
} }
}, },
"context_request": { "context_request": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"required": ["type"], "required": ["type"],
"properties": { "properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] }, "type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" }, "path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 }, "start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 }, "end_line": { "type": "integer", "minimum": 1 },
"query": { "type": "string" }, "query": { "type": "string" },
"glob": { "type": "string" }, "glob": { "type": "string" },
"source": { "type": "string" }, "source": { "type": "string" },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000 } "last_n": { "type": "integer", "minimum": 1, "maximum": 5000 }
} }
} }
} }
} }
} }

View File

@ -0,0 +1,155 @@
# Заключение по анализу архива papayu-main.zip
**Дата анализа:** 8 февраля 2026
**Источник:** `/Users/yrippertgmail.com/Downloads/papayu-main.zip`
**Коммит в архиве:** db21971761ff9305a92bd365c5f20481d32a8aca
---
## 1. Общая характеристика
Архив содержит **форк/альтернативную версию PAPA YU** с другой архитектурой и набором функций. Это **десктопное приложение Tauri 2 + React**, объединяющее:
- **Ядро PAPA YU** — анализ проектов, preview/apply/undo
- **Модули Mura Menasa ERP** — регламенты, ТМЦ/закупки, финансы, персонал
- **Инфраструктурные страницы** — Policy Engine, Audit Logger, Secrets Guard, Updates, Diagnostics
---
## 2. Структура проекта
| Путь | Назначение |
|------|------------|
| `desktop/` | Tauri + React (основное приложение) |
| `desktop/src-tauri/` | Rust backend (команды, типы) |
| `desktop/ui/` | React UI (Vite, TypeScript, Tailwind) |
| `desktop-core/` | Отдельный слой (Node/TS) — **пустой** |
| `desktop-core/tools/project-auditor/` | `index.ts`**0 байт** (заглушка) |
| `docs/` | CONTRACTS.md, частично повреждённые файлы при распаковке |
---
## 3. Backend (Rust)
### 3.1 Команды Tauri
| Команда | Назначение |
|---------|------------|
| `analyze_project` | Анализ папки, findings, recommendations, actions |
| `preview_actions` | Превью изменений (diff) |
| `apply_actions` | Применение с snapshot и откатом при ошибке |
| `undo_last` | Откат последней сессии |
| `get_app_info` | Версия, app_data_dir |
**Отсутствуют** (по сравнению с papa-yu на Desktop):
`run_batch`, `agentic_run`, `generate_actions_from_report`, `propose_actions`, `redo_last`, `get_folder_links`, `set_folder_links`, `get_project_profile`, `trends`, `weekly_report`, `domain_notes`, `settings_export`, `verify_project`, `auto_check`.
### 3.2 Анализатор (analyze_project.rs)
- **~750 строк** — детальный сканер с `ScanState`
- **Правила:** README, .gitignore, .env, LICENSE, tests/, много файлов в корне, глубокая вложенность, ESLint, Clippy, тип проекта
- **Прогресс:** эмит `analyze_progress` на стадиях
- **Лимиты:** MAX_FILES=50_000, MAX_DURATION_SECS=60
- **Типы:** `AnalyzeReport`, `ProjectContext`, `LlmContext`, `ReportStats`, `Finding`, `Recommendation`
### 3.3 Транзакционность (apply_actions)
- Snapshot перед применением
- `revert_snapshot` при ошибке
- Сессии в `app_data_dir/history/<session_id>`
- `last_session.txt` для undo
**Нет:** auto_check (cargo check / npm run build), лимитов из профиля, user_confirmed, двухстекового undo/redo.
---
## 4. Frontend (React)
### 4.1 Страницы
| Маршрут | Страница | Реализация |
|---------|----------|------------|
| `/tasks` | Tasks | Основной экран — анализ, превью, apply, undo |
| `/reglamenty` | Reglamenty | Регламенты (АРМАК, ФАА, ЕАСА) |
| `/tmc-zakupki` | TMCZakupki | ТМЦ и закупки |
| `/finances` | Finances | Финансы |
| `/personnel` | Personnel | Персонал |
| `/control-panel` | Dashboard | Панель управления |
| `/policies` | PolicyEngine | Движок политик |
| `/audit` | AuditLogger | Журнал аудита |
| `/secrets` | SecretsGuard | Защита секретов |
| `/updates` | Updates | Обновления (tauri-plugin-updater) |
| `/diagnostics` | Diagnostics | Версии, пути, логи |
### 4.2 Стек
- React 19, Vite 7, TypeScript 5.9
- Tailwind CSS, anime.js, lucide-react, zustand
- tauri-plugin-dialog, tauri-plugin-updater, tauri-plugin-process
### 4.3 Tasks.tsx
- **~38 000 строк** (очень большой файл)
- Чат, история, выбор папки, анализ, превью, apply, undo
- Поле «Чат с агентом» — заглушка: «Ответ ИИ агента будет отображаться здесь»
---
## 5. CI/CD
- **ci.yml:** lint (ESLint), TypeScript check, `cargo check`
- **Нет:** `cargo test`, `cargo clippy`, `cargo fmt`, `cargo audit`
- **release.yml:** сборка релизов по тегам `v*`
---
## 6. Сравнение с papa-yu (Desktop)
| Аспект | papayu-main | papa-yu (Desktop) |
|--------|-------------|-------------------|
| Структура | desktop/ + desktop-core/ | src/ + src-tauri/ (единая папка) |
| Команды Rust | 5 | 20+ |
| Agentic run | ❌ | ✅ |
| LLM planner | ❌ | ✅ |
| Undo/Redo | 1 шаг | Двухстековый |
| AutoCheck | ❌ | ✅ (cargo check, npm build) |
| Профиль проекта | Базовый | Детальный (лимиты, goal_template) |
| Online Research | ❌ | ✅ (Tavily) |
| Domain notes | ❌ | ✅ |
| Trends | ❌ | ✅ |
| ERP-страницы | ✅ (заглушки) | ❌ |
| Plugin updater | ✅ | ❌ |
| CI | lint + check | fmt + clippy + test + audit + frontend build |
---
## 7. Выводы
### 7.1 Сильные стороны архива
1. **Широкая оболочка** — маршруты для ERP (Регламенты, ТМЦ, Финансы, Персонал) и инфраструктуры (Audit, Secrets, Diagnostics, Updates).
2. **Архитектура** — CONTRACTS.md фиксирует контракты UI ↔ Tauri.
3. **Транзакционность** — snapshot + revert при ошибке apply.
4. **Прогресс** — эмит событий на стадиях анализа.
5. **Современный стек** — React 19, Vite 7, Tauri 2.9.
### 7.2 Слабые стороны и риски
1. **desktop-core пустой**`project-auditor/index.ts` = 0 байт, слой не реализован.
2. **ERP-страницы** — скорее заглушки, реальной логики (БД, API) нет.
3. **Chat Agent** — заглушка, ИИ не подключён.
4. **CI** — нет тестов, clippy, audit, что снижает надёжность.
5. **Tasks.tsx** — 38k строк, монолитный, сложно поддерживать.
6. **Нет LLM/агента** — в отличие от papa-yu, нет propose_actions, agentic_run.
### 7.3 Рекомендация
Архив **papayu-main** — это **более ранняя/параллельная ветка** с акцентом на ERP-оболочку и минимальный набор команд анализа. Для **продуктового PAPA YU** (анализ + автоисправления + agentic run) **текущая papa-yu** (Desktop) значительно функциональнее.
При необходимости объединения:
- взять из papayu-main: структуру маршрутов ERP, CONTRACTS.md, tauri-plugin-updater;
- сохранить из papa-yu: agentic_run, LLM planner, AutoCheck, undo/redo стек, domain notes, trends.
---
*Документ создан по результатам анализа архива papayu-main.zip.*

View File

@ -0,0 +1,385 @@
# papa-yu — Полномасштабная презентация программы
**Версия:** 2.4.5
**Дата:** 2025-01-31
**Статус:** Investment-ready (~87% DD score)
---
# Часть 1. ОБЗОР
---
## Слайд 1. Что такое papa-yu
**papa-yu** — десктопное приложение для **анализа проектов** и **автоматических исправлений** с использованием LLM.
| Характеристика | Значение |
|----------------|----------|
| **Тип** | Desktop (Tauri + Rust) |
| **Назначение** | LLM-оркестрация: анализ, план, применение правок |
| **Фокус** | Детерминизм, безопасность, управляемость |
| **Пользователь** | Разработчик / tech lead, работающий с локальными проектами |
---
## Слайд 2. Ключевая ценность
> **Продукт превращает «хочу исправить» в структурированные, проверяемые и откатываемые действия.**
- **Анализ** — поиск проблем (README, .gitignore, тесты, структура)
- **План** — LLM или эвристика предлагают конкретные правки
- **Превью** — пользователь видит diff до применения
- **Apply** — транзакционное применение с auto-check и откатом при ошибке
- **Undo/Redo** — полный контроль над изменениями
---
## Слайд 3. Текущий статус
| Параметр | Статус |
|----------|--------|
| **Due Diligence** | ~87% (investment-ready) |
| **Архитектура** | Документирована, ADR зафиксированы |
| **Границы продукта** | LIMITS.md, Critical failures |
| **Операционная готовность** | RUNBOOK, INCIDENTS |
| **Готовность к передаче** | Высокая |
---
# Часть 2. ПРОДУКТ
---
## Слайд 4. Основные сценарии
1. **Анализ по пути** — выбор папки → отчёт (findings, recommendations, actions)
2. **Предложить исправления** — план через LLM или эвристику → превью → применение
3. **Batch** — анализ → превью → apply в одной команде
4. **Agentic run** — цикл: план → apply → проверка → откат при неудаче
5. **Online research** — поиск (Tavily) → summarize → «Save as Project Note»
6. **Weekly report** — агрегация traces, LLM proposals, метрики v3
---
## Слайд 5. Что продукт НЕ делает (LIMITS.md)
| Область | Ограничение |
|---------|-------------|
| **Real-time** | Операции занимают секунды |
| **Concurrency** | Один активный контекст |
| **Plugins** | Нет sandbox для произвольного кода |
| **Auth** | SSO / RBAC не в scope |
| **Remote** | Прямая работа с удалёнными репозиториями — unsupported |
---
## Слайд 6. Critical failures
| Событие | Impact |
|---------|--------|
| Corrupted workspace | Потеря файлов при сбое apply + отката |
| Silent data loss (EDIT_FILE) | Некорректная замена без явной ошибки |
| Network outside allowlist | SSRF, утечка данных |
| Secrets in trace | Утечка ключей в логах |
**Риски названы и управляемы.**
---
# Часть 3. АРХИТЕКТУРА
---
## Слайд 7. High-level
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ UI │────▶│ Tauri commands │────▶│ Domain │
│ (React) │ │ (boundary) │ │ logic │
└─────────────┘ └──────────────────┘ └──────┬──────┘
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Adapters │◀────│ fs / net │◀────│ llm_ │
│ (IO) │ │ (centralized) │ │ planner │
└─────────────┘ └──────────────────┘ └─────────────┘
```
**Принцип:** UI никогда не выполняет fs/network напрямую.
---
## Слайд 8. Модули
| Модуль | Роль |
|--------|------|
| **net** | Единая точка сетевого доступа, SSRF-защита |
| **llm_planner** | Планирование, оркестрация, контекст |
| **online_research** | Внешние данные через net::fetch_url_safe |
| **commands/** | Граница Tauri, валидация ввода |
| **tx/** | Транзакции, undo/redo, снимки |
---
## Слайд 9. Протоколы v1 / v2 / v3
| Версия | Действия | Особенности |
|--------|----------|-------------|
| v1 | CREATE, UPDATE, DELETE | Простой контент |
| v2 | + PATCH_FILE (unified diff) | base_sha256 |
| v3 | + EDIT_FILE (anchor/before/after) | Структурированные правки, repair-first |
**Golden traces** фиксируют поведение для регрессий.
---
## Слайд 10. ADR — ключевые решения
| ADR | Тема | Суть |
|-----|------|------|
| ADR-001 | Tauri | Меньше attack surface, контроль IO, производительность |
| ADR-002 | EDIT_FILE v3 | Детерминизм, golden traces, протокол версионирован |
| ADR-003 | SSRF | Вся сеть через net, allowlist, лимиты размера/таймаута |
---
# Часть 4. КАЧЕСТВО И ТЕСТЫ
---
## Слайд 11. Тестирование
- **>100** автоматических тестов (Rust)
- **Golden traces** v1, v2, v3 — фиксация observable behavior
- **CI** — обязательный gate перед merge
- **Регрессии** — изменения без обновления тестов невозможны
---
## Слайд 12. CI/CD
| Этап | Команда |
|------|---------|
| Форматирование | `cargo fmt --check` |
| Линтинг | `cargo clippy` |
| Безопасность | `cargo audit` |
| Тесты | `cargo test` (включая golden_traces) |
Воспроизводимая сборка из чистого checkout.
---
# Часть 5. БЕЗОПАСНОСТЬ
---
## Слайд 13. Security posture
| Контроль | Реализация |
|----------|------------|
| **Сеть** | net::fetch_url_safe, SSRF mitigations |
| **Схемы** | http/https only |
| **IP** | Запрет private/loopback |
| **Размер** | 1 MB limit |
| **Таймаут** | 15 s |
| **Секреты** | Не в репозитории |
| **Зависимости** | cargo audit в CI |
**Scope:** design & code level (без pentest).
---
## Слайд 14. Protected paths
- `.git`, `node_modules`, `target`, `dist`, vendor
- Бинарные файлы — запрещены
- Только текстовые расширения (.rs, .ts, .py, .json, .toml, …)
- Allowlist команд для verify/auto_check
---
# Часть 6. ОПЕРАЦИИ
---
## Слайд 15. Build & Run
```bash
# Development
npm install && npm run tauri dev
# Production build
npm run tauri build
```
Требования: Node.js 18+, Rust 1.70+, npm.
---
## Слайд 16. Типовые проблемы (RUNBOOK)
| Проблема | Решение |
|----------|---------|
| Golden traces mismatch | Пересчитать schema_hash, обновить фикстуры |
| LLM planner нестабилен | PAPAYU_LLM_STRICT_JSON=1, уменьшить контекст |
| ERR_EDIT_* | См. EDIT_FILE_DEBUG.md, проверить anchor/before |
| Пустое окно | Запускать только `npm run tauri dev` |
---
## Слайд 17. INCIDENTS.md
- Шаблон формата инцидентов
- Известные «больные места»:
- llm_planner чувствителен к промптам
- PATCH/EDIT — сложность anchor/before
- Golden traces — schema_hash при смене схемы
---
# Часть 7. РИСКИ И ROADMAP
---
## Слайд 18. Известные риски
| Риск | Управление |
|------|------------|
| Чувствительность LLM к вводу | repair retry, fallback v3→v2 |
| Жёсткость PATCH/EDIT | Документировано, golden traces |
| Desktop-only | Явно в LIMITS, не сервер |
**Техдолг зафиксирован. Нет зон «не трогать».**
---
## Слайд 19. Roadmap
| Горизонт | Задачи |
|----------|--------|
| **Short** | cargo deny, LICENSES.md |
| **Mid** | Снижение bus-factor, расширение покрытия |
| **Long** | Новые протоколы, research adapters |
---
# Часть 8. ИНВЕСТИЦИОННАЯ ГОТОВНОСТЬ
---
## Слайд 20. Due Diligence Score
| Раздел | Оценка |
|--------|--------|
| A. Продукт | 4/4 |
| B. Архитектура | 4/4 |
| D. Тестирование | 4/4 |
| E. CI/CD | 4/4 |
| F. Security | 4/4 |
| H. Эксплуатация | 4/4 |
| I. Bus-factor | 3/3 |
| **Итого** | **~87%** |
---
## Слайд 21. Green flags (BUYER_RED_GREEN_FLAGS)
- 📗 Документация объясняет решения
- 🧠 Техдолг зафиксирован
- 🔐 Security на уровне дизайна
- 🧪 Тесты ловят регрессии
- 🔁 CI гарантирует воспроизводимость
- 📉 Риски названы прямо
---
## Слайд 22. Почему это актив, а не код
- Риски названы
- Поведение детерминировано (golden traces)
- Качество проверяется автоматически (CI)
- Знания зафиксированы (ADR, RUNBOOK)
**Снижает uncertainty — главный дисконт на сделках.**
---
# Часть 9. DATA ROOM И WALKTHROUGH
---
## Слайд 23. Структура Data Room (из Buyer.docx)
```
00_READ_ME_FIRST/ — Overview, 5 минут на понимание
01_PRODUCT/ — Назначение, LIMITS, Critical failures
02_ARCHITECTURE/ — Схема, ADR
03_CODEBASE/ — Репозиторий, BUILD_AND_RUN
04_QUALITY_AND_TESTS/ — Тесты, CI
05_SECURITY/ — SSRF, зависимости
06_OPERATIONS/ — RUNBOOK, INCIDENTS
07_RISKS_AND_DEBT/ — Риски, техдолг
08_ROADMAP/ — План развития
09_INVESTMENT/ — TECH_MEMO, DD Assessment
10_LEGAL_AND_MISC/ — Лицензии, ownership
```
---
## Слайд 24. Buyer Walkthrough (1520 мин)
| Время | Тема |
|-------|------|
| 03 мин | Контекст: desktop, Rust/Tauri, LLM-оркестрация, фокус на детерминизме |
| 36 мин | Почему актив: golden traces, CI, риски задокументированы |
| 610 мин | Архитектура: IO централизован, SSRF, ADR |
| 1013 мин | Риски: жёсткость PATCH/EDIT, desktop, LLM — осознаны и управляемы |
| 1316 мин | Передача: 35 дней до первого изменения, extension points |
| 1620 мин | Вопросы — объяснять, не защищаться |
---
## Слайд 25. Финальный месседж
> **«Это не идеальный код. Но это понятный, управляемый, передаваемый актив.»**
Проект готов к:
- передаче владельца
- продаже
- due diligence
- масштабированию команды
**Цена определяется рынком, а не страхами.**
---
# Приложения
---
## A. Ссылки на документы
| Документ | Путь |
|----------|------|
| README | `README.md` |
| LIMITS | `docs/LIMITS.md` |
| ARCHITECTURE | `docs/ARCHITECTURE.md` |
| RUNBOOK | `docs/RUNBOOK.md` |
| ADR | `docs/adr/` |
| TECH_MEMO | `docs/TECH_MEMO_FOR_INVESTORS.md` |
| BUYER_QA | `docs/BUYER_QA.md` |
| Investment Report | `docs/INVESTMENT_READY_REPORT.md` |
---
## B. Ключевые env-переменные
| Переменная | Назначение |
|------------|------------|
| PAPAYU_LLM_API_URL | API для LLM |
| PAPAYU_LLM_API_KEY | Ключ (OpenAI) |
| PAPAYU_PROTOCOL_VERSION | 1/2/3 |
| PAPAYU_ONLINE_RESEARCH | 1 = включить Tavily |
| PAPAYU_TAVILY_API_KEY | Tavily API |
| PAPAYU_TRACE | 1 = сохранять traces |

View File

@ -1,11 +1,37 @@
# Скопируйте этот файл в .env.openai и подставьте свой ключ OpenAI. # Скопируйте этот файл в .env.openai и подставьте свой ключ.
# Команда: cp env.openai.example .env.openai # Команда: cp env.openai.example .env.openai
# Затем откройте .env.openai и замените your-openai-key-here на ваш ключ. # Затем откройте .env.openai и замените ключ на ваш.
# --- OpenAI ---
PAPAYU_LLM_API_URL=https://api.openai.com/v1/chat/completions PAPAYU_LLM_API_URL=https://api.openai.com/v1/chat/completions
PAPAYU_LLM_API_KEY=your-openai-key-here PAPAYU_LLM_API_KEY=your-openai-key-here
PAPAYU_LLM_MODEL=gpt-4o-mini PAPAYU_LLM_MODEL=gpt-4o-mini
# --- Claude через OpenRouter (синхронизация с Claude Code / Cursor) ---
# PAPAYU_LLM_API_URL=https://openrouter.ai/api/v1/chat/completions
# PAPAYU_LLM_API_KEY=sk-or-v1-ваш-ключ-openrouter
# PAPAYU_LLM_MODEL=anthropic/claude-3.5-sonnet
# --- Мульти-провайдер: сбор планов от нескольких ИИ (Claude, OpenAI и др.), один оптимальный план ---
# PAPAYU_LLM_PROVIDERS — JSON-массив: [ {"url":"...", "model":"...", "api_key":"..."}, ... ]
# PAPAYU_LLM_PROVIDERS='[{"url":"https://openrouter.ai/api/v1/chat/completions","model":"anthropic/claude-3.5-sonnet","api_key":"sk-or-v1-..."},{"url":"https://api.openai.com/v1/chat/completions","model":"gpt-4o-mini","api_key":"sk-..."}]'
# Опционально: ИИ-агрегатор для слияния планов в один (иначе объединение в Rust).
# PAPAYU_LLM_AGGREGATOR_URL=https://api.openai.com/v1/chat/completions
# PAPAYU_LLM_AGGREGATOR_KEY=sk-...
# PAPAYU_LLM_AGGREGATOR_MODEL=gpt-4o-mini
# --- Синхронизация с агентом: запись .papa-yu/agent-sync.json после анализа ---
# PAPAYU_AGENT_SYNC=1
# --- Snyk Code: дополнение анализа кода (результаты в agent-sync.json, поле snyk_findings) ---
# PAPAYU_SNYK_SYNC=1
# PAPAYU_SNYK_TOKEN=ваш-токен-snyk
# PAPAYU_SNYK_ORG_ID=uuid-организации
# PAPAYU_SNYK_PROJECT_ID=uuid-проекта # опционально
# --- Documatic / архитектура: описание в .papa-yu/architecture.md (или PAPAYU_DOCUMATIC_ARCH_PATH) → agent-sync architecture_summary ---
# PAPAYU_DOCUMATIC_ARCH_PATH=docs/architecture.md # по умолчанию .papa-yu/architecture.md
# Строгий JSON (OpenAI Structured Outputs): добавляет response_format с JSON Schema. # Строгий JSON (OpenAI Structured Outputs): добавляет response_format с JSON Schema.
# Работает с OpenAI; Ollama и др. могут не поддерживать — не задавать или =0. # Работает с OpenAI; Ollama и др. могут не поддерживать — не задавать или =0.
# PAPAYU_LLM_STRICT_JSON=1 # PAPAYU_LLM_STRICT_JSON=1

5240
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "papa-yu", "name": "papa-yu",
"version": "2.4.4", "version": "2.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -14,6 +14,8 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "papa-yu" name = "papa-yu"
version = "2.4.4" version = "2.4.5"
default-run = "papa-yu" default-run = "papa-yu"
edition = "2021" edition = "2021"
description = "PAPA YU — анализ и исправление проектов" description = "PAPA YU — анализ и исправление проектов"
@ -16,6 +16,8 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
@ -28,6 +30,7 @@ hex = "0.4"
diffy = "0.4" diffy = "0.4"
url = "2" url = "2"
scraper = "0.20" scraper = "0.20"
futures = "0.3"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@ -0,0 +1,67 @@
{
"identifier": "personal-automation",
"description": "Личное использование: терминал (git, npm, cargo) и открытие ссылок в браузере. Команды ограничены allowlist.",
"windows": ["main"],
"permissions": [
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "open-url",
"cmd": "open",
"args": [{ "validator": "^https?://[^\\s]+$" }]
},
{
"name": "xdg-open-url",
"cmd": "xdg-open",
"args": [{ "validator": "^https?://[^\\s]+$" }]
},
{
"name": "start-url",
"cmd": "cmd",
"args": ["/c", "start", "", { "validator": "^https?://[^\\s]+$" }]
},
{
"name": "git",
"cmd": "git",
"args": [
"status",
"pull",
"push",
"add",
"commit",
"checkout",
"branch",
"log",
"diff",
"clone",
"fetch",
"merge",
{ "validator": "^https?://[^\\s]+$" },
{ "validator": "^[a-zA-Z0-9/_.-]+$" }
]
},
{
"name": "npm",
"cmd": "npm",
"args": ["install", "run", "ci", "test", "build", "start", "exec", "update", { "validator": "^[a-zA-Z0-9/_.-]+$" }]
},
{
"name": "npx",
"cmd": "npx",
"args": ["-y", "create-", "run", "exec", { "validator": "^[a-zA-Z0-9/_.@-]+$" }]
},
{
"name": "cargo",
"cmd": "cargo",
"args": ["build", "test", "run", "check", "clippy", "fmt", "install", { "validator": "^[a-zA-Z0-9/_.-]+$" }]
},
{
"name": "python3",
"cmd": "python3",
"args": ["-m", "pytest", "pip", "install", "-q", "-e", { "validator": "^[a-zA-Z0-9/_.-]+$" }]
}
]
}
]
}

View File

@ -0,0 +1,12 @@
{
"x_schema_version": 1,
"type": "object",
"additionalProperties": false,
"required": ["topic", "tags", "content_md", "confidence"],
"properties": {
"topic": { "type": "string" },
"tags": { "type": "array", "maxItems": 8, "items": { "type": "string" } },
"content_md": { "type": "string" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
}

View File

@ -1,27 +1,27 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"x_schema_version": 1, "x_schema_version": 1,
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["answer_md", "confidence", "sources"], "required": ["answer_md", "confidence", "sources"],
"properties": { "properties": {
"answer_md": { "type": "string" }, "answer_md": { "type": "string" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }, "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"sources": { "sources": {
"type": "array", "type": "array",
"maxItems": 10, "maxItems": 10,
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["url", "title"], "required": ["url", "title"],
"properties": { "properties": {
"url": { "type": "string" }, "url": { "type": "string" },
"title": { "type": "string" }, "title": { "type": "string" },
"published_at": { "type": "string" }, "published_at": { "type": "string" },
"snippet": { "type": "string" } "snippet": { "type": "string" }
} }
} }
}, },
"notes": { "type": "string" } "notes": { "type": "string" }
} }
} }

View File

@ -1,77 +1,77 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"x_schema_version": 1, "x_schema_version": 1,
"oneOf": [ "oneOf": [
{ {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" }, "items": { "$ref": "#/$defs/action" },
"minItems": 0 "minItems": 0
}, },
{ {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
"mode": { "type": "string", "enum": ["fix-plan", "apply"] }, "mode": { "type": "string", "enum": ["fix-plan", "apply"] },
"actions": { "actions": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" }
}, },
"proposed_changes": { "proposed_changes": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
"actions": { "actions": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" }
} }
} }
}, },
"summary": { "type": "string" }, "summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } }, "questions": { "type": "array", "items": { "type": "string" } },
"context_requests": { "context_requests": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/context_request" } "items": { "$ref": "#/$defs/context_request" }
}, },
"plan": { "plan": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "step": { "type": "string" }, "details": { "type": "string" } } "properties": { "step": { "type": "string" }, "details": { "type": "string" } }
} }
}, },
"memory_patch": { "type": "object", "additionalProperties": true }, "memory_patch": { "type": "object", "additionalProperties": true },
"risks": { "type": "array", "items": { "type": "string" } } "risks": { "type": "array", "items": { "type": "string" } }
} }
} }
], ],
"$defs": { "$defs": {
"action": { "action": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"required": ["kind", "path"], "required": ["kind", "path"],
"properties": { "properties": {
"kind": { "kind": {
"type": "string", "type": "string",
"enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"] "enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"]
}, },
"path": { "type": "string" }, "path": { "type": "string" },
"content": { "type": "string" } "content": { "type": "string" }
} }
}, },
"context_request": { "context_request": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"required": ["type"], "required": ["type"],
"properties": { "properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] }, "type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" }, "path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 }, "start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 }, "end_line": { "type": "integer", "minimum": 1 },
"query": { "type": "string" }, "query": { "type": "string" },
"glob": { "type": "string" }, "glob": { "type": "string" },
"source": { "type": "string" }, "source": { "type": "string" },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000 } "last_n": { "type": "integer", "minimum": 1, "maximum": 5000 }
} }
} }
} }
} }

View File

@ -1,152 +1,152 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"x_schema_version": 2, "x_schema_version": 2,
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["actions"], "required": ["actions"],
"properties": { "properties": {
"actions": { "actions": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/action" }, "items": { "$ref": "#/$defs/action" },
"maxItems": 200 "maxItems": 200
}, },
"summary": { "type": "string" }, "summary": { "type": "string" },
"context_requests": { "context_requests": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/context_request" } "items": { "$ref": "#/$defs/context_request" }
}, },
"memory_patch": { "$ref": "#/$defs/memory_patch" } "memory_patch": { "$ref": "#/$defs/memory_patch" }
}, },
"$defs": { "$defs": {
"action": { "action": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["kind", "path"], "required": ["kind", "path"],
"properties": { "properties": {
"kind": { "kind": {
"type": "string", "type": "string",
"enum": [ "enum": [
"CREATE_FILE", "CREATE_FILE",
"CREATE_DIR", "CREATE_DIR",
"UPDATE_FILE", "UPDATE_FILE",
"PATCH_FILE", "PATCH_FILE",
"DELETE_FILE", "DELETE_FILE",
"DELETE_DIR" "DELETE_DIR"
] ]
}, },
"path": { "type": "string" }, "path": { "type": "string" },
"content": { "type": "string" }, "content": { "type": "string" },
"patch": { "type": "string" }, "patch": { "type": "string" },
"base_sha256": { "base_sha256": {
"type": "string", "type": "string",
"pattern": "^[a-f0-9]{64}$" "pattern": "^[a-f0-9]{64}$"
} }
}, },
"allOf": [ "allOf": [
{ {
"if": { "properties": { "kind": { "const": "CREATE_DIR" } } }, "if": { "properties": { "kind": { "const": "CREATE_DIR" } } },
"then": { "then": {
"not": { "not": {
"anyOf": [ "anyOf": [
{ "required": ["content"] }, { "required": ["content"] },
{ "required": ["patch"] }, { "required": ["patch"] },
{ "required": ["base_sha256"] } { "required": ["base_sha256"] }
] ]
} }
} }
}, },
{ {
"if": { "properties": { "kind": { "const": "DELETE_DIR" } } }, "if": { "properties": { "kind": { "const": "DELETE_DIR" } } },
"then": { "then": {
"not": { "not": {
"anyOf": [ "anyOf": [
{ "required": ["content"] }, { "required": ["content"] },
{ "required": ["patch"] }, { "required": ["patch"] },
{ "required": ["base_sha256"] } { "required": ["base_sha256"] }
] ]
} }
} }
}, },
{ {
"if": { "properties": { "kind": { "const": "DELETE_FILE" } } }, "if": { "properties": { "kind": { "const": "DELETE_FILE" } } },
"then": { "then": {
"not": { "not": {
"anyOf": [ "anyOf": [
{ "required": ["content"] }, { "required": ["content"] },
{ "required": ["patch"] }, { "required": ["patch"] },
{ "required": ["base_sha256"] } { "required": ["base_sha256"] }
] ]
} }
} }
}, },
{ {
"if": { "properties": { "kind": { "enum": ["CREATE_FILE", "UPDATE_FILE"] } } }, "if": { "properties": { "kind": { "enum": ["CREATE_FILE", "UPDATE_FILE"] } } },
"then": { "then": {
"required": ["content"], "required": ["content"],
"not": { "not": {
"anyOf": [ "anyOf": [
{ "required": ["patch"] }, { "required": ["patch"] },
{ "required": ["base_sha256"] } { "required": ["base_sha256"] }
] ]
} }
} }
}, },
{ {
"if": { "properties": { "kind": { "const": "PATCH_FILE" } } }, "if": { "properties": { "kind": { "const": "PATCH_FILE" } } },
"then": { "then": {
"required": ["patch", "base_sha256"], "required": ["patch", "base_sha256"],
"not": { "anyOf": [{ "required": ["content"] }] } "not": { "anyOf": [{ "required": ["content"] }] }
} }
} }
] ]
}, },
"context_request": { "context_request": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["type"], "required": ["type"],
"properties": { "properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] }, "type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" }, "path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 }, "start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 }, "end_line": { "type": "integer", "minimum": 1 },
"glob": { "type": "string" }, "glob": { "type": "string" },
"query": { "type": "string" }, "query": { "type": "string" },
"source": { "type": "string" }, "source": { "type": "string" },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000 } "last_n": { "type": "integer", "minimum": 1, "maximum": 5000 }
}, },
"allOf": [ "allOf": [
{ {
"if": { "properties": { "type": { "const": "read_file" } } }, "if": { "properties": { "type": { "const": "read_file" } } },
"then": { "required": ["path"] } "then": { "required": ["path"] }
}, },
{ {
"if": { "properties": { "type": { "const": "search" } } }, "if": { "properties": { "type": { "const": "search" } } },
"then": { "required": ["query"] } "then": { "required": ["query"] }
}, },
{ {
"if": { "properties": { "type": { "const": "logs" } } }, "if": { "properties": { "type": { "const": "logs" } } },
"then": { "required": ["source"] } "then": { "required": ["source"] }
} }
] ]
}, },
"memory_patch": { "memory_patch": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"user.preferred_style": { "type": "string" }, "user.preferred_style": { "type": "string" },
"user.ask_budget": { "type": "integer" }, "user.ask_budget": { "type": "integer" },
"user.risk_tolerance": { "type": "string" }, "user.risk_tolerance": { "type": "string" },
"user.default_language": { "type": "string" }, "user.default_language": { "type": "string" },
"user.output_format": { "type": "string" }, "user.output_format": { "type": "string" },
"project.default_test_command": { "type": "string" }, "project.default_test_command": { "type": "string" },
"project.default_lint_command": { "type": "string" }, "project.default_lint_command": { "type": "string" },
"project.default_format_command": { "type": "string" }, "project.default_format_command": { "type": "string" },
"project.package_manager": { "type": "string" }, "project.package_manager": { "type": "string" },
"project.build_command": { "type": "string" }, "project.build_command": { "type": "string" },
"project.src_roots": { "type": "array", "items": { "type": "string" } }, "project.src_roots": { "type": "array", "items": { "type": "string" } },
"project.test_roots": { "type": "array", "items": { "type": "string" } }, "project.test_roots": { "type": "array", "items": { "type": "string" } },
"project.ci_notes": { "type": "string" } "project.ci_notes": { "type": "string" }
} }
} }
} }
} }

View File

@ -0,0 +1,236 @@
{
"x_schema_version": 3,
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "papa-yu llm plan response schema v3",
"oneOf": [
{
"type": "array",
"items": { "$ref": "#/$defs/action" },
"maxItems": 200
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" },
"maxItems": 200
},
"proposed_changes": {
"type": "object",
"additionalProperties": false,
"properties": {
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" },
"maxItems": 200
}
}
},
"summary": {
"type": "string",
"maxLength": 8000
},
"memory_patch": {
"type": "object",
"additionalProperties": false,
"properties": {
"user.preferred_style": { "type": "string", "maxLength": 64 },
"user.ask_budget": { "type": "string", "maxLength": 64 },
"user.risk_tolerance": { "type": "string", "maxLength": 64 },
"user.default_language": { "type": "string", "maxLength": 32 },
"user.output_format": { "type": "string", "maxLength": 32 },
"project.default_test_command": { "type": "string", "maxLength": 256 },
"project.default_lint_command": { "type": "string", "maxLength": 256 },
"project.default_format_command": { "type": "string", "maxLength": 256 },
"project.package_manager": { "type": "string", "maxLength": 64 },
"project.build_command": { "type": "string", "maxLength": 256 },
"project.src_roots": {
"type": "array",
"items": { "type": "string", "maxLength": 256 },
"maxItems": 32
},
"project.test_roots": {
"type": "array",
"items": { "type": "string", "maxLength": 256 },
"maxItems": 32
},
"project.ci_notes": { "type": "string", "maxLength": 2000 }
}
},
"context_requests": {
"type": "array",
"items": { "$ref": "#/$defs/context_request" },
"maxItems": 64
}
},
"anyOf": [
{ "required": ["actions"] },
{ "required": ["proposed_changes"] }
]
}
],
"$defs": {
"action": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "path"],
"properties": {
"kind": {
"type": "string",
"enum": [
"CREATE_FILE",
"CREATE_DIR",
"UPDATE_FILE",
"DELETE_FILE",
"DELETE_DIR",
"PATCH_FILE",
"EDIT_FILE"
]
},
"path": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"content": {
"type": "string",
"maxLength": 1200000
},
"patch": {
"type": "string",
"maxLength": 1200000
},
"base_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"edits": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": { "$ref": "#/$defs/edit_op" }
}
},
"allOf": [
{
"if": { "properties": { "kind": { "const": "CREATE_DIR" } } },
"then": {
"not": {
"anyOf": [
{ "required": ["content"] },
{ "required": ["patch"] },
{ "required": ["base_sha256"] },
{ "required": ["edits"] }
]
}
}
},
{
"if": { "properties": { "kind": { "const": "CREATE_FILE" } } },
"then": { "required": ["content"] }
},
{
"if": { "properties": { "kind": { "const": "UPDATE_FILE" } } },
"then": { "required": ["content"] }
},
{
"if": { "properties": { "kind": { "const": "DELETE_FILE" } } },
"then": {
"not": {
"anyOf": [
{ "required": ["content"] },
{ "required": ["patch"] },
{ "required": ["base_sha256"] },
{ "required": ["edits"] }
]
}
}
},
{
"if": { "properties": { "kind": { "const": "DELETE_DIR" } } },
"then": {
"not": {
"anyOf": [
{ "required": ["content"] },
{ "required": ["patch"] },
{ "required": ["base_sha256"] },
{ "required": ["edits"] }
]
}
}
},
{
"if": { "properties": { "kind": { "const": "PATCH_FILE" } } },
"then": {
"required": ["patch", "base_sha256"],
"not": {
"anyOf": [
{ "required": ["content"] },
{ "required": ["edits"] }
]
}
}
},
{
"if": { "properties": { "kind": { "const": "EDIT_FILE" } } },
"then": {
"required": ["base_sha256", "edits"],
"not": {
"anyOf": [
{ "required": ["content"] },
{ "required": ["patch"] }
]
}
}
}
]
},
"edit_op": {
"type": "object",
"additionalProperties": false,
"required": ["op", "anchor", "before", "after"],
"properties": {
"op": { "type": "string", "enum": ["replace"] },
"anchor": { "type": "string", "minLength": 1, "maxLength": 256 },
"before": { "type": "string", "minLength": 1, "maxLength": 50000 },
"after": { "type": "string", "minLength": 0, "maxLength": 50000 },
"occurrence": { "type": "integer", "minimum": 1, "maximum": 1000 },
"context_lines": { "type": "integer", "minimum": 0, "maximum": 3 }
}
},
"context_request": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["read_file", "search", "logs", "env"]
},
"path": { "type": "string", "maxLength": 240 },
"start_line": { "type": "integer", "minimum": 1, "maximum": 2000000 },
"end_line": { "type": "integer", "minimum": 1, "maximum": 2000000 },
"query": { "type": "string", "maxLength": 2000 },
"glob": { "type": "string", "maxLength": 512 },
"source": { "type": "string", "maxLength": 64 },
"last_n": { "type": "integer", "minimum": 1, "maximum": 500000 }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "read_file" } } },
"then": { "required": ["path"] }
},
{
"if": { "properties": { "type": { "const": "search" } } },
"then": { "required": ["query"] }
},
{
"if": { "properties": { "type": { "const": "logs" } } },
"then": { "required": ["source"] }
}
]
}
}
}

View File

@ -1,73 +1,91 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"x_schema_version": 1, "x_schema_version": 1,
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["title", "period", "summary_md", "kpis", "findings", "recommendations", "operator_actions"], "required": ["title", "period", "summary_md", "kpis", "findings", "recommendations", "operator_actions"],
"properties": { "properties": {
"title": { "type": "string" }, "title": { "type": "string" },
"period": { "period": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["from", "to"], "required": ["from", "to"],
"properties": { "properties": {
"from": { "type": "string" }, "from": { "type": "string" },
"to": { "type": "string" } "to": { "type": "string" }
} }
}, },
"summary_md": { "type": "string" }, "summary_md": { "type": "string" },
"kpis": { "kpis": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["apply_count", "fallback_count", "fallback_rate", "fallback_rate_excluding_non_utf8", "repair_success_rate", "sha_injection_rate"], "required": ["apply_count", "fallback_count", "fallback_rate", "fallback_rate_excluding_non_utf8", "repair_success_rate", "sha_injection_rate"],
"properties": { "properties": {
"apply_count": { "type": "integer", "minimum": 0 }, "apply_count": { "type": "integer", "minimum": 0 },
"fallback_count": { "type": "integer", "minimum": 0 }, "fallback_count": { "type": "integer", "minimum": 0 },
"fallback_rate": { "type": "number", "minimum": 0, "maximum": 1 }, "fallback_rate": { "type": "number", "minimum": 0, "maximum": 1 },
"fallback_rate_excluding_non_utf8": { "type": "number", "minimum": 0, "maximum": 1 }, "fallback_rate_excluding_non_utf8": { "type": "number", "minimum": 0, "maximum": 1 },
"repair_success_rate": { "type": "number", "minimum": 0, "maximum": 1 }, "repair_success_rate": { "type": "number", "minimum": 0, "maximum": 1 },
"sha_injection_rate": { "type": "number", "minimum": 0, "maximum": 1 } "sha_injection_rate": { "type": "number", "minimum": 0, "maximum": 1 }
} }
}, },
"findings": { "findings": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["severity", "title", "evidence"], "required": ["severity", "title", "evidence"],
"properties": { "properties": {
"severity": { "type": "string", "enum": ["info", "warning", "critical"] }, "severity": { "type": "string", "enum": ["info", "warning", "critical"] },
"title": { "type": "string" }, "title": { "type": "string" },
"evidence": { "type": "string" } "evidence": { "type": "string" }
} }
} }
}, },
"recommendations": { "recommendations": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["priority", "title", "rationale", "expected_impact"], "required": ["priority", "title", "rationale", "expected_impact"],
"properties": { "properties": {
"priority": { "type": "string", "enum": ["p0", "p1", "p2"] }, "priority": { "type": "string", "enum": ["p0", "p1", "p2"] },
"title": { "type": "string" }, "title": { "type": "string" },
"rationale": { "type": "string" }, "rationale": { "type": "string" },
"expected_impact": { "type": "string" } "expected_impact": { "type": "string" }
} }
} }
}, },
"operator_actions": { "operator_actions": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["title", "steps", "time_estimate_minutes"], "required": ["title", "steps", "time_estimate_minutes"],
"properties": { "properties": {
"title": { "type": "string" }, "title": { "type": "string" },
"steps": { "type": "array", "items": { "type": "string" } }, "steps": { "type": "array", "items": { "type": "string" } },
"time_estimate_minutes": { "type": "integer", "minimum": 1 } "time_estimate_minutes": { "type": "integer", "minimum": 1 }
} }
} }
} },
} "proposals": {
} "type": "array",
"description": "Concrete actionable proposals (prompt_change, setting_change, golden_trace_add, limit_tuning, safety_rule). Only propose what bundle+deltas justify.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "title", "why", "risk", "steps", "expected_impact"],
"properties": {
"kind": { "type": "string", "enum": ["prompt_change", "setting_change", "golden_trace_add", "limit_tuning", "safety_rule"] },
"title": { "type": "string" },
"why": { "type": "string" },
"risk": { "type": "string", "enum": ["low", "medium", "high"] },
"steps": { "type": "array", "items": { "type": "string" } },
"expected_impact": { "type": "string" },
"evidence": { "type": "string" }
}
}
}
}
}

22
src-tauri/deny.toml Normal file
View File

@ -0,0 +1,22 @@
# cargo-deny configuration for PAPA YU
# https://embarkstudios.github.io/cargo-deny/
[advisories]
ignore = []
unmaintained = "warn"
unsound = "deny"
[bans]
multiple-versions = "warn"
wildcards = "warn"
[sources]
unknown-registry = "warn"
unknown-git = "warn"
[licenses]
unlicensed = "deny"
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause"]
deny = []
copyleft = "warn"
confidence-threshold = 0.8

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for PAPA YU","local":true,"windows":["*"],"permissions":["core:default","core:path:default","shell:allow-open","dialog:allow-open"]}} {"default":{"identifier":"default","description":"Default capability for PAPA YU","local":true,"windows":["*"],"permissions":["core:default","core:path:default","shell:allow-open","dialog:allow-open"]},"personal-automation":{"identifier":"personal-automation","description":"Личное использование: терминал (git, npm, cargo) и открытие ссылок в браузере. Команды ограничены allowlist.","local":true,"windows":["main"],"permissions":[{"identifier":"shell:allow-execute","allow":[{"args":[{"validator":"^https?://[^\\s]+$"}],"cmd":"open","name":"open-url"},{"args":[{"validator":"^https?://[^\\s]+$"}],"cmd":"xdg-open","name":"xdg-open-url"},{"args":["/c","start","",{"validator":"^https?://[^\\s]+$"}],"cmd":"cmd","name":"start-url"},{"args":["status","pull","push","add","commit","checkout","branch","log","diff","clone","fetch","merge",{"validator":"^https?://[^\\s]+$"},{"validator":"^[a-zA-Z0-9/_.-]+$"}],"cmd":"git","name":"git"},{"args":["install","run","ci","test","build","start","exec","update",{"validator":"^[a-zA-Z0-9/_.-]+$"}],"cmd":"npm","name":"npm"},{"args":["-y","create-","run","exec",{"validator":"^[a-zA-Z0-9/_.@-]+$"}],"cmd":"npx","name":"npx"},{"args":["build","test","run","check","clippy","fmt","install",{"validator":"^[a-zA-Z0-9/_.-]+$"}],"cmd":"cargo","name":"cargo"},{"args":["-m","pytest","pip","install","-q","-e",{"validator":"^[a-zA-Z0-9/_.-]+$"}],"cmd":"python3","name":"python3"}]}]}}

View File

@ -2420,6 +2420,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",
@ -2485,6 +2515,60 @@
"type": "string", "type": "string",
"const": "shell:deny-stdin-write", "const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope." "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
} }
] ]
}, },

View File

@ -2420,6 +2420,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",
@ -2485,6 +2515,60 @@
"type": "string", "type": "string",
"const": "shell:deny-stdin-write", "const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope." "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
} }
] ]
}, },

View File

@ -0,0 +1,82 @@
//! Запись agent-sync.json для синхронизации с Cursor / Claude Code.
//! Включается через PAPAYU_AGENT_SYNC=1.
//! Опционально: Snyk Code (PAPAYU_SNYK_SYNC=1), Documatic — архитектура из .papa-yu/architecture.md.
use std::fs;
use std::path::Path;
use chrono::Utc;
use serde::Serialize;
use crate::types::{AnalyzeReport, Finding};
#[derive(Serialize)]
struct AgentSyncPayload {
path: String,
updated_at: String,
narrative: String,
findings_count: usize,
actions_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
snyk_findings: Option<Vec<Finding>>,
#[serde(skip_serializing_if = "Option::is_none")]
architecture_summary: Option<String>,
}
/// Читает описание архитектуры для агента (Documatic и др.): .papa-yu/architecture.md или путь из PAPAYU_DOCUMATIC_ARCH_PATH.
fn read_architecture_summary(project_root: &Path) -> Option<String> {
let path = std::env::var("PAPAYU_DOCUMATIC_ARCH_PATH")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| project_root.join(s))
.unwrap_or_else(|| project_root.join(".papa-yu").join("architecture.md"));
if path.exists() {
fs::read_to_string(&path)
.ok()
.map(|s| s.chars().take(16_000).collect())
} else {
None
}
}
/// Записывает .papa-yu/agent-sync.json в корень проекта при PAPAYU_AGENT_SYNC=1.
/// snyk_findings — при PAPAYU_SNYK_SYNC=1 (подгружается снаружи асинхронно).
pub fn write_agent_sync_if_enabled(report: &AnalyzeReport, snyk_findings: Option<Vec<Finding>>) {
let enabled = std::env::var("PAPAYU_AGENT_SYNC")
.map(|s| matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
if !enabled {
return;
}
let root = Path::new(&report.path);
if !root.is_dir() {
return;
}
let dir = root.join(".papa-yu");
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("agent_sync: create_dir_all .papa-yu: {}", e);
return;
}
let file = dir.join("agent-sync.json");
let architecture_summary = read_architecture_summary(root);
let payload = AgentSyncPayload {
path: report.path.clone(),
updated_at: Utc::now().to_rfc3339(),
narrative: report.narrative.clone(),
findings_count: report.findings.len(),
actions_count: report.actions.len(),
snyk_findings,
architecture_summary,
};
let json = match serde_json::to_string_pretty(&payload) {
Ok(j) => j,
Err(e) => {
eprintln!("agent_sync: serialize: {}", e);
return;
}
};
if let Err(e) = fs::write(&file, json) {
eprintln!("agent_sync: write {}: {}", file.display(), e);
}
}

View File

@ -1,123 +1,140 @@
//! Преобразует trace из .papa-yu/traces/<trace_id>.json в golden fixture. //! Преобразует trace из .papa-yu/traces/<trace_id>.json в golden fixture.
//! //!
//! Использование: //! Использование:
//! cargo run --bin trace_to_golden -- <trace_id> [output_path] //! cargo run --bin trace_to_golden -- <trace_id> [output_path]
//! cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path] //! cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path]
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
fn schema_hash_for_version(version: u32) -> String { fn schema_hash_for_version(version: u32) -> String {
let schema_raw = if version == 2 { let schema_raw = if version == 2 {
include_str!("../../config/llm_response_schema_v2.json") include_str!("../../config/llm_response_schema_v2.json")
} else { } else {
include_str!("../../config/llm_response_schema.json") include_str!("../../config/llm_response_schema.json")
}; };
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(schema_raw.as_bytes()); hasher.update(schema_raw.as_bytes());
format!("{:x}", hasher.finalize()) format!("{:x}", hasher.finalize())
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.len() < 2 { if args.len() < 2 {
eprintln!("Usage: trace_to_golden <trace_id|path/to/trace.json> [output_path]"); eprintln!("Usage: trace_to_golden <trace_id|path/to/trace.json> [output_path]");
std::process::exit(1); std::process::exit(1);
} }
let input = &args[1]; let input = &args[1];
let output = args.get(2).map(|s| s.as_str()); let output = args.get(2).map(|s| s.as_str());
let content = if Path::new(input).is_file() { let content = if Path::new(input).is_file() {
fs::read_to_string(input)? fs::read_to_string(input)?
} else { } else {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
let trace_path = Path::new(&manifest_dir) let trace_path = Path::new(&manifest_dir)
.join("../.papa-yu/traces") .join("../.papa-yu/traces")
.join(format!("{}.json", input)); .join(format!("{}.json", input));
fs::read_to_string(&trace_path) fs::read_to_string(&trace_path)
.map_err(|e| format!("read {}: {}", trace_path.display(), e))? .map_err(|e| format!("read {}: {}", trace_path.display(), e))?
}; };
let trace: serde_json::Value = serde_json::from_str(&content)?; let trace: serde_json::Value = serde_json::from_str(&content)?;
let golden = trace_to_golden_format(&trace)?; let golden = trace_to_golden_format(&trace)?;
let out_json = serde_json::to_string_pretty(&golden)?; let out_json = serde_json::to_string_pretty(&golden)?;
let out_path = match output { let out_path = match output {
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => { None => {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
let name = trace.get("trace_id").and_then(|v| v.as_str()).unwrap_or("out"); let name = trace
format!( .get("trace_id")
"{}/../docs/golden_traces/v1/{}_golden.json", .and_then(|v| v.as_str())
manifest_dir, name .unwrap_or("out");
) format!(
} "{}/../docs/golden_traces/v1/{}_golden.json",
}; manifest_dir, name
fs::create_dir_all(Path::new(&out_path).parent().unwrap_or(Path::new(".")))?; )
fs::write(&out_path, out_json)?; }
println!("Written: {}", out_path); };
Ok(()) fs::create_dir_all(Path::new(&out_path).parent().unwrap_or(Path::new(".")))?;
} fs::write(&out_path, out_json)?;
println!("Written: {}", out_path);
fn trace_to_golden_format(trace: &serde_json::Value) -> Result<serde_json::Value, Box<dyn std::error::Error>> { Ok(())
let schema_version = trace }
.get("schema_version")
.or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_version"))) fn trace_to_golden_format(
.cloned() trace: &serde_json::Value,
.unwrap_or(serde_json::json!(1)); ) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let version = schema_version.as_u64().unwrap_or(1) as u32; let schema_version = trace
let schema_hash_val = trace .get("schema_version")
.get("schema_hash") .or_else(|| {
.or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_hash"))) trace
.cloned() .get("config_snapshot")
.unwrap_or_else(|| serde_json::Value::String(schema_hash_for_version(version))); .and_then(|c| c.get("schema_version"))
})
let validated = trace.get("validated_json").cloned(); .cloned()
let validated_obj = validated .unwrap_or(serde_json::json!(1));
.as_ref() let version = schema_version.as_u64().unwrap_or(1) as u32;
.and_then(|v| v.as_str()) let schema_hash_val = trace
.and_then(|s| serde_json::from_str(s).ok()) .get("schema_hash")
.or_else(|| validated.clone()) .or_else(|| {
.unwrap_or(serde_json::Value::Null); trace
.get("config_snapshot")
let config = trace.get("config_snapshot").and_then(|c| c.as_object()); .and_then(|c| c.get("schema_hash"))
let strict_json = config })
.and_then(|c| c.get("strict_json")) .cloned()
.and_then(|v| v.as_str()) .unwrap_or_else(|| serde_json::Value::String(schema_hash_for_version(version)));
.map(|s| !s.is_empty() && matches!(s.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false); let validated = trace.get("validated_json").cloned();
let validated_obj = validated
let validation_outcome = if trace.get("event").and_then(|v| v.as_str()) == Some("VALIDATION_FAILED") { .as_ref()
"err" .and_then(|v| v.as_str())
} else { .and_then(|s| serde_json::from_str(s).ok())
"ok" .or_else(|| validated.clone())
}; .unwrap_or(serde_json::Value::Null);
let error_code = trace.get("error").and_then(|v| v.as_str()).map(String::from);
let config = trace.get("config_snapshot").and_then(|c| c.as_object());
let golden = serde_json::json!({ let strict_json = config
"protocol": { .and_then(|c| c.get("strict_json"))
"schema_version": schema_version, .and_then(|v| v.as_str())
"schema_hash": schema_hash_val .map(|s| !s.is_empty() && matches!(s.to_lowercase().as_str(), "1" | "true" | "yes"))
}, .unwrap_or(false);
"request": {
"mode": trace.get("mode").unwrap_or(&serde_json::Value::Null).clone(), let validation_outcome =
"input_chars": trace.get("input_chars").unwrap_or(&serde_json::Value::Null).clone(), if trace.get("event").and_then(|v| v.as_str()) == Some("VALIDATION_FAILED") {
"token_budget": config.and_then(|c| c.get("max_tokens")).unwrap_or(&serde_json::Value::Null).clone(), "err"
"strict_json": strict_json, } else {
"provider": trace.get("provider").unwrap_or(&serde_json::Value::Null).clone(), "ok"
"model": trace.get("model").unwrap_or(&serde_json::Value::Null).clone() };
}, let error_code = trace
"context": { .get("error")
"context_stats": trace.get("context_stats").cloned().unwrap_or(serde_json::Value::Null), .and_then(|v| v.as_str())
"cache_stats": trace.get("cache_stats").cloned().unwrap_or(serde_json::Value::Null) .map(String::from);
},
"result": { let golden = serde_json::json!({
"validated_json": validated_obj, "protocol": {
"validation_outcome": validation_outcome, "schema_version": schema_version,
"error_code": error_code "schema_hash": schema_hash_val
} },
}); "request": {
Ok(golden) "mode": trace.get("mode").unwrap_or(&serde_json::Value::Null).clone(),
} "input_chars": trace.get("input_chars").unwrap_or(&serde_json::Value::Null).clone(),
"token_budget": config.and_then(|c| c.get("max_tokens")).unwrap_or(&serde_json::Value::Null).clone(),
"strict_json": strict_json,
"provider": trace.get("provider").unwrap_or(&serde_json::Value::Null).clone(),
"model": trace.get("model").unwrap_or(&serde_json::Value::Null).clone()
},
"context": {
"context_stats": trace.get("context_stats").cloned().unwrap_or(serde_json::Value::Null),
"cache_stats": trace.get("cache_stats").cloned().unwrap_or(serde_json::Value::Null)
},
"result": {
"validated_json": validated_obj,
"validation_outcome": validation_outcome,
"error_code": error_code
}
});
Ok(golden)
}

View File

@ -5,10 +5,13 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, Window}; use tauri::{Emitter, Manager, Window};
use crate::commands::{analyze_project, apply_actions_tx, generate_actions_from_report, get_project_profile, preview_actions, undo_last_tx}; use crate::commands::{
analyze_project, apply_actions_tx, generate_actions_from_report, get_project_profile,
preview_actions, undo_last_tx,
};
use crate::types::{ use crate::types::{
Action, ActionKind, AgenticRunRequest, AgenticRunResult, AttemptResult, Action, ActionKind, AgenticRunRequest, AgenticRunResult, ApplyOptions, ApplyPayload,
ApplyOptions, ApplyPayload, VerifyResult, AttemptResult, VerifyResult,
}; };
use crate::verify::verify_project; use crate::verify::verify_project;
@ -55,11 +58,7 @@ fn has_editorconfig(root: &Path) -> bool {
} }
/// v2.4.0: эвристический план (без LLM). README, .gitignore, tests/README.md, .editorconfig. /// v2.4.0: эвристический план (без LLM). README, .gitignore, tests/README.md, .editorconfig.
fn build_plan( fn build_plan(path: &str, _goal: &str, max_actions: u16) -> (String, Vec<Action>) {
path: &str,
_goal: &str,
max_actions: u16,
) -> (String, Vec<Action>) {
let root = Path::new(path); let root = Path::new(path);
let mut actions: Vec<Action> = vec![]; let mut actions: Vec<Action> = vec![];
let mut plan_parts: Vec<String> = vec![]; let mut plan_parts: Vec<String> = vec![];
@ -73,6 +72,7 @@ fn build_plan(
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
plan_parts.push("README.md".into()); plan_parts.push("README.md".into());
} }
@ -86,6 +86,7 @@ fn build_plan(
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
plan_parts.push(".gitignore".into()); plan_parts.push(".gitignore".into());
} }
@ -97,6 +98,7 @@ fn build_plan(
content: Some("# Тесты\n\nДобавьте unit- и интеграционные тесты.\n".into()), content: Some("# Тесты\n\nДобавьте unit- и интеграционные тесты.\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
plan_parts.push("tests/README.md".into()); plan_parts.push("tests/README.md".into());
} }
@ -106,10 +108,12 @@ fn build_plan(
kind: ActionKind::CreateFile, kind: ActionKind::CreateFile,
path: ".editorconfig".to_string(), path: ".editorconfig".to_string(),
content: Some( content: Some(
"root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\n".into(), "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\n"
.into(),
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
plan_parts.push(".editorconfig".into()); plan_parts.push(".editorconfig".into());
} }
@ -186,10 +190,7 @@ pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticR
if a.len() > n { if a.len() > n {
a.truncate(n); a.truncate(n);
} }
( (format!("План из отчёта: {} действий.", a.len()), a)
format!("План из отчёта: {} действий.", a.len()),
a,
)
} else { } else {
build_plan(&path, &goal, max_actions) build_plan(&path, &goal, max_actions)
}; };
@ -241,7 +242,12 @@ pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticR
.await; .await;
if !apply_result.ok { if !apply_result.ok {
emit_progress(&window, "failed", "Не удалось безопасно применить изменения.", attempt_u8); emit_progress(
&window,
"failed",
"Не удалось безопасно применить изменения.",
attempt_u8,
);
let err = apply_result.error.clone(); let err = apply_result.error.clone();
let code = apply_result.error_code.clone(); let code = apply_result.error_code.clone();
attempts.push(AttemptResult { attempts.push(AttemptResult {
@ -270,7 +276,12 @@ pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticR
emit_progress(&window, "verify", "Проверяю сборку/типы…", attempt_u8); emit_progress(&window, "verify", "Проверяю сборку/типы…", attempt_u8);
let v = verify_project(&path); let v = verify_project(&path);
if !v.ok { if !v.ok {
emit_progress(&window, "revert", "Обнаружены ошибки. Откатываю изменения…", attempt_u8); emit_progress(
&window,
"revert",
"Обнаружены ошибки. Откатываю изменения…",
attempt_u8,
);
let _ = undo_last_tx(app.clone(), path.clone()).await; let _ = undo_last_tx(app.clone(), path.clone()).await;
attempts.push(AttemptResult { attempts.push(AttemptResult {
attempt: attempt_u8, attempt: attempt_u8,
@ -311,7 +322,12 @@ pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticR
}; };
} }
emit_progress(&window, "failed", "Не удалось безопасно применить изменения.", max_attempts.min(255) as u8); emit_progress(
&window,
"failed",
"Не удалось безопасно применить изменения.",
max_attempts.min(255) as u8,
);
AgenticRunResult { AgenticRunResult {
ok: false, ok: false,
attempts, attempts,

View File

@ -1,7 +1,15 @@
use crate::types::{Action, ActionGroup, ActionKind, AnalyzeReport, Finding, FixPack, ProjectSignal}; use crate::commands::get_project_profile::detect_project_type;
use crate::types::{
Action, ActionGroup, ActionKind, AnalyzeReport, Finding, FixPack, ProjectSignal,
};
use crate::types::ProjectType;
use std::path::Path; use std::path::Path;
use walkdir::WalkDir;
pub fn analyze_project(paths: Vec<String>, attached_files: Option<Vec<String>>) -> Result<AnalyzeReport, String> { pub fn analyze_project(
paths: Vec<String>,
attached_files: Option<Vec<String>>,
) -> Result<AnalyzeReport, String> {
let path = paths.first().cloned().unwrap_or_else(|| ".".to_string()); let path = paths.first().cloned().unwrap_or_else(|| ".".to_string());
let root = Path::new(&path); let root = Path::new(&path);
if !root.is_dir() { if !root.is_dir() {
@ -25,11 +33,26 @@ pub fn analyze_project(paths: Vec<String>, attached_files: Option<Vec<String>>)
let has_tests = root.join("tests").is_dir(); let has_tests = root.join("tests").is_dir();
let has_package = root.join("package.json").is_file(); let has_package = root.join("package.json").is_file();
let has_cargo = root.join("Cargo.toml").is_file(); let has_cargo = root.join("Cargo.toml").is_file();
let has_lockfile = root.join("package-lock.json").is_file()
|| root.join("yarn.lock").is_file()
|| root.join("Cargo.lock").is_file();
let has_editorconfig = root.join(".editorconfig").is_file();
let mut findings = Vec::new(); let mut findings = Vec::new();
let recommendations = Vec::new(); let recommendations = Vec::new();
let action_groups = build_action_groups(root, has_readme, has_gitignore, has_src, has_tests, has_package, has_cargo); let action_groups = build_action_groups(
let mut actions: Vec<Action> = action_groups.iter().flat_map(|g| g.actions.clone()).collect(); root,
has_readme,
has_gitignore,
has_src,
has_tests,
has_package,
has_cargo,
);
let mut actions: Vec<Action> = action_groups
.iter()
.flat_map(|g| g.actions.clone())
.collect();
if !has_readme { if !has_readme {
findings.push(Finding { findings.push(Finding {
@ -57,6 +80,7 @@ pub fn analyze_project(paths: Vec<String>, attached_files: Option<Vec<String>>)
content: Some("# Copy to .env and fill\n".to_string()), content: Some("# Copy to .env and fill\n".to_string()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
if has_src && !has_tests { if has_src && !has_tests {
@ -66,17 +90,62 @@ pub fn analyze_project(paths: Vec<String>, attached_files: Option<Vec<String>>)
path: Some(path.clone()), path: Some(path.clone()),
}); });
} }
if has_env && !has_gitignore {
findings.push(Finding {
title: ".env без .gitignore (критично)".to_string(),
details: "Файл .env может попасть в репозиторий. Добавьте .gitignore с .env.".to_string(),
path: Some(path.clone()),
});
}
if (has_package || has_cargo) && !has_lockfile {
findings.push(Finding {
title: "Нет lock-файла".to_string(),
details: "Рекомендуется добавить package-lock.json, yarn.lock или Cargo.lock для воспроизводимых сборок.".to_string(),
path: Some(path.clone()),
});
}
if !has_editorconfig {
findings.push(Finding {
title: "Нет .editorconfig".to_string(),
details: "Рекомендуется добавить .editorconfig для единообразного форматирования.".to_string(),
path: Some(path.clone()),
});
}
if has_package {
if let Some(scripts_missing) = check_package_scripts(root) {
findings.push(Finding {
title: "package.json без scripts (build/test/lint)".to_string(),
details: scripts_missing,
path: Some(path.clone()),
});
}
}
for f in check_empty_dirs(root) {
findings.push(f);
}
for f in check_large_files(root, 500) {
findings.push(f);
}
for f in check_utils_dump(root, 20) {
findings.push(f);
}
for f in check_large_dir(root, 50) {
findings.push(f);
}
for f in check_monolith_structure(root) {
findings.push(f);
}
for f in check_prettier_config(root) {
findings.push(f);
}
for f in check_ci_workflows(root) {
findings.push(f);
}
let signals = build_signals_from_findings(&findings); let signals = build_signals_from_findings(&findings);
let (fix_packs, recommended_pack_ids) = build_fix_packs(&action_groups, &signals); let (fix_packs, recommended_pack_ids) = build_fix_packs(&action_groups, &signals);
let narrative = format!( let narrative = build_human_narrative(root, &path, &findings, &actions, has_src, has_tests);
"Проанализировано: {}. Найдено проблем: {}, рекомендаций: {}, действий: {}.",
path,
findings.len(),
recommendations.len(),
actions.len()
);
Ok(AnalyzeReport { Ok(AnalyzeReport {
path, path,
@ -113,6 +182,7 @@ fn build_action_groups(
content: Some("# Project\n\n## Overview\n\n## How to run\n\n## Tests\n\n".into()), content: Some("# Project\n\n## Overview\n\n## How to run\n\n## Tests\n\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}], }],
}); });
} }
@ -135,6 +205,7 @@ fn build_action_groups(
content: Some(content.to_string()), content: Some(content.to_string()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}], }],
}); });
} }
@ -151,6 +222,7 @@ fn build_action_groups(
content: None, content: None,
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}, },
Action { Action {
kind: ActionKind::CreateFile, kind: ActionKind::CreateFile,
@ -158,6 +230,7 @@ fn build_action_groups(
content: Some("# Tests\n\nAdd tests here.\n".into()), content: Some("# Tests\n\nAdd tests here.\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}, },
], ],
}); });
@ -166,6 +239,284 @@ fn build_action_groups(
groups groups
} }
fn check_package_scripts(root: &Path) -> Option<String> {
let pkg_path = root.join("package.json");
let content = std::fs::read_to_string(&pkg_path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let scripts = json.get("scripts")?.as_object()?;
let mut missing = Vec::new();
if scripts.get("build").is_none() {
missing.push("build");
}
if scripts.get("test").is_none() {
missing.push("test");
}
if scripts.get("lint").is_none() {
missing.push("lint");
}
if missing.is_empty() {
None
} else {
Some(format!(
"Отсутствуют scripts: {}. Рекомендуется добавить для CI и локальной разработки.",
missing.join(", ")
))
}
}
fn check_empty_dirs(root: &Path) -> Vec<Finding> {
let mut out = Vec::new();
for e in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.flatten()
{
if e.file_type().is_dir() {
let p = e.path();
if p.read_dir().is_ok_and(|mut it| it.next().is_none()) {
if let Ok(rel) = p.strip_prefix(root) {
let rel_str = rel.to_string_lossy();
if !rel_str.is_empty() && !rel_str.starts_with('.') {
out.push(Finding {
title: "Пустая папка".to_string(),
details: format!("Папка {} пуста. Можно удалить или добавить .gitkeep.", rel_str),
path: Some(p.to_string_lossy().to_string()),
});
}
}
}
}
}
out.truncate(3); // не более 3, чтобы не засорять отчёт
out
}
fn is_ignored(p: &Path) -> bool {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n == "node_modules"
|| n == "target"
|| n == "dist"
|| n == ".git"
|| n.starts_with('.')
})
.unwrap_or(false)
}
fn check_large_files(root: &Path, max_lines: u32) -> Vec<Finding> {
let mut candidates: Vec<(String, u32)> = Vec::new();
for e in WalkDir::new(root)
.max_depth(6)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.flatten()
{
if e.file_type().is_file() {
let p = e.path();
if let Some(ext) = p.extension() {
let ext = ext.to_string_lossy();
if ["rs", "ts", "tsx", "js", "jsx", "py", "java"].contains(&ext.as_ref()) {
if let Ok(content) = std::fs::read_to_string(p) {
let lines = content.lines().count() as u32;
if lines > max_lines {
if let Ok(rel) = p.strip_prefix(root) {
candidates.push((rel.to_string_lossy().to_string(), lines));
}
}
}
}
}
}
}
candidates.sort_by(|a, b| b.1.cmp(&a.1));
candidates
.into_iter()
.take(3)
.map(|(rel, lines)| Finding {
title: "Файл > 500 строк".to_string(),
details: format!("{}: {} строк. Рекомендуется разбить на модули.", rel, lines),
path: Some(rel),
})
.collect()
}
fn check_utils_dump(root: &Path, threshold: usize) -> Vec<Finding> {
let utils = root.join("utils");
if !utils.is_dir() {
return vec![];
}
let count = WalkDir::new(&utils)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count();
if count > threshold {
vec![Finding {
title: "utils/ как свалка".to_string(),
details: format!(
"В utils/ {} файлов (порог {}). Рекомендуется структурировать по доменам.",
count, threshold
),
path: Some(utils.to_string_lossy().to_string()),
}]
} else {
vec![]
}
}
fn check_monolith_structure(root: &Path) -> Vec<Finding> {
let src = root.join("src");
if !src.is_dir() {
return vec![];
}
let (files_in_src, has_subdirs) = {
let mut files = 0usize;
let mut dirs = false;
for e in WalkDir::new(&src).max_depth(1).into_iter().filter_map(|e| e.ok()) {
if e.file_type().is_file() {
files += 1;
} else if e.file_type().is_dir() && e.path() != src {
dirs = true;
}
}
(files, dirs)
};
if files_in_src > 15 && !has_subdirs {
vec![Finding {
title: "Монолитная структура src/".to_string(),
details: "Много файлов в корне src/ без подпапок. Рекомендуется разделение по feature/domain.".to_string(),
path: Some(src.to_string_lossy().to_string()),
}]
} else {
vec![]
}
}
fn check_prettier_config(root: &Path) -> Vec<Finding> {
let has_prettier = root.join(".prettierrc").is_file()
|| root.join(".prettierrc.json").is_file()
|| root.join("prettier.config.js").is_file();
if has_package(root) && !has_prettier {
vec![Finding {
title: "Нет конфигурации Prettier".to_string(),
details: "Рекомендуется добавить .prettierrc для JS/TS проектов.".to_string(),
path: Some(root.to_string_lossy().to_string()),
}]
} else {
vec![]
}
}
fn has_package(root: &Path) -> bool {
root.join("package.json").is_file()
}
fn check_ci_workflows(root: &Path) -> Vec<Finding> {
let has_pkg = root.join("package.json").is_file();
let has_cargo = root.join("Cargo.toml").is_file();
if !has_pkg && !has_cargo {
return vec![];
}
let gh = root.join(".github").join("workflows");
if !gh.is_dir() {
vec![Finding {
title: "Нет GitHub Actions CI".to_string(),
details: "Рекомендуется добавить .github/workflows/ для lint, test, build.".to_string(),
path: Some(root.to_string_lossy().to_string()),
}]
} else {
vec![]
}
}
fn check_large_dir(root: &Path, threshold: usize) -> Vec<Finding> {
let mut out = Vec::new();
for e in WalkDir::new(root)
.max_depth(3)
.min_depth(1)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.flatten()
{
if e.file_type().is_dir() {
let p = e.path();
let count = WalkDir::new(p)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count();
if count > threshold {
if let Ok(rel) = p.strip_prefix(root) {
out.push(Finding {
title: "Слишком много файлов в одной папке".to_string(),
details: format!(
"{}: {} файлов. Рекомендуется разбить на подпапки.",
rel.to_string_lossy(),
count
),
path: Some(p.to_string_lossy().to_string()),
});
}
}
}
}
out.truncate(2);
out
}
fn build_human_narrative(
root: &Path,
path: &str,
findings: &[Finding],
actions: &[Action],
has_src: bool,
has_tests: bool,
) -> String {
let pt = detect_project_type(root);
let stack = match pt {
ProjectType::ReactVite => "React + Vite (Frontend SPA)",
ProjectType::NextJs => "Next.js",
ProjectType::Node => "Node.js",
ProjectType::Rust => "Rust/Cargo",
ProjectType::Python => "Python",
ProjectType::Unknown => "тип не определён",
};
let mut lines = vec![
format!("Я проанализировал проект {}.", path),
format!("Это {}.", stack),
];
if has_src {
lines.push("Есть src/.".to_string());
}
if has_src && !has_tests {
lines.push("Нет tests/ — стоит добавить тесты.".to_string());
}
let n = findings.len();
if n > 0 {
lines.push(format!(
"Найдено проблем: {}. Рекомендую начать с: {}.",
n,
findings
.iter()
.take(3)
.map(|f| f.title.as_str())
.collect::<Vec<_>>()
.join("; ")
));
}
if !actions.is_empty() {
lines.push(format!(
"Можно применить {} безопасных исправлений.",
actions.len()
));
}
lines.join(" ")
}
fn build_signals_from_findings(findings: &[Finding]) -> Vec<ProjectSignal> { fn build_signals_from_findings(findings: &[Finding]) -> Vec<ProjectSignal> {
let mut signals: Vec<ProjectSignal> = vec![]; let mut signals: Vec<ProjectSignal> = vec![];
for f in findings { for f in findings {
@ -191,7 +542,10 @@ fn build_signals_from_findings(findings: &[Finding]) -> Vec<ProjectSignal> {
signals signals
} }
fn build_fix_packs(action_groups: &[ActionGroup], signals: &[ProjectSignal]) -> (Vec<FixPack>, Vec<String>) { fn build_fix_packs(
action_groups: &[ActionGroup],
signals: &[ProjectSignal],
) -> (Vec<FixPack>, Vec<String>) {
let mut security: Vec<String> = vec![]; let mut security: Vec<String> = vec![];
let mut quality: Vec<String> = vec![]; let mut quality: Vec<String> = vec![];
let structure: Vec<String> = vec![]; let structure: Vec<String> = vec![];
@ -253,4 +607,3 @@ fn build_fix_packs(action_groups: &[ActionGroup], signals: &[ProjectSignal]) ->
(packs, recommended) (packs, recommended)
} }

View File

@ -3,8 +3,8 @@ use tauri::AppHandle;
use crate::commands::auto_check::auto_check; use crate::commands::auto_check::auto_check;
use crate::tx::{ use crate::tx::{
apply_one_action, clear_redo, collect_rel_paths, ensure_history, new_tx_id, apply_one_action, clear_redo, collect_rel_paths, ensure_history, new_tx_id, preflight_actions,
preflight_actions, push_undo, rollback_tx, snapshot_before, sort_actions_for_apply, write_manifest, push_undo, rollback_tx, snapshot_before, sort_actions_for_apply, write_manifest,
}; };
use crate::types::{ApplyPayload, ApplyResult, TxManifest}; use crate::types::{ApplyPayload, ApplyResult, TxManifest};
@ -149,7 +149,7 @@ pub fn apply_actions(app: AppHandle, payload: ApplyPayload) -> ApplyResult {
} }
if payload.auto_check.unwrap_or(false) { if payload.auto_check.unwrap_or(false) {
if let Err(_) = auto_check(&root) { if auto_check(&root).is_err() {
let _ = rollback_tx(&app, &tx_id); let _ = rollback_tx(&app, &tx_id);
return ApplyResult { return ApplyResult {
ok: false, ok: false,
@ -179,26 +179,48 @@ pub fn apply_actions(app: AppHandle, payload: ApplyPayload) -> ApplyResult {
fn is_protected_file(p: &str) -> bool { fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/"); let lower = p.to_lowercase().replace('\\', "/");
if lower == ".env" || lower.ends_with("/.env") { return true; } if lower == ".env" || lower.ends_with("/.env") {
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; } return true;
if lower.contains("id_rsa") { return true; } }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; } if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") {
if lower.ends_with("cargo.lock") { return true; } return true;
if lower.ends_with("package-lock.json") { return true; } }
if lower.ends_with("pnpm-lock.yaml") { return true; } if lower.contains("id_rsa") {
if lower.ends_with("yarn.lock") { return true; } return true;
if lower.ends_with("composer.lock") { return true; } }
if lower.ends_with("poetry.lock") { return true; } if lower.contains("/secrets/") || lower.starts_with("secrets/") {
if lower.ends_with("pipfile.lock") { return true; } return true;
}
if lower.ends_with("cargo.lock") {
return true;
}
if lower.ends_with("package-lock.json") {
return true;
}
if lower.ends_with("pnpm-lock.yaml") {
return true;
}
if lower.ends_with("yarn.lock") {
return true;
}
if lower.ends_with("composer.lock") {
return true;
}
if lower.ends_with("poetry.lock") {
return true;
}
if lower.ends_with("pipfile.lock") {
return true;
}
let bin_ext = [ let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".zip", ".7z", ".rar", ".dmg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg", ".pkg", ".exe", ".dll", ".so", ".dylib", ".bin", ".mp3", ".mp4", ".mov", ".avi", ".wasm",
".exe", ".dll", ".so", ".dylib", ".bin", ".class",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
]; ];
for ext in bin_ext { for ext in bin_ext {
if lower.ends_with(ext) { return true; } if lower.ends_with(ext) {
return true;
}
} }
false false
} }
@ -206,9 +228,31 @@ fn is_protected_file(p: &str) -> bool {
fn is_text_allowed(p: &str) -> bool { fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase(); let lower = p.to_lowercase();
let ok_ext = [ let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml", ".ts",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp", ".tsx",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig", ".js",
".jsx",
".json",
".md",
".txt",
".toml",
".yaml",
".yml",
".rs",
".py",
".go",
".java",
".kt",
".c",
".cpp",
".h",
".hpp",
".css",
".scss",
".html",
".env",
".gitignore",
".editorconfig",
]; ];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.') ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
} }

View File

@ -44,25 +44,16 @@ fn emit_progress(app: &AppHandle, msg: &str) {
let _ = app.emit(PROGRESS_EVENT, msg); let _ = app.emit(PROGRESS_EVENT, msg);
} }
fn write_tx_record( fn write_tx_record(app: &AppHandle, tx_id: &str, record: &serde_json::Value) -> Result<(), String> {
app: &AppHandle,
tx_id: &str,
record: &serde_json::Value,
) -> Result<(), String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let tx_dir = dir.join("history").join("tx"); let tx_dir = dir.join("history").join("tx");
fs::create_dir_all(&tx_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&tx_dir).map_err(|e| e.to_string())?;
let p = tx_dir.join(format!("{tx_id}.json")); let p = tx_dir.join(format!("{tx_id}.json"));
let bytes = let bytes = serde_json::to_vec_pretty(record).map_err(|e| e.to_string())?;
serde_json::to_vec_pretty(record).map_err(|e| e.to_string())?;
fs::write(&p, bytes).map_err(|e| e.to_string()) fs::write(&p, bytes).map_err(|e| e.to_string())
} }
fn copy_dir_recursive( fn copy_dir_recursive(src: &Path, dst: &Path, exclude: &[&str]) -> Result<(), String> {
src: &Path,
dst: &Path,
exclude: &[&str],
) -> Result<(), String> {
if exclude if exclude
.iter() .iter()
.any(|x| src.file_name().map(|n| n == *x).unwrap_or(false)) .any(|x| src.file_name().map(|n| n == *x).unwrap_or(false))
@ -85,11 +76,7 @@ fn copy_dir_recursive(
Ok(()) Ok(())
} }
fn snapshot_project( fn snapshot_project(app: &AppHandle, project_root: &Path, tx_id: &str) -> Result<PathBuf, String> {
app: &AppHandle,
project_root: &Path,
tx_id: &str,
) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let snap_dir = dir.join("history").join("snapshots").join(tx_id); let snap_dir = dir.join("history").join("snapshots").join(tx_id);
if snap_dir.exists() { if snap_dir.exists() {
@ -98,7 +85,14 @@ fn snapshot_project(
fs::create_dir_all(&snap_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&snap_dir).map_err(|e| e.to_string())?;
let exclude = [ let exclude = [
".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage", ".git",
"node_modules",
"dist",
"build",
".next",
"target",
".cache",
"coverage",
]; ];
copy_dir_recursive(project_root, &snap_dir, &exclude)?; copy_dir_recursive(project_root, &snap_dir, &exclude)?;
Ok(snap_dir) Ok(snap_dir)
@ -106,7 +100,14 @@ fn snapshot_project(
fn restore_snapshot(project_root: &Path, snap_dir: &Path) -> Result<(), String> { fn restore_snapshot(project_root: &Path, snap_dir: &Path) -> Result<(), String> {
let exclude = [ let exclude = [
".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage", ".git",
"node_modules",
"dist",
"build",
".next",
"target",
".cache",
"coverage",
]; ];
for entry in fs::read_dir(project_root).map_err(|e| e.to_string())? { for entry in fs::read_dir(project_root).map_err(|e| e.to_string())? {
@ -130,7 +131,6 @@ fn restore_snapshot(project_root: &Path, snap_dir: &Path) -> Result<(), String>
Ok(()) Ok(())
} }
fn run_cmd_allowlisted( fn run_cmd_allowlisted(
cwd: &Path, cwd: &Path,
exe: &str, exe: &str,
@ -363,7 +363,10 @@ pub async fn apply_actions_tx(
.iter() .iter()
.any(|c| error_code == *c) .any(|c| error_code == *c)
.then(|| "apply".to_string()); .then(|| "apply".to_string());
eprintln!("[APPLY_ROLLBACK] tx_id={} path={} reason={}", tx_id, path, e); eprintln!(
"[APPLY_ROLLBACK] tx_id={} path={} reason={}",
tx_id, path, e
);
return ApplyTxResult { return ApplyTxResult {
ok: false, ok: false,
tx_id: Some(tx_id.clone()), tx_id: Some(tx_id.clone()),
@ -386,7 +389,10 @@ pub async fn apply_actions_tx(
if any_fail { if any_fail {
emit_progress(&app, "Обнаружены ошибки. Откатываю изменения…"); emit_progress(&app, "Обнаружены ошибки. Откатываю изменения…");
let _ = restore_snapshot(&root, &snap_dir); let _ = restore_snapshot(&root, &snap_dir);
eprintln!("[APPLY_ROLLBACK] tx_id={} path={} reason=autoCheck_failed", tx_id, path); eprintln!(
"[APPLY_ROLLBACK] tx_id={} path={} reason=autoCheck_failed",
tx_id, path
);
let record = json!({ let record = json!({
"txId": tx_id, "txId": tx_id,
@ -417,7 +423,12 @@ pub async fn apply_actions_tx(
}); });
let _ = write_tx_record(&app, &tx_id, &record); let _ = write_tx_record(&app, &tx_id, &record);
eprintln!("[APPLY_SUCCESS] tx_id={} path={} actions={}", tx_id, path, actions.len()); eprintln!(
"[APPLY_SUCCESS] tx_id={} path={} actions={}",
tx_id,
path,
actions.len()
);
ApplyTxResult { ApplyTxResult {
ok: true, ok: true,
@ -434,27 +445,49 @@ pub async fn apply_actions_tx(
fn is_protected_file(p: &str) -> bool { fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/"); let lower = p.to_lowercase().replace('\\', "/");
// Секреты и ключи (denylist) // Секреты и ключи (denylist)
if lower == ".env" || lower.ends_with("/.env") { return true; } if lower == ".env" || lower.ends_with("/.env") {
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; } return true;
if lower.contains("id_rsa") { return true; } }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; } if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") {
return true;
}
if lower.contains("id_rsa") {
return true;
}
if lower.contains("/secrets/") || lower.starts_with("secrets/") {
return true;
}
// Lock-файлы // Lock-файлы
if lower.ends_with("cargo.lock") { return true; } if lower.ends_with("cargo.lock") {
if lower.ends_with("package-lock.json") { return true; } return true;
if lower.ends_with("pnpm-lock.yaml") { return true; } }
if lower.ends_with("yarn.lock") { return true; } if lower.ends_with("package-lock.json") {
if lower.ends_with("composer.lock") { return true; } return true;
if lower.ends_with("poetry.lock") { return true; } }
if lower.ends_with("pipfile.lock") { return true; } if lower.ends_with("pnpm-lock.yaml") {
return true;
}
if lower.ends_with("yarn.lock") {
return true;
}
if lower.ends_with("composer.lock") {
return true;
}
if lower.ends_with("poetry.lock") {
return true;
}
if lower.ends_with("pipfile.lock") {
return true;
}
let bin_ext = [ let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".zip", ".7z", ".rar", ".dmg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg", ".pkg", ".exe", ".dll", ".so", ".dylib", ".bin", ".mp3", ".mp4", ".mov", ".avi", ".wasm",
".exe", ".dll", ".so", ".dylib", ".bin", ".class",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
]; ];
for ext in bin_ext { for ext in bin_ext {
if lower.ends_with(ext) { return true; } if lower.ends_with(ext) {
return true;
}
} }
false false
} }
@ -462,9 +495,31 @@ fn is_protected_file(p: &str) -> bool {
fn is_text_allowed(p: &str) -> bool { fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase(); let lower = p.to_lowercase();
let ok_ext = [ let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml", ".ts",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp", ".tsx",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig", ".js",
".jsx",
".json",
".md",
".txt",
".toml",
".yaml",
".yml",
".rs",
".py",
".go",
".java",
".kt",
".c",
".cpp",
".h",
".hpp",
".css",
".scss",
".html",
".env",
".gitignore",
".editorconfig",
]; ];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.') ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
} }

View File

@ -0,0 +1,173 @@
//! Поиск трендовых дизайнов сайтов и приложений, иконок из безопасных источников.
//!
//! Использует Tavily Search с include_domains — только разрешённые домены.
//! Результаты возвращаются в формате рекомендаций (TrendsRecommendation) для показа в UI
//! и передачи в контекст ИИ для передовых дизайнерских решений.
use crate::online_research::{tavily_search_with_domains, SearchResult};
use crate::types::{TrendsRecommendation, TrendsResult};
/// Домены, разрешённые для поиска дизайна и иконок (безопасные, известные источники).
const ALLOWED_DESIGN_DOMAINS: &[&str] = &[
"dribbble.com",
"behance.net",
"figma.com",
"material.io",
"heroicons.com",
"lucide.dev",
"fontawesome.com",
"icons8.com",
"flaticon.com",
"thenounproject.com",
"undraw.co",
"storyset.com",
"smashingmagazine.com",
"uxdesign.cc",
"nngroup.com",
"design.google",
"apple.com",
"developer.apple.com",
"m3.material.io",
"tailwindui.com",
"shadcn.com",
"radix-ui.com",
"github.com",
"css-tricks.com",
"web.dev",
];
fn host_from_url(url: &str) -> Option<String> {
let url = url.trim().to_lowercase();
let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
let host = rest.split('/').next()?;
let host = host.trim_matches(|c| c == '[' || c == ']');
if host.is_empty() {
return None;
}
Some(host.to_string())
}
/// Проверяет, что хост входит в allowlist (или поддомен разрешённого).
fn is_host_allowed(host: &str) -> bool {
let host_lower = host.to_lowercase();
ALLOWED_DESIGN_DOMAINS.iter().any(|d| {
host_lower == *d || host_lower.ends_with(&format!(".{}", d))
})
}
/// Двойная проверка: оставляем только результаты с разрешённых доменов.
fn filter_results_by_domains(results: Vec<SearchResult>) -> Vec<SearchResult> {
results
.into_iter()
.filter(|r| host_from_url(&r.url).map_or(false, |h| is_host_allowed(&h)))
.collect()
}
/// Запрос к Tavily с ограничением по безопасным дизайн-доменам.
async fn search_design_safe(
query: &str,
max_results: usize,
) -> Result<Vec<SearchResult>, String> {
let results = tavily_search_with_domains(
query,
max_results.min(15),
Some(ALLOWED_DESIGN_DOMAINS),
)
.await?;
Ok(filter_results_by_domains(results))
}
/// Преобразует результаты поиска в рекомендации для UI и контекста ИИ.
fn search_results_to_recommendations(
results: Vec<SearchResult>,
source_label: &str,
) -> Vec<TrendsRecommendation> {
results
.into_iter()
.map(|r| {
let source = host_from_url(&r.url).unwrap_or_else(|| source_label.to_string());
TrendsRecommendation {
title: r.title,
summary: r.snippet,
url: Some(r.url),
source: Some(source),
}
})
.collect()
}
/// Поиск трендов дизайна и иконок из безопасных источников.
/// Возвращает TrendsResult для отображения в модалке трендов и передачи в ИИ.
#[tauri::command]
pub async fn research_design_trends(
query: Option<String>,
max_results: Option<usize>,
) -> Result<TrendsResult, String> {
let q = query
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("trending UI UX design 2024, modern app icons, design systems");
let max = max_results.unwrap_or(10).clamp(1, 15);
let results = search_design_safe(q, max).await?;
let recommendations = search_results_to_recommendations(results, "Design");
let now = chrono::Utc::now().to_rfc3339();
Ok(TrendsResult {
last_updated: now,
recommendations: if recommendations.is_empty() {
default_design_recommendations()
} else {
recommendations
},
should_update: false,
})
}
/// Рекомендации по умолчанию (без поиска), если Tavily недоступен или запрос пустой.
fn default_design_recommendations() -> Vec<TrendsRecommendation> {
vec![
TrendsRecommendation {
title: "Material Design 3 (Material You)".to_string(),
summary: Some(
"Адаптивные компоненты, динамические цвета, передовые гайдлайны для приложений."
.to_string(),
),
url: Some("https://m3.material.io/".to_string()),
source: Some("material.io".to_string()),
},
TrendsRecommendation {
title: "Lucide Icons".to_string(),
summary: Some(
"Современные открытые иконки, единый стиль, Tree-shakeable для React/Vue."
.to_string(),
),
url: Some("https://lucide.dev/".to_string()),
source: Some("lucide.dev".to_string()),
},
TrendsRecommendation {
title: "shadcn/ui".to_string(),
summary: Some(
"Компоненты на Radix, копируешь в проект — полный контроль, тренд 2024 для React."
.to_string(),
),
url: Some("https://ui.shadcn.com/".to_string()),
source: Some("shadcn.com".to_string()),
},
TrendsRecommendation {
title: "Heroicons".to_string(),
summary: Some("Иконки от создателей Tailwind: outline и solid, SVG.".to_string()),
url: Some("https://heroicons.com/".to_string()),
source: Some("heroicons.com".to_string()),
},
TrendsRecommendation {
title: "Nielsen Norman Group".to_string(),
summary: Some(
"Исследования UX и гайдлайны по юзабилити для веба и приложений."
.to_string(),
),
url: Some("https://www.nngroup.com/".to_string()),
source: Some("nngroup.com".to_string()),
},
]
}

View File

@ -17,10 +17,9 @@ fn report_mentions_readme(report: &AnalyzeReport) -> bool {
.findings .findings
.iter() .iter()
.any(|f| f.title.contains("README") || f.details.to_lowercase().contains("readme")) .any(|f| f.title.contains("README") || f.details.to_lowercase().contains("readme"))
|| report || report.recommendations.iter().any(|r| {
.recommendations r.title.to_lowercase().contains("readme") || r.details.to_lowercase().contains("readme")
.iter() })
.any(|r| r.title.to_lowercase().contains("readme") || r.details.to_lowercase().contains("readme"))
} }
fn report_mentions_gitignore(report: &AnalyzeReport) -> bool { fn report_mentions_gitignore(report: &AnalyzeReport) -> bool {
@ -28,10 +27,10 @@ fn report_mentions_gitignore(report: &AnalyzeReport) -> bool {
.findings .findings
.iter() .iter()
.any(|f| f.title.contains("gitignore") || f.details.to_lowercase().contains("gitignore")) .any(|f| f.title.contains("gitignore") || f.details.to_lowercase().contains("gitignore"))
|| report || report.recommendations.iter().any(|r| {
.recommendations r.title.to_lowercase().contains("gitignore")
.iter() || r.details.to_lowercase().contains("gitignore")
.any(|r| r.title.to_lowercase().contains("gitignore") || r.details.to_lowercase().contains("gitignore")) })
} }
fn report_mentions_tests(report: &AnalyzeReport) -> bool { fn report_mentions_tests(report: &AnalyzeReport) -> bool {
@ -39,10 +38,9 @@ fn report_mentions_tests(report: &AnalyzeReport) -> bool {
.findings .findings
.iter() .iter()
.any(|f| f.title.contains("tests") || f.details.to_lowercase().contains("тест")) .any(|f| f.title.contains("tests") || f.details.to_lowercase().contains("тест"))
|| report || report.recommendations.iter().any(|r| {
.recommendations r.title.to_lowercase().contains("test") || r.details.to_lowercase().contains("тест")
.iter() })
.any(|r| r.title.to_lowercase().contains("test") || r.details.to_lowercase().contains("тест"))
} }
pub fn build_actions_from_report(report: &AnalyzeReport, mode: &str) -> Vec<ActionItem> { pub fn build_actions_from_report(report: &AnalyzeReport, mode: &str) -> Vec<ActionItem> {
@ -104,7 +102,9 @@ pub fn build_actions_from_report(report: &AnalyzeReport, mode: &str) -> Vec<Acti
if mode == "balanced" { if mode == "balanced" {
let root = Path::new(&report.path); let root = Path::new(&report.path);
let has_node = root.join("package.json").exists(); let has_node = root.join("package.json").exists();
let has_react = root.join("package.json").exists() && (root.join("src").join("App.jsx").exists() || root.join("src").join("App.tsx").exists()); let has_react = root.join("package.json").exists()
&& (root.join("src").join("App.jsx").exists()
|| root.join("src").join("App.tsx").exists());
if has_node || has_react { if has_node || has_react {
out.push(ActionItem { out.push(ActionItem {
id: mk_id("action", out.len() + 1), id: mk_id("action", out.len() + 1),
@ -125,23 +125,26 @@ pub fn build_actions_from_report(report: &AnalyzeReport, mode: &str) -> Vec<Acti
#[tauri::command] #[tauri::command]
pub async fn generate_actions(payload: GenerateActionsPayload) -> Result<ActionPlan, String> { pub async fn generate_actions(payload: GenerateActionsPayload) -> Result<ActionPlan, String> {
let path = payload.path.clone(); let path = payload.path.clone();
let mode = if payload.mode.is_empty() { "safe" } else { payload.mode.as_str() }; let mode = if payload.mode.is_empty() {
"safe"
} else {
payload.mode.as_str()
};
let report = crate::commands::analyze_project(vec![path.clone()], None)?; let report = crate::commands::analyze_project(vec![path.clone()], None)?;
let mut actions = build_actions_from_report(&report, mode); let mut actions = build_actions_from_report(&report, mode);
if !payload.selected.is_empty() { if !payload.selected.is_empty() {
let sel: Vec<String> = payload.selected.iter().map(|s| s.to_lowercase()).collect(); let sel: Vec<String> = payload.selected.iter().map(|s| s.to_lowercase()).collect();
actions = actions actions.retain(|a| {
.into_iter() let txt =
.filter(|a| { format!("{} {} {} {:?}", a.summary, a.rationale, a.risk, a.tags).to_lowercase();
let txt = format!("{} {} {} {:?}", a.summary, a.rationale, a.risk, a.tags).to_lowercase(); sel.iter().any(|k| txt.contains(k))
sel.iter().any(|k| txt.contains(k)) });
})
.collect();
} }
let warnings = vec!["Все изменения применяются только через предпросмотр и могут быть отменены.".into()]; let warnings =
vec!["Все изменения применяются только через предпросмотр и могут быть отменены.".into()];
Ok(ActionPlan { Ok(ActionPlan {
plan_id: format!("plan-{}", chrono::Utc::now().timestamp_millis()), plan_id: format!("plan-{}", chrono::Utc::now().timestamp_millis()),

View File

@ -88,6 +88,7 @@ pub async fn generate_actions_from_report(
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
} }
@ -106,6 +107,7 @@ pub async fn generate_actions_from_report(
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
} }
@ -122,6 +124,7 @@ pub async fn generate_actions_from_report(
content: Some("MIT License\n\nCopyright (c) <year> <copyright holders>\n".into()), content: Some("MIT License\n\nCopyright (c) <year> <copyright holders>\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
} }
@ -136,6 +139,7 @@ pub async fn generate_actions_from_report(
content: None, content: None,
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
let keep_path = rel("tests/.gitkeep"); let keep_path = rel("tests/.gitkeep");
@ -146,6 +150,7 @@ pub async fn generate_actions_from_report(
content: Some("".into()), content: Some("".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ mod generate_actions;
mod generate_actions_from_report; mod generate_actions_from_report;
mod get_project_profile; mod get_project_profile;
mod llm_planner; mod llm_planner;
mod multi_provider;
mod preview_actions; mod preview_actions;
mod project_content; mod project_content;
mod projects; mod projects;
@ -15,6 +16,8 @@ mod propose_actions;
mod redo_last; mod redo_last;
mod run_batch; mod run_batch;
mod settings_export; mod settings_export;
pub mod design_trends;
mod trace_fields;
mod trends; mod trends;
mod undo_last; mod undo_last;
mod undo_last_tx; mod undo_last_tx;
@ -22,21 +25,24 @@ mod undo_status;
mod weekly_report; mod weekly_report;
pub use agentic_run::agentic_run; pub use agentic_run::agentic_run;
pub use get_project_profile::get_project_profile;
pub use projects::{add_project, append_session_event, get_project_settings, list_projects, list_sessions, set_project_settings};
pub use analyze_project::analyze_project; pub use analyze_project::analyze_project;
pub use apply_actions::apply_actions; pub use apply_actions::apply_actions;
pub use apply_actions_tx::apply_actions_tx; pub use apply_actions_tx::apply_actions_tx;
pub use folder_links::{load_folder_links, save_folder_links, FolderLinks};
pub use generate_actions::generate_actions; pub use generate_actions::generate_actions;
pub use generate_actions_from_report::generate_actions_from_report; pub use generate_actions_from_report::generate_actions_from_report;
pub use propose_actions::propose_actions; pub use get_project_profile::get_project_profile;
pub use folder_links::{load_folder_links, save_folder_links, FolderLinks};
pub use preview_actions::preview_actions; pub use preview_actions::preview_actions;
pub use projects::{
add_project, append_session_event, apply_project_setting_cmd, get_project_settings,
list_projects, list_sessions, set_project_settings,
};
pub use propose_actions::propose_actions;
pub use redo_last::redo_last; pub use redo_last::redo_last;
pub use run_batch::run_batch; pub use run_batch::run_batch;
pub use settings_export::{export_settings, import_settings};
pub use trends::{fetch_trends_recommendations, get_trends_recommendations}; pub use trends::{fetch_trends_recommendations, get_trends_recommendations};
pub use undo_last::{get_undo_redo_state_cmd, undo_available, undo_last}; pub use undo_last::{get_undo_redo_state_cmd, undo_available, undo_last};
pub use undo_last_tx::undo_last_tx; pub use undo_last_tx::undo_last_tx;
pub use undo_status::undo_status; pub use undo_status::undo_status;
pub use settings_export::{export_settings, import_settings};
pub use weekly_report::{analyze_weekly_reports, save_report_to_file, WeeklyReportResult}; pub use weekly_report::{analyze_weekly_reports, save_report_to_file, WeeklyReportResult};

View File

@ -0,0 +1,206 @@
//! Сбор ответов от нескольких ИИ (Claude, OpenAI и др.), анализ и выдача оптимального плана.
//!
//! Включение: задайте PAPAYU_LLM_PROVIDERS (JSON-массив провайдеров).
//! Опционально: PAPAYU_LLM_AGGREGATOR_URL — ИИ-агрегатор для слияния планов.
use crate::commands::llm_planner;
use crate::types::AgentPlan;
use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub struct ProviderConfig {
pub url: String,
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
}
/// Парсит PAPAYU_LLM_PROVIDERS: JSON-массив объектов { "url", "model", "api_key" (опционально) }.
pub fn parse_providers_from_env() -> Result<Vec<ProviderConfig>, String> {
let s = std::env::var("PAPAYU_LLM_PROVIDERS").map_err(|_| "PAPAYU_LLM_PROVIDERS not set")?;
let s = s.trim();
if s.is_empty() {
return Err("PAPAYU_LLM_PROVIDERS is empty".into());
}
let list: Vec<ProviderConfig> =
serde_json::from_str(s).map_err(|e| format!("PAPAYU_LLM_PROVIDERS JSON: {}", e))?;
if list.is_empty() {
return Err("PAPAYU_LLM_PROVIDERS: empty array".into());
}
Ok(list)
}
/// Запрашивает план у одного провайдера. Имя провайдера — для логов и агрегации.
pub async fn fetch_plan_from_provider(
name: String,
config: &ProviderConfig,
system_content: &str,
user_message: &str,
path: &str,
) -> Result<AgentPlan, String> {
let fallback_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
let api_key = config
.api_key
.as_deref()
.filter(|k| !k.is_empty())
.or_else(|| fallback_key.as_deref());
llm_planner::request_one_plan(
&config.url,
api_key,
&config.model,
system_content,
user_message,
path,
)
.await
.map_err(|e| format!("{}: {}", name, e))
}
/// Собирает планы от всех провайдеров параллельно.
pub async fn fetch_all_plans(
providers: &[ProviderConfig],
system_content: &str,
user_message: &str,
path: &str,
) -> Vec<(String, AgentPlan)> {
let mut handles = Vec::with_capacity(providers.len());
for (i, config) in providers.iter().enumerate() {
let name = format!(
"provider_{}_{}",
i,
config
.url
.split('/')
.nth(2)
.unwrap_or("unknown")
);
let config = config.clone();
let system_content = system_content.to_string();
let user_message = user_message.to_string();
let path = path.to_string();
handles.push(async move {
let result = fetch_plan_from_provider(
name.clone(),
&config,
&system_content,
&user_message,
&path,
)
.await;
result.map(|plan| (name, plan))
});
}
let results = futures::future::join_all(handles).await;
results.into_iter().filter_map(Result::ok).collect()
}
/// Объединяет планы: по пути действия дедуплицируются (оставляем первое вхождение).
fn merge_plans_rust(plans: Vec<(String, AgentPlan)>) -> AgentPlan {
let mut all_actions = Vec::new();
let mut seen_paths: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
let mut summary_parts = Vec::new();
let mut plan_json_merged: Option<String> = None;
let protocol_version_used = plans.first().and_then(|(_, p)| p.protocol_version_used);
for (name, plan) in &plans {
summary_parts.push(format!("{} ({} действий)", name, plan.actions.len()));
for action in &plan.actions {
let key = (action.path.clone(), format!("{:?}", action.kind));
if seen_paths.insert(key) {
all_actions.push(action.clone());
}
}
if plan_json_merged.is_none() {
plan_json_merged = plan.plan_json.clone();
}
}
let summary = format!(
"Объединённый план из {} ИИ: {}. Всего действий: {}.",
plans.len(),
summary_parts.join("; "),
all_actions.len()
);
AgentPlan {
ok: true,
summary,
actions: all_actions,
error: None,
error_code: None,
plan_json: plan_json_merged,
plan_context: None,
protocol_version_used,
online_fallback_suggested: None,
online_context_used: Some(false),
}
}
/// Вызывает агрегатор-ИИ: один запрос с текстом всех планов, ожидаем один оптимальный план в том же JSON-формате.
async fn aggregate_via_llm(
plans: Vec<(String, AgentPlan)>,
_system_content: &str,
user_message: &str,
path: &str,
) -> Result<AgentPlan, String> {
let aggregator_url =
std::env::var("PAPAYU_LLM_AGGREGATOR_URL").map_err(|_| "PAPAYU_LLM_AGGREGATOR_URL not set")?;
let aggregator_url = aggregator_url.trim();
if aggregator_url.is_empty() {
return Err("PAPAYU_LLM_AGGREGATOR_URL is empty".into());
}
let aggregator_key = std::env::var("PAPAYU_LLM_AGGREGATOR_KEY").ok();
let aggregator_model = std::env::var("PAPAYU_LLM_AGGREGATOR_MODEL")
.unwrap_or_else(|_| "gpt-4o-mini".to_string());
let plans_text: Vec<String> = plans
.iter()
.map(|(name, plan)| {
let actions_json = serde_json::to_string(&plan.actions).unwrap_or_else(|_| "[]".into());
format!("--- {} ---\nsummary: {}\nactions: {}\n", name, plan.summary, actions_json)
})
.collect();
let aggregator_prompt = format!(
"Ниже приведены планы от разных ИИ (Claude, OpenAI и др.) по одной и той же задаче.\n\
Твоя задача: проанализировать все планы и выдать ОДИН оптимальный план (объединённый или лучший).\n\
Ответь в том же JSON-формате, что и входные планы: объект с полем \"actions\" (массив действий) и опционально \"summary\".\n\n\
Планы:\n{}\n\n\
Исходный запрос пользователя (контекст):\n{}",
plans_text.join("\n"),
user_message.chars().take(4000).collect::<String>()
);
let system_aggregator = "Ты — агрегатор планов. На вход даны несколько планов от разных ИИ. Выдай один итоговый план в формате JSON: { \"summary\": \"...\", \"actions\": [ ... ] }. Без markdown-обёртки.";
llm_planner::request_one_plan(
aggregator_url,
aggregator_key.as_deref(),
&aggregator_model,
system_aggregator,
&aggregator_prompt,
path,
)
.await
}
/// Собирает планы от всех провайдеров и возвращает один оптимальный (агрегатор-ИИ или слияние в Rust).
pub async fn fetch_and_aggregate(
system_content: &str,
user_message: &str,
path: &str,
) -> Result<AgentPlan, String> {
let providers = parse_providers_from_env()?;
let plans = fetch_all_plans(&providers, system_content, user_message, path).await;
if plans.is_empty() {
return Err("Ни один из ИИ-провайдеров не вернул валидный план".into());
}
if plans.len() == 1 {
return Ok(plans.into_iter().next().unwrap().1);
}
let use_aggregator = std::env::var("PAPAYU_LLM_AGGREGATOR_URL")
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if use_aggregator {
aggregate_via_llm(plans, system_content, user_message, path).await
} else {
Ok(merge_plans_rust(plans))
}
}

View File

@ -54,7 +54,12 @@ pub fn preview_actions(payload: ApplyPayload) -> Result<PreviewResult, String> {
} }
} }
ActionKind::PatchFile => { ActionKind::PatchFile => {
let (diff, summary, bytes_before, bytes_after) = preview_patch_file(root, &a.path, a.patch.as_deref().unwrap_or(""), a.base_sha256.as_deref().unwrap_or("")); let (diff, summary, bytes_before, bytes_after) = preview_patch_file(
root,
&a.path,
a.patch.as_deref().unwrap_or(""),
a.base_sha256.as_deref().unwrap_or(""),
);
DiffItem { DiffItem {
kind: "patch".to_string(), kind: "patch".to_string(),
path: a.path.clone(), path: a.path.clone(),
@ -65,6 +70,23 @@ pub fn preview_actions(payload: ApplyPayload) -> Result<PreviewResult, String> {
bytes_after, bytes_after,
} }
} }
ActionKind::EditFile => {
let (diff, summary, bytes_before, bytes_after) = preview_edit_file(
root,
&a.path,
a.base_sha256.as_deref().unwrap_or(""),
a.edits.as_deref().unwrap_or(&[]),
);
DiffItem {
kind: "edit".to_string(),
path: a.path.clone(),
old_content: None,
new_content: Some(diff),
summary,
bytes_before,
bytes_after,
}
}
ActionKind::DeleteFile => { ActionKind::DeleteFile => {
let old = read_text_if_exists(root, &a.path); let old = read_text_if_exists(root, &a.path);
DiffItem { DiffItem {
@ -93,9 +115,18 @@ pub fn preview_actions(payload: ApplyPayload) -> Result<PreviewResult, String> {
let files = diffs.len(); let files = diffs.len();
let bytes = diffs let bytes = diffs
.iter() .iter()
.map(|d| d.old_content.as_ref().unwrap_or(&String::new()).len() + d.new_content.as_ref().unwrap_or(&String::new()).len()) .map(|d| {
d.old_content.as_ref().unwrap_or(&String::new()).len()
+ d.new_content.as_ref().unwrap_or(&String::new()).len()
})
.sum::<usize>(); .sum::<usize>();
eprintln!("[PREVIEW_READY] path={} files={} diffs={} bytes={}", payload.root_path, files, diffs.len(), bytes); eprintln!(
"[PREVIEW_READY] path={} files={} diffs={} bytes={}",
payload.root_path,
files,
diffs.len(),
bytes
);
Ok(PreviewResult { diffs, summary }) Ok(PreviewResult { diffs, summary })
} }
@ -107,31 +138,146 @@ fn preview_patch_file(
base_sha256: &str, base_sha256: &str,
) -> (String, Option<String>, Option<usize>, Option<usize>) { ) -> (String, Option<String>, Option<usize>, Option<usize>) {
if !looks_like_unified_diff(patch_text) { if !looks_like_unified_diff(patch_text) {
return (patch_text.to_string(), Some("ERR_PATCH_NOT_UNIFIED: patch is not unified diff".into()), None, None); return (
patch_text.to_string(),
Some("ERR_PATCH_NOT_UNIFIED: patch is not unified diff".into()),
None,
None,
);
} }
let p = match safe_join(root, rel) { let p = match safe_join(root, rel) {
Ok(p) => p, Ok(p) => p,
Err(_) => return (patch_text.to_string(), Some("ERR_INVALID_PATH".into()), None, None), Err(_) => {
return (
patch_text.to_string(),
Some("ERR_INVALID_PATH".into()),
None,
None,
)
}
}; };
if !p.is_file() { if !p.is_file() {
return (patch_text.to_string(), Some("ERR_BASE_MISMATCH: file not found".into()), None, None); return (
patch_text.to_string(),
Some("ERR_BASE_MISMATCH: file not found".into()),
None,
None,
);
} }
let old_bytes = match fs::read(&p) { let old_bytes = match fs::read(&p) {
Ok(b) => b, Ok(b) => b,
Err(_) => return (patch_text.to_string(), Some("ERR_IO: cannot read file".into()), None, None), Err(_) => {
return (
patch_text.to_string(),
Some("ERR_IO: cannot read file".into()),
None,
None,
)
}
}; };
let old_sha = sha256_hex(&old_bytes); let old_sha = sha256_hex(&old_bytes);
if old_sha != base_sha256 { if old_sha != base_sha256 {
return (patch_text.to_string(), Some(format!("ERR_BASE_MISMATCH: have {}, want {}", old_sha, base_sha256)), None, None); return (
patch_text.to_string(),
Some(format!(
"ERR_BASE_MISMATCH: have {}, want {}",
old_sha, base_sha256
)),
None,
None,
);
} }
let old_text = match String::from_utf8(old_bytes) { let old_text = match String::from_utf8(old_bytes) {
Ok(s) => s, Ok(s) => s,
Err(_) => return (patch_text.to_string(), Some("ERR_NON_UTF8_FILE: PATCH_FILE требует UTF-8. Файл не UTF-8.".into()), None, None), Err(_) => {
return (
patch_text.to_string(),
Some("ERR_NON_UTF8_FILE: PATCH_FILE требует UTF-8. Файл не UTF-8.".into()),
None,
None,
)
}
}; };
let bytes_before = old_text.len(); let bytes_before = old_text.len();
match apply_unified_diff_to_text(&old_text, patch_text) { match apply_unified_diff_to_text(&old_text, patch_text) {
Ok(new_text) => (patch_text.to_string(), None, Some(bytes_before), Some(new_text.len())), Ok(new_text) => (
Err(_) => (patch_text.to_string(), Some("ERR_PATCH_APPLY_FAILED: could not apply patch".into()), None, None), patch_text.to_string(),
None,
Some(bytes_before),
Some(new_text.len()),
),
Err(_) => (
patch_text.to_string(),
Some("ERR_PATCH_APPLY_FAILED: could not apply patch".into()),
None,
None,
),
}
}
/// Returns (unified_diff, summary, bytes_before, bytes_after) for EDIT_FILE.
fn preview_edit_file(
root: &std::path::Path,
rel: &str,
base_sha256: &str,
edits: &[crate::types::EditOp],
) -> (String, Option<String>, Option<usize>, Option<usize>) {
use crate::patch::apply_edit_file_to_text;
use diffy::create_patch;
let p = match safe_join(root, rel) {
Ok(p) => p,
Err(_) => return (String::new(), Some("ERR_INVALID_PATH".into()), None, None),
};
if !p.is_file() {
return (
String::new(),
Some("ERR_EDIT_BASE_MISMATCH: file not found".into()),
None,
None,
);
}
let old_bytes = match fs::read(&p) {
Ok(b) => b,
Err(_) => {
return (
String::new(),
Some("ERR_IO: cannot read file".into()),
None,
None,
)
}
};
let old_sha = sha256_hex(&old_bytes);
if old_sha != base_sha256 {
return (
String::new(),
Some(format!(
"ERR_EDIT_BASE_MISMATCH: have {}, want {}",
old_sha, base_sha256
)),
None,
None,
);
}
let old_text = match String::from_utf8(old_bytes) {
Ok(s) => s,
Err(_) => {
return (
String::new(),
Some("ERR_NON_UTF8_FILE: EDIT_FILE requires utf-8".into()),
None,
None,
)
}
};
let bytes_before = old_text.len();
match apply_edit_file_to_text(&old_text, edits) {
Ok(new_text) => {
let patch = create_patch(&old_text, &new_text);
let diff = format!("{}", patch);
(diff, None, Some(bytes_before), Some(new_text.len()))
}
Err(e) => (String::new(), Some(e), None, None),
} }
} }
@ -152,13 +298,14 @@ fn summarize(diffs: &[DiffItem]) -> String {
let create = diffs.iter().filter(|d| d.kind == "create").count(); let create = diffs.iter().filter(|d| d.kind == "create").count();
let update = diffs.iter().filter(|d| d.kind == "update").count(); let update = diffs.iter().filter(|d| d.kind == "update").count();
let patch = diffs.iter().filter(|d| d.kind == "patch").count(); let patch = diffs.iter().filter(|d| d.kind == "patch").count();
let edit = diffs.iter().filter(|d| d.kind == "edit").count();
let delete = diffs.iter().filter(|d| d.kind == "delete").count(); let delete = diffs.iter().filter(|d| d.kind == "delete").count();
let mkdir = diffs.iter().filter(|d| d.kind == "mkdir").count(); let mkdir = diffs.iter().filter(|d| d.kind == "mkdir").count();
let rmdir = diffs.iter().filter(|d| d.kind == "rmdir").count(); let rmdir = diffs.iter().filter(|d| d.kind == "rmdir").count();
let blocked = diffs.iter().filter(|d| d.kind == "blocked").count(); let blocked = diffs.iter().filter(|d| d.kind == "blocked").count();
let mut s = format!( let mut s = format!(
"Создать: {}, изменить: {}, patch: {}, удалить: {}, mkdir: {}, rmdir: {}", "Создать: {}, изменить: {}, patch: {}, edit: {}, удалить: {}, mkdir: {}, rmdir: {}",
create, update, patch, delete, mkdir, rmdir create, update, patch, edit, delete, mkdir, rmdir
); );
if blocked > 0 { if blocked > 0 {
s.push_str(&format!(", заблокировано: {}", blocked)); s.push_str(&format!(", заблокировано: {}", blocked));
@ -168,26 +315,48 @@ fn summarize(diffs: &[DiffItem]) -> String {
fn is_protected_file(p: &str) -> bool { fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/"); let lower = p.to_lowercase().replace('\\', "/");
if lower == ".env" || lower.ends_with("/.env") { return true; } if lower == ".env" || lower.ends_with("/.env") {
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; } return true;
if lower.contains("id_rsa") { return true; } }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; } if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") {
if lower.ends_with("cargo.lock") { return true; } return true;
if lower.ends_with("package-lock.json") { return true; } }
if lower.ends_with("pnpm-lock.yaml") { return true; } if lower.contains("id_rsa") {
if lower.ends_with("yarn.lock") { return true; } return true;
if lower.ends_with("composer.lock") { return true; } }
if lower.ends_with("poetry.lock") { return true; } if lower.contains("/secrets/") || lower.starts_with("secrets/") {
if lower.ends_with("pipfile.lock") { return true; } return true;
}
if lower.ends_with("cargo.lock") {
return true;
}
if lower.ends_with("package-lock.json") {
return true;
}
if lower.ends_with("pnpm-lock.yaml") {
return true;
}
if lower.ends_with("yarn.lock") {
return true;
}
if lower.ends_with("composer.lock") {
return true;
}
if lower.ends_with("poetry.lock") {
return true;
}
if lower.ends_with("pipfile.lock") {
return true;
}
let bin_ext = [ let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".zip", ".7z", ".rar", ".dmg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg", ".pkg", ".exe", ".dll", ".so", ".dylib", ".bin", ".mp3", ".mp4", ".mov", ".avi", ".wasm",
".exe", ".dll", ".so", ".dylib", ".bin", ".class",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
]; ];
for ext in bin_ext { for ext in bin_ext {
if lower.ends_with(ext) { return true; } if lower.ends_with(ext) {
return true;
}
} }
false false
} }
@ -195,9 +364,31 @@ fn is_protected_file(p: &str) -> bool {
fn is_text_allowed(p: &str) -> bool { fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase(); let lower = p.to_lowercase();
let ok_ext = [ let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml", ".ts",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp", ".tsx",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig", ".js",
".jsx",
".json",
".md",
".txt",
".toml",
".yaml",
".yml",
".rs",
".py",
".go",
".java",
".kt",
".c",
".cpp",
".h",
".hpp",
".css",
".scss",
".html",
".env",
".gitignore",
".editorconfig",
]; ];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.') ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
} }

View File

@ -6,15 +6,28 @@ use std::path::Path;
/// Расширения текстовых файлов для включения в контекст ИИ /// Расширения текстовых файлов для включения в контекст ИИ
const TEXT_EXT: &[&str] = &[ const TEXT_EXT: &[&str] = &[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "rs", "py", "json", "toml", "md", "yml", "yaml", "ts", "tsx", "js", "jsx", "mjs", "cjs", "rs", "py", "json", "toml", "md", "yml", "yaml", "css",
"css", "scss", "html", "xml", "vue", "svelte", "go", "rb", "java", "kt", "swift", "c", "h", "scss", "html", "xml", "vue", "svelte", "go", "rb", "java", "kt", "swift", "c", "h", "cpp",
"cpp", "hpp", "sh", "bash", "zsh", "sql", "graphql", "hpp", "sh", "bash", "zsh", "sql", "graphql",
]; ];
/// Папки, которые не сканируем /// Папки, которые не сканируем
const EXCLUDE_DIRS: &[&str] = &[ const EXCLUDE_DIRS: &[&str] = &[
"node_modules", "target", "dist", "build", ".git", ".next", ".nuxt", ".cache", "node_modules",
"coverage", "__pycache__", ".venv", "venv", ".idea", ".vscode", "vendor", "target",
"dist",
"build",
".git",
".next",
".nuxt",
".cache",
"coverage",
"__pycache__",
".venv",
"venv",
".idea",
".vscode",
"vendor",
]; ];
/// Макс. символов на файл (чтобы не перегружать контекст) /// Макс. символов на файл (чтобы не перегружать контекст)
@ -56,7 +69,11 @@ pub fn get_project_content_for_llm(root: &Path, max_total_chars: Option<usize>)
let rel = path.strip_prefix(root).unwrap_or(&path); let rel = path.strip_prefix(root).unwrap_or(&path);
let rel_str = rel.display().to_string(); let rel_str = rel.display().to_string();
let truncated = if content.len() > MAX_BYTES_PER_FILE { let truncated = if content.len() > MAX_BYTES_PER_FILE {
format!("{}\n(обрезано, всего {} байт)", &content[..MAX_BYTES_PER_FILE], content.len()) format!(
"{}…\n(обрезано, всего {} байт)",
&content[..MAX_BYTES_PER_FILE],
content.len()
)
} else { } else {
content content
}; };
@ -76,7 +93,10 @@ pub fn get_project_content_for_llm(root: &Path, max_total_chars: Option<usize>)
if out.is_empty() { if out.is_empty() {
out = "В папке нет релевантных исходных файлов. Можно создать проект с нуля.".to_string(); out = "В папке нет релевантных исходных файлов. Можно создать проект с нуля.".to_string();
} else { } else {
out.insert_str(0, "Содержимое файлов проекта (полный контекст для анализа):\n"); out.insert_str(
0,
"Содержимое файлов проекта (полный контекст для анализа):\n",
);
} }
out out
} }
@ -127,7 +147,11 @@ fn collect_dir(
let rel = path.strip_prefix(root).unwrap_or(&path); let rel = path.strip_prefix(root).unwrap_or(&path);
let rel_str = rel.display().to_string(); let rel_str = rel.display().to_string();
let truncated = if content.len() > MAX_BYTES_PER_FILE { let truncated = if content.len() > MAX_BYTES_PER_FILE {
format!("{}\n(обрезано, всего {} байт)", &content[..MAX_BYTES_PER_FILE], content.len()) format!(
"{}…\n(обрезано, всего {} байт)",
&content[..MAX_BYTES_PER_FILE],
content.len()
)
} else { } else {
content content
}; };

View File

@ -8,9 +8,7 @@ use crate::types::{Project, ProjectSettings, Session, SessionEvent};
use tauri::Manager; use tauri::Manager;
fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> { fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
app.path() app.path().app_data_dir().map_err(|e| e.to_string())
.app_data_dir()
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@ -20,7 +18,11 @@ pub fn list_projects(app: tauri::AppHandle) -> Result<Vec<Project>, String> {
} }
#[tauri::command] #[tauri::command]
pub fn add_project(app: tauri::AppHandle, path: String, name: Option<String>) -> Result<Project, String> { pub fn add_project(
app: tauri::AppHandle,
path: String,
name: Option<String>,
) -> Result<Project, String> {
let dir = app_data_dir(&app)?; let dir = app_data_dir(&app)?;
let mut projects = load_projects(&dir); let mut projects = load_projects(&dir);
let name = name.unwrap_or_else(|| { let name = name.unwrap_or_else(|| {
@ -47,7 +49,10 @@ pub fn add_project(app: tauri::AppHandle, path: String, name: Option<String>) ->
} }
#[tauri::command] #[tauri::command]
pub fn get_project_settings(app: tauri::AppHandle, project_id: String) -> Result<ProjectSettings, String> { pub fn get_project_settings(
app: tauri::AppHandle,
project_id: String,
) -> Result<ProjectSettings, String> {
let dir = app_data_dir(&app)?; let dir = app_data_dir(&app)?;
let profiles = load_profiles(&dir); let profiles = load_profiles(&dir);
Ok(profiles Ok(profiles
@ -59,6 +64,7 @@ pub fn get_project_settings(app: tauri::AppHandle, project_id: String) -> Result
max_attempts: 2, max_attempts: 2,
max_actions: 12, max_actions: 12,
goal_template: None, goal_template: None,
online_auto_use_as_context: None,
})) }))
} }
@ -71,8 +77,80 @@ pub fn set_project_settings(app: tauri::AppHandle, profile: ProjectSettings) ->
Ok(()) Ok(())
} }
/// B3: Apply a single project setting (whitelist only). Resolves project_id from project_path.
const SETTING_WHITELIST: &[&str] = &[
"auto_check",
"max_attempts",
"max_actions",
"goal_template",
"onlineAutoUseAsContext",
];
#[tauri::command] #[tauri::command]
pub fn list_sessions(app: tauri::AppHandle, project_id: Option<String>) -> Result<Vec<Session>, String> { pub fn apply_project_setting_cmd(
app: tauri::AppHandle,
project_path: String,
key: String,
value: serde_json::Value,
) -> Result<(), String> {
let key = key.trim();
if !SETTING_WHITELIST.contains(&key) {
return Err(format!("Setting not in whitelist: {}", key));
}
let dir = app_data_dir(&app)?;
let projects = load_projects(&dir);
let project_id = projects
.iter()
.find(|p| p.path == project_path)
.map(|p| p.id.as_str())
.ok_or_else(|| "Project not found for path".to_string())?;
let mut profiles = load_profiles(&dir);
let profile = profiles
.get(project_id)
.cloned()
.unwrap_or_else(|| ProjectSettings {
project_id: project_id.to_string(),
auto_check: true,
max_attempts: 2,
max_actions: 12,
goal_template: None,
online_auto_use_as_context: None,
});
let mut updated = profile.clone();
match key {
"auto_check" => {
updated.auto_check = value.as_bool().ok_or("auto_check: expected boolean")?;
}
"max_attempts" => {
let n = value.as_u64().ok_or("max_attempts: expected number")? as u8;
updated.max_attempts = n;
}
"max_actions" => {
let n = value.as_u64().ok_or("max_actions: expected number")? as u16;
updated.max_actions = n;
}
"goal_template" => {
updated.goal_template = value.as_str().map(String::from);
}
"onlineAutoUseAsContext" => {
updated.online_auto_use_as_context = Some(
value
.as_bool()
.ok_or("onlineAutoUseAsContext: expected boolean")?,
);
}
_ => return Err(format!("Setting not in whitelist: {}", key)),
}
profiles.insert(project_id.to_string(), updated);
save_profiles(&dir, &profiles)?;
Ok(())
}
#[tauri::command]
pub fn list_sessions(
app: tauri::AppHandle,
project_id: Option<String>,
) -> Result<Vec<Session>, String> {
let dir = app_data_dir(&app)?; let dir = app_data_dir(&app)?;
let mut sessions = load_sessions(&dir); let mut sessions = load_sessions(&dir);
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));

View File

@ -32,7 +32,11 @@ fn has_license(root: &str) -> bool {
fn extract_error_code(msg: &str) -> &str { fn extract_error_code(msg: &str) -> &str {
if let Some(colon) = msg.find(':') { if let Some(colon) = msg.find(':') {
let prefix = msg[..colon].trim(); let prefix = msg[..colon].trim();
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { if !prefix.is_empty()
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return prefix; return prefix;
} }
} }
@ -40,7 +44,16 @@ fn extract_error_code(msg: &str) -> &str {
} }
const APPLY_TRIGGERS: &[&str] = &[ const APPLY_TRIGGERS: &[&str] = &[
"ok", "ок", "apply", "применяй", "применить", "делай", "да", "yes", "go", "вперёд", "ok",
"ок",
"apply",
"применяй",
"применить",
"делай",
"да",
"yes",
"go",
"вперёд",
]; ];
#[tauri::command] #[tauri::command]
@ -90,13 +103,13 @@ pub async fn propose_actions(
summary: String::new(), summary: String::new(),
actions: vec![], actions: vec![],
error: Some(format!("app data dir: {}", e)), error: Some(format!("app data dir: {}", e)),
error_code: Some("APP_DATA_DIR".into()), error_code: Some("APP_DATA_DIR".into()),
plan_json: None, plan_json: None,
plan_context: None, plan_context: None,
protocol_version_used: None, protocol_version_used: None,
online_fallback_suggested: None, online_fallback_suggested: None,
online_context_used: None, online_context_used: None,
}; };
} }
}; };
let user_prefs_path = app_data.join("papa-yu").join("preferences.json"); let user_prefs_path = app_data.join("papa-yu").join("preferences.json");
@ -118,9 +131,17 @@ pub async fn propose_actions(
Some("apply") Some("apply")
} else if APPLY_TRIGGERS.contains(&goal_lower.as_str()) && last_plan_json.is_some() { } else if APPLY_TRIGGERS.contains(&goal_lower.as_str()) && last_plan_json.is_some() {
Some("apply") Some("apply")
} else if goal_lower.contains("исправь") || goal_lower.contains("почини") || goal_lower.contains("fix ") || goal_lower.contains("исправить") { } else if goal_lower.contains("исправь")
|| goal_lower.contains("почини")
|| goal_lower.contains("fix ")
|| goal_lower.contains("исправить")
{
Some("plan") Some("plan")
} else if goal_lower.contains("создай") || goal_lower.contains("сгенерируй") || goal_lower.contains("create") || goal_lower.contains("с нуля") { } else if goal_lower.contains("создай")
|| goal_lower.contains("сгенерируй")
|| goal_lower.contains("create")
|| goal_lower.contains("с нуля")
{
Some("apply") Some("apply")
} else { } else {
None None
@ -129,14 +150,26 @@ pub async fn propose_actions(
let last_plan_ref = last_plan_json.as_deref(); let last_plan_ref = last_plan_json.as_deref();
let last_ctx_ref = last_context.as_deref(); let last_ctx_ref = last_context.as_deref();
let apply_error = apply_error_code.as_deref().and_then(|code| { let apply_error = apply_error_code.as_deref().and_then(|code| {
apply_error_validated_json.as_deref().map(|json| (code, json)) apply_error_validated_json
.as_deref()
.map(|json| (code, json))
}); });
let force_protocol = { let force_protocol = {
let code = apply_error_code.as_deref().unwrap_or(""); let code = apply_error_code.as_deref().unwrap_or("");
let repair_attempt = apply_repair_attempt.unwrap_or(0); let repair_attempt = apply_repair_attempt.unwrap_or(0);
if llm_planner::is_protocol_fallback_applicable(code, repair_attempt) { if llm_planner::is_protocol_fallback_v3_to_v2_applicable(code, repair_attempt) {
let stage = apply_error_stage.as_deref().unwrap_or("apply"); let stage = apply_error_stage.as_deref().unwrap_or("apply");
eprintln!("[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason={} stage={}", code, stage); eprintln!(
"[trace] PROTOCOL_FALLBACK from=v3 to=v2 reason={} stage={}",
code, stage
);
Some(2u32)
} else if llm_planner::is_protocol_fallback_applicable(code, repair_attempt) {
let stage = apply_error_stage.as_deref().unwrap_or("apply");
eprintln!(
"[trace] PROTOCOL_FALLBACK from=v2 to=v1 reason={} stage={}",
code, stage
);
Some(1u32) Some(1u32)
} else { } else {
None None
@ -179,14 +212,22 @@ pub async fn propose_actions(
) )
.then_some(goal_trim.to_string()); .then_some(goal_trim.to_string());
if online_suggested.is_some() { if online_suggested.is_some() {
eprintln!("[trace] ONLINE_FALLBACK_SUGGESTED error_code={} query_len={}", error_code_str, goal_trim.len()); eprintln!(
"[trace] ONLINE_FALLBACK_SUGGESTED error_code={} query_len={}",
error_code_str,
goal_trim.len()
);
} }
AgentPlan { AgentPlan {
ok: false, ok: false,
summary: String::new(), summary: String::new(),
actions: vec![], actions: vec![],
error: Some(e), error: Some(e),
error_code: Some(if error_code_str.is_empty() { "LLM_ERROR".into() } else { error_code_str }), error_code: Some(if error_code_str.is_empty() {
"LLM_ERROR".into()
} else {
error_code_str
}),
plan_json: None, plan_json: None,
plan_context: None, plan_context: None,
protocol_version_used: None, protocol_version_used: None,
@ -245,6 +286,7 @@ pub async fn propose_actions(
)), )),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
summary.push("Добавлю README.md".into()); summary.push("Добавлю README.md".into());
} }
@ -254,10 +296,12 @@ pub async fn propose_actions(
kind: ActionKind::CreateFile, kind: ActionKind::CreateFile,
path: ".gitignore".into(), path: ".gitignore".into(),
content: Some( content: Some(
"node_modules/\ndist/\nbuild/\n.DS_Store\n.env\n.env.*\ncoverage/\n.target/\n".into(), "node_modules/\ndist/\nbuild/\n.DS_Store\n.env\n.env.*\ncoverage/\n.target/\n"
.into(),
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
summary.push("Добавлю .gitignore".into()); summary.push("Добавлю .gitignore".into());
} }
@ -279,6 +323,7 @@ pub async fn propose_actions(
), ),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
summary.push("Добавлю main.py (скелет)".into()); summary.push("Добавлю main.py (скелет)".into());
} }
@ -291,6 +336,7 @@ pub async fn propose_actions(
content: Some("UNLICENSED\n".into()), content: Some("UNLICENSED\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
summary.push("Добавлю LICENSE (пометка UNLICENSED)".into()); summary.push("Добавлю LICENSE (пометка UNLICENSED)".into());
} }
@ -302,6 +348,7 @@ pub async fn propose_actions(
content: Some("VITE_API_URL=\n# пример, без секретов\n".into()), content: Some("VITE_API_URL=\n# пример, без секретов\n".into()),
patch: None, patch: None,
base_sha256: None, base_sha256: None,
edits: None,
}); });
summary.push("Добавлю .env.example (без секретов)".into()); summary.push("Добавлю .env.example (без секретов)".into());
} }
@ -309,7 +356,8 @@ pub async fn propose_actions(
if actions.is_empty() { if actions.is_empty() {
return AgentPlan { return AgentPlan {
ok: true, ok: true,
summary: "Нет безопасных минимальных правок, которые можно применить автоматически.".into(), summary: "Нет безопасных минимальных правок, которые можно применить автоматически."
.into(),
actions, actions,
error: None, error: None,
error_code: None, error_code: None,

View File

@ -1,7 +1,9 @@
use std::path::Path; use std::path::Path;
use crate::agent_sync;
use crate::commands::get_project_profile::get_project_limits; use crate::commands::get_project_profile::get_project_limits;
use crate::commands::{analyze_project, apply_actions, preview_actions}; use crate::commands::{analyze_project, apply_actions, preview_actions};
use crate::snyk_sync;
use crate::tx::get_undo_redo_state; use crate::tx::get_undo_redo_state;
use crate::types::{BatchEvent, BatchPayload}; use crate::types::{BatchEvent, BatchPayload};
use tauri::AppHandle; use tauri::AppHandle;
@ -15,7 +17,14 @@ pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<Batc
payload.paths.clone() payload.paths.clone()
}; };
let report = analyze_project(paths.clone(), payload.attached_files.clone()).map_err(|e| e.to_string())?; let report = analyze_project(paths.clone(), payload.attached_files.clone())
.map_err(|e| e.to_string())?;
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
snyk_sync::fetch_snyk_code_issues().await.ok()
} else {
None
};
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
events.push(BatchEvent { events.push(BatchEvent {
kind: "report".to_string(), kind: "report".to_string(),
report: Some(report.clone()), report: Some(report.clone()),
@ -25,9 +34,7 @@ pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<Batc
undo_available: None, undo_available: None,
}); });
let actions = payload let actions = payload.selected_actions.unwrap_or(report.actions.clone());
.selected_actions
.unwrap_or(report.actions.clone());
if actions.is_empty() { if actions.is_empty() {
return Ok(events); return Ok(events);
} }

View File

@ -1,223 +1,225 @@
//! v2.4.4: Export/import settings (projects, profiles, sessions, folder_links). //! v2.4.4: Export/import settings (projects, profiles, sessions, folder_links).
use crate::commands::folder_links::{load_folder_links, save_folder_links, FolderLinks}; use crate::commands::folder_links::{load_folder_links, save_folder_links, FolderLinks};
use crate::store::{load_profiles, load_projects, load_sessions, save_profiles, save_projects, save_sessions}; use crate::store::{
use crate::types::{Project, ProjectSettings, Session}; load_profiles, load_projects, load_sessions, save_profiles, save_projects, save_sessions,
use serde::{Deserialize, Serialize}; };
use std::collections::HashMap; use crate::types::{Project, ProjectSettings, Session};
use tauri::Manager; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Bundle of all exportable settings use tauri::Manager;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsBundle { /// Bundle of all exportable settings
pub version: String, #[derive(Debug, Clone, Serialize, Deserialize)]
pub exported_at: String, pub struct SettingsBundle {
pub projects: Vec<Project>, pub version: String,
pub profiles: HashMap<String, ProjectSettings>, pub exported_at: String,
pub sessions: Vec<Session>, pub projects: Vec<Project>,
pub folder_links: FolderLinks, pub profiles: HashMap<String, ProjectSettings>,
} pub sessions: Vec<Session>,
pub folder_links: FolderLinks,
fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> { }
app.path().app_data_dir().map_err(|e| e.to_string())
} fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
app.path().app_data_dir().map_err(|e| e.to_string())
/// Export all settings as JSON string }
#[tauri::command]
pub fn export_settings(app: tauri::AppHandle) -> Result<String, String> { /// Export all settings as JSON string
let dir = app_data_dir(&app)?; #[tauri::command]
pub fn export_settings(app: tauri::AppHandle) -> Result<String, String> {
let bundle = SettingsBundle { let dir = app_data_dir(&app)?;
version: "2.4.4".to_string(),
exported_at: chrono::Utc::now().to_rfc3339(), let bundle = SettingsBundle {
projects: load_projects(&dir), version: "2.4.4".to_string(),
profiles: load_profiles(&dir), exported_at: chrono::Utc::now().to_rfc3339(),
sessions: load_sessions(&dir), projects: load_projects(&dir),
folder_links: load_folder_links(&dir), profiles: load_profiles(&dir),
}; sessions: load_sessions(&dir),
folder_links: load_folder_links(&dir),
serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string()) };
}
serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())
/// Import mode }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] /// Import mode
pub enum ImportMode { #[derive(Debug, Clone, Serialize, Deserialize)]
/// Replace all existing settings #[serde(rename_all = "snake_case")]
Replace, pub enum ImportMode {
/// Merge with existing (don't overwrite existing items) /// Replace all existing settings
Merge, Replace,
} /// Merge with existing (don't overwrite existing items)
Merge,
/// Import settings from JSON string }
#[tauri::command]
pub fn import_settings( /// Import settings from JSON string
app: tauri::AppHandle, #[tauri::command]
json: String, pub fn import_settings(
mode: Option<String>, app: tauri::AppHandle,
) -> Result<ImportResult, String> { json: String,
let bundle: SettingsBundle = serde_json::from_str(&json) mode: Option<String>,
.map_err(|e| format!("Invalid settings JSON: {}", e))?; ) -> Result<ImportResult, String> {
let bundle: SettingsBundle =
let mode = match mode.as_deref() { serde_json::from_str(&json).map_err(|e| format!("Invalid settings JSON: {}", e))?;
Some("replace") => ImportMode::Replace,
_ => ImportMode::Merge, let mode = match mode.as_deref() {
}; Some("replace") => ImportMode::Replace,
_ => ImportMode::Merge,
let dir = app_data_dir(&app)?; };
let mut result = ImportResult { let dir = app_data_dir(&app)?;
projects_imported: 0,
profiles_imported: 0, let mut result = ImportResult {
sessions_imported: 0, projects_imported: 0,
folder_links_imported: 0, profiles_imported: 0,
}; sessions_imported: 0,
folder_links_imported: 0,
match mode { };
ImportMode::Replace => {
// Replace all match mode {
save_projects(&dir, &bundle.projects)?; ImportMode::Replace => {
result.projects_imported = bundle.projects.len(); // Replace all
save_projects(&dir, &bundle.projects)?;
save_profiles(&dir, &bundle.profiles)?; result.projects_imported = bundle.projects.len();
result.profiles_imported = bundle.profiles.len();
save_profiles(&dir, &bundle.profiles)?;
save_sessions(&dir, &bundle.sessions)?; result.profiles_imported = bundle.profiles.len();
result.sessions_imported = bundle.sessions.len();
save_sessions(&dir, &bundle.sessions)?;
save_folder_links(&dir, &bundle.folder_links)?; result.sessions_imported = bundle.sessions.len();
result.folder_links_imported = bundle.folder_links.paths.len();
} save_folder_links(&dir, &bundle.folder_links)?;
ImportMode::Merge => { result.folder_links_imported = bundle.folder_links.paths.len();
// Merge projects }
let mut existing_projects = load_projects(&dir); ImportMode::Merge => {
let existing_paths: std::collections::HashSet<_> = // Merge projects
existing_projects.iter().map(|p| p.path.clone()).collect(); let mut existing_projects = load_projects(&dir);
for p in bundle.projects { let existing_paths: std::collections::HashSet<_> =
if !existing_paths.contains(&p.path) { existing_projects.iter().map(|p| p.path.clone()).collect();
existing_projects.push(p); for p in bundle.projects {
result.projects_imported += 1; if !existing_paths.contains(&p.path) {
} existing_projects.push(p);
} result.projects_imported += 1;
save_projects(&dir, &existing_projects)?; }
}
// Merge profiles save_projects(&dir, &existing_projects)?;
let mut existing_profiles = load_profiles(&dir);
for (k, v) in bundle.profiles { // Merge profiles
if !existing_profiles.contains_key(&k) { let mut existing_profiles = load_profiles(&dir);
existing_profiles.insert(k, v); for (k, v) in bundle.profiles {
result.profiles_imported += 1; if existing_profiles.insert(k, v).is_none() {
} result.profiles_imported += 1;
} }
save_profiles(&dir, &existing_profiles)?; }
save_profiles(&dir, &existing_profiles)?;
// Merge sessions
let mut existing_sessions = load_sessions(&dir); // Merge sessions
let existing_ids: std::collections::HashSet<_> = let mut existing_sessions = load_sessions(&dir);
existing_sessions.iter().map(|s| s.id.clone()).collect(); let existing_ids: std::collections::HashSet<_> =
for s in bundle.sessions { existing_sessions.iter().map(|s| s.id.clone()).collect();
if !existing_ids.contains(&s.id) { for s in bundle.sessions {
existing_sessions.push(s); if !existing_ids.contains(&s.id) {
result.sessions_imported += 1; existing_sessions.push(s);
} result.sessions_imported += 1;
} }
save_sessions(&dir, &existing_sessions)?; }
save_sessions(&dir, &existing_sessions)?;
// Merge folder links
let mut existing_links = load_folder_links(&dir); // Merge folder links
let existing_set: std::collections::HashSet<_> = let mut existing_links = load_folder_links(&dir);
existing_links.paths.iter().cloned().collect(); let existing_set: std::collections::HashSet<_> =
for p in bundle.folder_links.paths { existing_links.paths.iter().cloned().collect();
if !existing_set.contains(&p) { for p in bundle.folder_links.paths {
existing_links.paths.push(p); if !existing_set.contains(&p) {
result.folder_links_imported += 1; existing_links.paths.push(p);
} result.folder_links_imported += 1;
} }
save_folder_links(&dir, &existing_links)?; }
} save_folder_links(&dir, &existing_links)?;
} }
}
Ok(result)
} Ok(result)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult { #[derive(Debug, Clone, Serialize, Deserialize)]
pub projects_imported: usize, pub struct ImportResult {
pub profiles_imported: usize, pub projects_imported: usize,
pub sessions_imported: usize, pub profiles_imported: usize,
pub folder_links_imported: usize, pub sessions_imported: usize,
} pub folder_links_imported: usize,
}
#[cfg(test)]
mod tests { #[cfg(test)]
use super::*; mod tests {
use super::*;
fn create_test_bundle() -> SettingsBundle {
SettingsBundle { fn create_test_bundle() -> SettingsBundle {
version: "2.4.4".to_string(), SettingsBundle {
exported_at: "2025-01-31T00:00:00Z".to_string(), version: "2.4.4".to_string(),
projects: vec![Project { exported_at: "2025-01-31T00:00:00Z".to_string(),
id: "test-id".to_string(), projects: vec![Project {
path: "/test/path".to_string(), id: "test-id".to_string(),
name: "Test Project".to_string(), path: "/test/path".to_string(),
created_at: "2025-01-31T00:00:00Z".to_string(), name: "Test Project".to_string(),
}], created_at: "2025-01-31T00:00:00Z".to_string(),
profiles: HashMap::from([( }],
"test-id".to_string(), profiles: HashMap::from([(
ProjectSettings { "test-id".to_string(),
project_id: "test-id".to_string(), ProjectSettings {
auto_check: true, project_id: "test-id".to_string(),
max_attempts: 3, auto_check: true,
max_actions: 10, max_attempts: 3,
goal_template: Some("Test goal".to_string()), max_actions: 10,
}, goal_template: Some("Test goal".to_string()),
)]), online_auto_use_as_context: None,
sessions: vec![], },
folder_links: FolderLinks { )]),
paths: vec!["/test/folder".to_string()], sessions: vec![],
}, folder_links: FolderLinks {
} paths: vec!["/test/folder".to_string()],
} },
}
#[test] }
fn test_settings_bundle_serialization() {
let bundle = create_test_bundle(); #[test]
let json = serde_json::to_string(&bundle).unwrap(); fn test_settings_bundle_serialization() {
let bundle = create_test_bundle();
assert!(json.contains("\"version\":\"2.4.4\"")); let json = serde_json::to_string(&bundle).unwrap();
assert!(json.contains("\"Test Project\""));
assert!(json.contains("\"/test/folder\"")); assert!(json.contains("\"version\":\"2.4.4\""));
assert!(json.contains("\"Test Project\""));
let parsed: SettingsBundle = serde_json::from_str(&json).unwrap(); assert!(json.contains("\"/test/folder\""));
assert_eq!(parsed.version, "2.4.4");
assert_eq!(parsed.projects.len(), 1); let parsed: SettingsBundle = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.projects[0].name, "Test Project"); assert_eq!(parsed.version, "2.4.4");
} assert_eq!(parsed.projects.len(), 1);
assert_eq!(parsed.projects[0].name, "Test Project");
#[test] }
fn test_settings_bundle_deserialization() {
let json = r#"{ #[test]
"version": "2.4.4", fn test_settings_bundle_deserialization() {
"exported_at": "2025-01-31T00:00:00Z", let json = r#"{
"projects": [], "version": "2.4.4",
"profiles": {}, "exported_at": "2025-01-31T00:00:00Z",
"sessions": [], "projects": [],
"folder_links": { "paths": [] } "profiles": {},
}"#; "sessions": [],
"folder_links": { "paths": [] }
let bundle: SettingsBundle = serde_json::from_str(json).unwrap(); }"#;
assert_eq!(bundle.version, "2.4.4");
assert!(bundle.projects.is_empty()); let bundle: SettingsBundle = serde_json::from_str(json).unwrap();
} assert_eq!(bundle.version, "2.4.4");
assert!(bundle.projects.is_empty());
#[test] }
fn test_import_result_default() {
let result = ImportResult { #[test]
projects_imported: 0, fn test_import_result_default() {
profiles_imported: 0, let result = ImportResult {
sessions_imported: 0, projects_imported: 0,
folder_links_imported: 0, profiles_imported: 0,
}; sessions_imported: 0,
assert_eq!(result.projects_imported, 0); folder_links_imported: 0,
} };
} assert_eq!(result.projects_imported, 0);
}
}

View File

@ -0,0 +1,203 @@
//! Универсальный слой извлечения полей из trace JSON.
//! Корректно работает при разных форматах (root vs result vs request) и эволюции полей.
use serde_json::Value;
fn get_str<'a>(v: &'a Value, path: &[&str]) -> Option<&'a str> {
let mut cur = v;
for p in path {
cur = cur.get(*p)?;
}
cur.as_str()
}
fn get_u64(v: &Value, path: &[&str]) -> Option<u64> {
let mut cur = v;
for p in path {
cur = cur.get(*p)?;
}
cur.as_u64()
}
#[allow(dead_code)]
fn get_arr<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Vec<Value>> {
let mut cur = v;
for p in path {
cur = cur.get(*p)?;
}
cur.as_array()
}
/// mode может жить в разных местах. Возвращаем "plan"/"apply" если нашли.
#[allow(dead_code)]
pub fn trace_mode(trace: &Value) -> Option<&str> {
get_str(trace, &["request", "mode"])
.or_else(|| get_str(trace, &["result", "request", "mode"]))
.or_else(|| get_str(trace, &["request_mode"]))
.or_else(|| get_str(trace, &["mode"]))
}
/// protocol_version_used / schema_version: где реально применили протокол.
/// В papa-yu schema_version (1/2/3) соответствует протоколу.
pub fn trace_protocol_version_used(trace: &Value) -> Option<u8> {
let v = get_u64(trace, &["protocol_version_used"])
.or_else(|| get_u64(trace, &["result", "protocol_version_used"]))
.or_else(|| get_u64(trace, &["plan", "protocol_version_used"]))
.or_else(|| get_u64(trace, &["config_snapshot", "protocol_version_used"]))
.or_else(|| get_u64(trace, &["schema_version"]))
.or_else(|| get_u64(trace, &["config_snapshot", "schema_version"]))?;
u8::try_from(v).ok()
}
/// protocol_attempts: попытки (например [3,2] или ["v3","v2]).
#[allow(dead_code)]
pub fn trace_protocol_attempts(trace: &Value) -> Vec<u8> {
let arr = get_arr(trace, &["protocol_attempts"])
.or_else(|| get_arr(trace, &["result", "protocol_attempts"]))
.or_else(|| get_arr(trace, &["plan", "protocol_attempts"]));
match arr {
Some(a) => a
.iter()
.filter_map(|x| {
x.as_u64().and_then(|n| u8::try_from(n).ok()).or_else(|| {
x.as_str()
.and_then(|s| s.strip_prefix('v').and_then(|n| n.parse::<u8>().ok()))
})
})
.collect(),
None => vec![],
}
}
/// error_code: итоговый код ошибки.
pub fn trace_error_code(trace: &Value) -> Option<String> {
get_str(trace, &["error_code"])
.or_else(|| get_str(trace, &["result", "error_code"]))
.or_else(|| get_str(trace, &["error", "code"]))
.or_else(|| get_str(trace, &["result", "error", "code"]))
.or_else(|| get_str(trace, &["validation_failed", "code"]))
.map(|s| s.to_string())
.or_else(|| {
get_str(trace, &["error"]).map(|s| s.split(':').next().unwrap_or(s).trim().to_string())
})
}
/// protocol_fallback_reason: причина fallback.
pub fn trace_protocol_fallback_reason(trace: &Value) -> Option<String> {
get_str(trace, &["protocol_fallback_reason"])
.or_else(|| get_str(trace, &["result", "protocol_fallback_reason"]))
.map(|s| s.to_string())
}
/// validated_json как объект. Если строка — парсит.
fn trace_validated_json_owned(trace: &Value) -> Option<Value> {
let v = trace
.get("validated_json")
.or_else(|| trace.get("result").and_then(|r| r.get("validated_json")))
.or_else(|| trace.get("trace_val").and_then(|r| r.get("validated_json")))?;
if let Some(s) = v.as_str() {
serde_json::from_str(s).ok()
} else {
Some(v.clone())
}
}
/// actions из validated_json (root.actions или proposed_changes.actions).
pub fn trace_actions(trace: &Value) -> Vec<Value> {
let vj = match trace_validated_json_owned(trace) {
Some(v) => v,
None => return vec![],
};
if let Some(a) = vj.get("actions").and_then(|x| x.as_array()) {
return a.clone();
}
if let Some(a) = vj
.get("proposed_changes")
.and_then(|pc| pc.get("actions"))
.and_then(|x| x.as_array())
{
return a.clone();
}
vec![]
}
/// Есть ли action с kind в actions.
pub fn trace_has_action_kind(trace: &Value, kind: &str) -> bool {
trace_actions(trace)
.iter()
.any(|a| a.get("kind").and_then(|k| k.as_str()) == Some(kind))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trace_mode() {
let t = serde_json::json!({ "request": { "mode": "apply" } });
assert_eq!(trace_mode(&t), Some("apply"));
let t2 = serde_json::json!({ "mode": "plan" });
assert_eq!(trace_mode(&t2), Some("plan"));
}
#[test]
fn test_trace_protocol_version_used() {
let t = serde_json::json!({ "schema_version": 3 });
assert_eq!(trace_protocol_version_used(&t), Some(3));
let t2 = serde_json::json!({ "config_snapshot": { "schema_version": 2 } });
assert_eq!(trace_protocol_version_used(&t2), Some(2));
}
#[test]
fn test_trace_has_action_kind() {
let t = serde_json::json!({
"validated_json": {
"actions": [
{ "kind": "EDIT_FILE", "path": "src/main.rs" },
{ "kind": "CREATE_FILE", "path": "x" }
]
}
});
assert!(trace_has_action_kind(&t, "EDIT_FILE"));
assert!(trace_has_action_kind(&t, "CREATE_FILE"));
assert!(!trace_has_action_kind(&t, "PATCH_FILE"));
}
#[test]
fn test_trace_error_code() {
let t = serde_json::json!({ "error_code": "ERR_EDIT_AMBIGUOUS" });
assert_eq!(trace_error_code(&t).as_deref(), Some("ERR_EDIT_AMBIGUOUS"));
let t2 = serde_json::json!({ "result": { "error_code": "ERR_PATCH_APPLY_FAILED" } });
assert_eq!(
trace_error_code(&t2).as_deref(),
Some("ERR_PATCH_APPLY_FAILED")
);
}
#[test]
fn test_trace_adapters_golden() {
let apply_v3 = serde_json::json!({
"request": { "mode": "apply" },
"schema_version": 3,
"validated_json": {
"actions": [{ "kind": "EDIT_FILE", "path": "src/main.rs" }],
"summary": "Fix"
}
});
assert_eq!(trace_mode(&apply_v3), Some("apply"));
assert_eq!(trace_protocol_version_used(&apply_v3), Some(3));
assert!(trace_has_action_kind(&apply_v3, "EDIT_FILE"));
assert!(!trace_has_action_kind(&apply_v3, "PATCH_FILE"));
let err_trace = serde_json::json!({
"event": "VALIDATION_FAILED",
"schema_version": 3,
"error_code": "ERR_EDIT_AMBIGUOUS"
});
assert_eq!(trace_protocol_version_used(&err_trace), Some(3));
assert_eq!(
trace_error_code(&err_trace).as_deref(),
Some("ERR_EDIT_AMBIGUOUS")
);
}
}

View File

@ -2,7 +2,6 @@
//! Данные хранятся в app_data_dir/trends.json; при первом запуске или если прошло >= 30 дней — should_update = true. //! Данные хранятся в app_data_dir/trends.json; при первом запуске или если прошло >= 30 дней — should_update = true.
use std::fs; use std::fs;
use std::time::Duration;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
@ -16,13 +15,18 @@ fn default_recommendations() -> Vec<TrendsRecommendation> {
vec![ vec![
TrendsRecommendation { TrendsRecommendation {
title: "TypeScript и строгая типизация".to_string(), title: "TypeScript и строгая типизация".to_string(),
summary: Some("Использование TypeScript в веб- и Node-проектах снижает количество ошибок.".to_string()), summary: Some(
"Использование TypeScript в веб- и Node-проектах снижает количество ошибок."
.to_string(),
),
url: Some("https://www.typescriptlang.org/".to_string()), url: Some("https://www.typescriptlang.org/".to_string()),
source: Some("PAPA YU".to_string()), source: Some("PAPA YU".to_string()),
}, },
TrendsRecommendation { TrendsRecommendation {
title: "React Server Components и Next.js".to_string(), title: "React Server Components и Next.js".to_string(),
summary: Some("Тренд на серверный рендеринг и стриминг в React-экосистеме.".to_string()), summary: Some(
"Тренд на серверный рендеринг и стриминг в React-экосистеме.".to_string(),
),
url: Some("https://nextjs.org/".to_string()), url: Some("https://nextjs.org/".to_string()),
source: Some("PAPA YU".to_string()), source: Some("PAPA YU".to_string()),
}, },
@ -34,7 +38,10 @@ fn default_recommendations() -> Vec<TrendsRecommendation> {
}, },
TrendsRecommendation { TrendsRecommendation {
title: "Обновляйте зависимости и линтеры".to_string(), title: "Обновляйте зависимости и линтеры".to_string(),
summary: Some("Регулярно обновляйте npm/cargo зависимости и настройте линтеры (ESLint, Clippy).".to_string()), summary: Some(
"Регулярно обновляйте npm/cargo зависимости и настройте линтеры (ESLint, Clippy)."
.to_string(),
),
url: None, url: None,
source: Some("PAPA YU".to_string()), source: Some("PAPA YU".to_string()),
}, },
@ -93,7 +100,8 @@ pub fn get_trends_recommendations(app: AppHandle) -> TrendsResult {
}; };
} }
}; };
let should_update = parse_and_check_older_than_days(&stored.last_updated, RECOMMEND_UPDATE_DAYS); let should_update =
parse_and_check_older_than_days(&stored.last_updated, RECOMMEND_UPDATE_DAYS);
TrendsResult { TrendsResult {
last_updated: stored.last_updated, last_updated: stored.last_updated,
recommendations: stored.recommendations, recommendations: stored.recommendations,
@ -114,7 +122,11 @@ fn parse_and_check_older_than_days(iso: &str, days: i64) -> bool {
} }
/// Разрешённые URL для запроса трендов (только эти домены). /// Разрешённые URL для запроса трендов (только эти домены).
const ALLOWED_TRENDS_HOSTS: &[&str] = &["raw.githubusercontent.com", "api.github.com", "jsonplaceholder.typicode.com"]; const ALLOWED_TRENDS_HOSTS: &[&str] = &[
"raw.githubusercontent.com",
"api.github.com",
"jsonplaceholder.typicode.com",
];
fn url_allowed(url: &str) -> bool { fn url_allowed(url: &str) -> bool {
let url = url.trim().to_lowercase(); let url = url.trim().to_lowercase();
@ -123,7 +135,9 @@ fn url_allowed(url: &str) -> bool {
} }
let rest = url.strip_prefix("https://").unwrap_or(""); let rest = url.strip_prefix("https://").unwrap_or("");
let host = rest.split('/').next().unwrap_or(""); let host = rest.split('/').next().unwrap_or("");
ALLOWED_TRENDS_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{}", h))) ALLOWED_TRENDS_HOSTS
.iter()
.any(|h| host == *h || host.ends_with(&format!(".{}", h)))
} }
/// Обновляет тренды: запрашивает данные по allowlist URL (PAPAYU_TRENDS_URL или встроенный список) и сохраняет. /// Обновляет тренды: запрашивает данные по allowlist URL (PAPAYU_TRENDS_URL или встроенный список) и сохраняет.
@ -134,33 +148,45 @@ pub async fn fetch_trends_recommendations(app: AppHandle) -> TrendsResult {
let urls: Vec<String> = std::env::var("PAPAYU_TRENDS_URLS") let urls: Vec<String> = std::env::var("PAPAYU_TRENDS_URLS")
.ok() .ok()
.map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()) .map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect()
})
.unwrap_or_else(Vec::new); .unwrap_or_else(Vec::new);
let mut recommendations = Vec::new(); let mut recommendations = Vec::new();
const MAX_TRENDS_RESPONSE_BYTES: usize = 1_000_000;
const TRENDS_FETCH_TIMEOUT_SEC: u64 = 15;
if !urls.is_empty() { if !urls.is_empty() {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.unwrap_or_default();
for url in urls { for url in urls {
if !url_allowed(&url) { if !url_allowed(&url) {
continue; continue;
} }
if let Ok(resp) = client.get(&url).send().await { match crate::net::fetch_url_safe(
if let Ok(text) = resp.text().await { &url,
if let Ok(parsed) = serde_json::from_str::<Vec<TrendsRecommendation>>(&text) { MAX_TRENDS_RESPONSE_BYTES,
TRENDS_FETCH_TIMEOUT_SEC,
)
.await
{
Ok(body) => {
if let Ok(parsed) = serde_json::from_str::<Vec<TrendsRecommendation>>(&body) {
recommendations.extend(parsed); recommendations.extend(parsed);
} else if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&text) { } else if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(arr) = obj.get("recommendations").and_then(|a| a.as_array()) { if let Some(arr) = obj.get("recommendations").and_then(|a| a.as_array()) {
for v in arr { for v in arr {
if let Ok(r) = serde_json::from_value::<TrendsRecommendation>(v.clone()) { if let Ok(r) =
serde_json::from_value::<TrendsRecommendation>(v.clone())
{
recommendations.push(r); recommendations.push(r);
} }
} }
} }
} }
} }
Err(_) => {}
} }
} }
} }
@ -173,7 +199,10 @@ pub async fn fetch_trends_recommendations(app: AppHandle) -> TrendsResult {
recommendations: recommendations.clone(), recommendations: recommendations.clone(),
}; };
if let Ok(path) = app_trends_path(&app) { if let Ok(path) = app_trends_path(&app) {
let _ = fs::write(path, serde_json::to_string_pretty(&stored).unwrap_or_default()); let _ = fs::write(
path,
serde_json::to_string_pretty(&stored).unwrap_or_default(),
);
} }
TrendsResult { TrendsResult {

View File

@ -11,12 +11,20 @@ use crate::types::UndoStatus;
pub async fn undo_status(app: AppHandle) -> UndoStatus { pub async fn undo_status(app: AppHandle) -> UndoStatus {
let base: PathBuf = match app.path().app_data_dir() { let base: PathBuf = match app.path().app_data_dir() {
Ok(v) => v, Ok(v) => v,
Err(_) => return UndoStatus { available: false, tx_id: None }, Err(_) => {
return UndoStatus {
available: false,
tx_id: None,
}
}
}; };
let dir = base.join("history").join("tx"); let dir = base.join("history").join("tx");
let Ok(rd) = fs::read_dir(&dir) else { let Ok(rd) = fs::read_dir(&dir) else {
return UndoStatus { available: false, tx_id: None }; return UndoStatus {
available: false,
tx_id: None,
};
}; };
let last = rd let last = rd
@ -31,6 +39,9 @@ pub async fn undo_status(app: AppHandle) -> UndoStatus {
tx_id: Some(name), tx_id: Some(name),
} }
} }
None => UndoStatus { available: false, tx_id: None }, None => UndoStatus {
available: false,
tx_id: None,
},
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
//! Автосбор контекста для LLM: env, project prefs, context_requests (read_file, search, logs). //! Автосбор контекста для LLM: env, project prefs, context_requests (read_file, search, logs).
//! Кеш read/search/logs/env в пределах сессии (plan-цикла). //! Кеш read/search/logs/env в пределах сессии (plan-цикла).
//! Protocol v2: FILE[path] (sha256=...) для base_sha256 в PATCH_FILE. //! Protocol v2/v3: FILE[path] (sha256=...) для base_sha256 в PATCH_FILE/EDIT_FILE.
use crate::memory::EngineeringMemory; use crate::memory::EngineeringMemory;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -133,13 +133,22 @@ pub fn gather_base_context(_project_root: &Path, mem: &EngineeringMemory) -> Str
if !mem.project.is_default() { if !mem.project.is_default() {
let mut prefs = Vec::new(); let mut prefs = Vec::new();
if !mem.project.default_test_command.is_empty() { if !mem.project.default_test_command.is_empty() {
prefs.push(format!("default_test_command: {}", mem.project.default_test_command)); prefs.push(format!(
"default_test_command: {}",
mem.project.default_test_command
));
} }
if !mem.project.default_lint_command.is_empty() { if !mem.project.default_lint_command.is_empty() {
prefs.push(format!("default_lint_command: {}", mem.project.default_lint_command)); prefs.push(format!(
"default_lint_command: {}",
mem.project.default_lint_command
));
} }
if !mem.project.default_format_command.is_empty() { if !mem.project.default_format_command.is_empty() {
prefs.push(format!("default_format_command: {}", mem.project.default_format_command)); prefs.push(format!(
"default_format_command: {}",
mem.project.default_format_command
));
} }
if !mem.project.src_roots.is_empty() { if !mem.project.src_roots.is_empty() {
prefs.push(format!("src_roots: {:?}", mem.project.src_roots)); prefs.push(format!("src_roots: {:?}", mem.project.src_roots));
@ -190,7 +199,7 @@ pub struct FulfillResult {
/// Выполняет context_requests от модели и возвращает текст для добавления в user message. /// Выполняет context_requests от модели и возвращает текст для добавления в user message.
/// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id. /// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id.
/// При protocol_version=2 добавляет sha256 в FILE-блоки: FILE[path] (sha256=...). /// При protocol_version>=2 (v2 PATCH_FILE, v3 EDIT_FILE) добавляет sha256 в FILE-блоки: FILE[path] (sha256=...).
pub fn fulfill_context_requests( pub fn fulfill_context_requests(
project_root: &Path, project_root: &Path,
requests: &[serde_json::Value], requests: &[serde_json::Value],
@ -198,7 +207,7 @@ pub fn fulfill_context_requests(
mut cache: Option<&mut ContextCache>, mut cache: Option<&mut ContextCache>,
trace_id: Option<&str>, trace_id: Option<&str>,
) -> FulfillResult { ) -> FulfillResult {
let include_sha256 = protocol_version() == 2; let include_sha256 = protocol_version() >= 2;
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut logs_chars: usize = 0; let mut logs_chars: usize = 0;
for r in requests { for r in requests {
@ -225,25 +234,43 @@ pub fn fulfill_context_requests(
if let Some(v) = hit { if let Some(v) = hit {
c.cache_stats.read_hits += 1; c.cache_stats.read_hits += 1;
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=read_file path={}", tid, path); eprintln!(
"[{}] CONTEXT_CACHE_HIT key=read_file path={}",
tid, path
);
} }
v v
} else { } else {
c.cache_stats.read_misses += 1; c.cache_stats.read_misses += 1;
let (snippet, sha) = read_file_snippet_with_sha256(project_root, path, start as usize, end as usize); let (snippet, sha) = read_file_snippet_with_sha256(
project_root,
path,
start as usize,
end as usize,
);
let out = if include_sha256 && !sha.is_empty() { let out = if include_sha256 && !sha.is_empty() {
format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet) format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet)
} else { } else {
format!("FILE[{}]:\n{}", path, snippet) format!("FILE[{}]:\n{}", path, snippet)
}; };
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=read_file path={} size={}", tid, path, out.len()); eprintln!(
"[{}] CONTEXT_CACHE_MISS key=read_file path={} size={}",
tid,
path,
out.len()
);
} }
c.put(key, out.clone()); c.put(key, out.clone());
out out
} }
} else { } else {
let (snippet, sha) = read_file_snippet_with_sha256(project_root, path, start as usize, end as usize); let (snippet, sha) = read_file_snippet_with_sha256(
project_root,
path,
start as usize,
end as usize,
);
if include_sha256 && !sha.is_empty() { if include_sha256 && !sha.is_empty() {
format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet) format!("FILE[{}] (sha256={}):\n{}", path, sha, snippet)
} else { } else {
@ -273,7 +300,12 @@ pub fn fulfill_context_requests(
let hits = search_in_project(project_root, query, glob.as_deref()); let hits = search_in_project(project_root, query, glob.as_deref());
let out = format!("SEARCH[{}]:\n{}", query, hits.join("\n")); let out = format!("SEARCH[{}]:\n{}", query, hits.join("\n"));
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=search query={} hits={}", tid, query, hits.len()); eprintln!(
"[{}] CONTEXT_CACHE_MISS key=search query={} hits={}",
tid,
query,
hits.len()
);
} }
c.put(key, out.clone()); c.put(key, out.clone());
out out
@ -286,7 +318,10 @@ pub fn fulfill_context_requests(
} }
} }
"logs" => { "logs" => {
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("runtime"); let source = obj
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("runtime");
let last_n = obj let last_n = obj
.get("last_n") .get("last_n")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@ -295,17 +330,17 @@ pub fn fulfill_context_requests(
source: source.to_string(), source: source.to_string(),
last_n, last_n,
}; };
let content = if let Some(ref mut c) = cache { let content = if let Some(ref mut c) = cache {
let hit = c.get(&key).map(|v| v.clone()); let hit = c.get(&key).map(|v| v.clone());
if let Some(v) = hit { if let Some(v) = hit {
c.cache_stats.logs_hits += 1; c.cache_stats.logs_hits += 1;
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source); eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source);
} }
v v
} else { } else {
c.cache_stats.logs_misses += 1; c.cache_stats.logs_misses += 1;
let v = format!( let v = format!(
"LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n", "LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n",
source, last_n source, last_n
); );
@ -326,17 +361,17 @@ pub fn fulfill_context_requests(
} }
"env" => { "env" => {
let key = ContextCacheKey::Env; let key = ContextCacheKey::Env;
let content = if let Some(ref mut c) = cache { let content = if let Some(ref mut c) = cache {
let hit = c.get(&key).map(|v| v.clone()); let hit = c.get(&key).map(|v| v.clone());
if let Some(v) = hit { if let Some(v) = hit {
c.cache_stats.env_hits += 1; c.cache_stats.env_hits += 1;
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid); eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid);
} }
v v
} else { } else {
c.cache_stats.env_misses += 1; c.cache_stats.env_misses += 1;
let v = format!("ENV (повторно):\n{}", gather_env()); let v = format!("ENV (повторно):\n{}", gather_env());
if let Some(tid) = trace_id { if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=env size={}", tid, v.len()); eprintln!("[{}] CONTEXT_CACHE_MISS key=env size={}", tid, v.len());
} }
@ -404,7 +439,10 @@ pub fn fulfill_context_requests(
result_parts.push(to_add); result_parts.push(to_add);
} }
let content = format!("{}{}", header, result_parts.join("\n\n")); let content = format!("{}{}", header, result_parts.join("\n\n"));
let files_in_result = result_parts.iter().filter(|s| s.starts_with("FILE[")).count() as u32; let files_in_result = result_parts
.iter()
.filter(|s| s.starts_with("FILE["))
.count() as u32;
let context_stats = ContextStats { let context_stats = ContextStats {
context_files_count: files_in_result, context_files_count: files_in_result,
context_files_dropped_count: dropped as u32, context_files_dropped_count: dropped as u32,
@ -416,11 +454,18 @@ pub fn fulfill_context_requests(
if dropped > 0 || truncated > 0 { if dropped > 0 || truncated > 0 {
eprintln!( eprintln!(
"[{}] CONTEXT_DIET_APPLIED files={} dropped={} truncated={} total_chars={}", "[{}] CONTEXT_DIET_APPLIED files={} dropped={} truncated={} total_chars={}",
tid, result_parts.len(), dropped, truncated, total_chars tid,
result_parts.len(),
dropped,
truncated,
total_chars
); );
} }
} }
FulfillResult { content, context_stats } FulfillResult {
content,
context_stats,
}
} }
} }
@ -447,7 +492,12 @@ fn read_file_snippet_with_sha256(
let lines: Vec<&str> = full_content.lines().collect(); let lines: Vec<&str> = full_content.lines().collect();
let start = start_line.saturating_sub(1).min(lines.len()); let start = start_line.saturating_sub(1).min(lines.len());
let end = end_line.min(lines.len()).max(start); let end = end_line.min(lines.len()).max(start);
let slice: Vec<&str> = lines.get(start..end).unwrap_or(&[]).into_iter().copied().collect(); let slice: Vec<&str> = lines
.get(start..end)
.unwrap_or(&[])
.into_iter()
.copied()
.collect();
let mut out = String::new(); let mut out = String::new();
for (i, line) in slice.iter().enumerate() { for (i, line) in slice.iter().enumerate() {
let line_no = start + i + 1; let line_no = start + i + 1;
@ -481,7 +531,12 @@ fn read_file_snippet(root: &Path, rel_path: &str, start_line: usize, end_line: u
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let start = start_line.saturating_sub(1).min(lines.len()); let start = start_line.saturating_sub(1).min(lines.len());
let end = end_line.min(lines.len()).max(start); let end = end_line.min(lines.len()).max(start);
let slice: Vec<&str> = lines.get(start..end).unwrap_or(&[]).into_iter().copied().collect(); let slice: Vec<&str> = lines
.get(start..end)
.unwrap_or(&[])
.into_iter()
.copied()
.collect();
let mut out = String::new(); let mut out = String::new();
for (i, line) in slice.iter().enumerate() { for (i, line) in slice.iter().enumerate() {
let line_no = start + i + 1; let line_no = start + i + 1;
@ -521,8 +576,10 @@ fn search_in_project(root: &Path, query: &str, _glob: Option<&str>) -> Vec<Strin
continue; continue;
} }
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_text = ["py", "rs", "ts", "tsx", "js", "jsx", "md", "json", "toml", "yml", "yaml"] let is_text = [
.contains(&ext); "py", "rs", "ts", "tsx", "js", "jsx", "md", "json", "toml", "yml", "yaml",
]
.contains(&ext);
if !is_text { if !is_text {
continue; continue;
} }
@ -590,7 +647,12 @@ pub fn gather_auto_context_from_message(project_root: &Path, user_message: &str)
{ {
parts.push(format!("ENV (для ImportError):\n{}", gather_env())); parts.push(format!("ENV (для ImportError):\n{}", gather_env()));
// Попытаться добавить содержимое pyproject.toml, requirements.txt, package.json // Попытаться добавить содержимое pyproject.toml, requirements.txt, package.json
for rel in ["pyproject.toml", "requirements.txt", "package.json", "poetry.lock"] { for rel in [
"pyproject.toml",
"requirements.txt",
"package.json",
"poetry.lock",
] {
let p = project_root.join(rel); let p = project_root.join(rel);
if p.is_file() { if p.is_file() {
if let Ok(s) = fs::read_to_string(&p) { if let Ok(s) = fs::read_to_string(&p) {
@ -613,7 +675,9 @@ pub fn gather_auto_context_from_message(project_root: &Path, user_message: &str)
} }
/// Извлекает path → sha256 из контекста (FILE[path] (sha256=...):). Для диагностики и repair. /// Извлекает path → sha256 из контекста (FILE[path] (sha256=...):). Для диагностики и repair.
pub fn extract_file_sha256_from_context(context: &str) -> std::collections::HashMap<String, String> { pub fn extract_file_sha256_from_context(
context: &str,
) -> std::collections::HashMap<String, String> {
use std::collections::HashMap; use std::collections::HashMap;
let mut m = HashMap::new(); let mut m = HashMap::new();
for line in context.lines() { for line in context.lines() {
@ -720,15 +784,33 @@ mod tests {
fn test_cache_logs_key_includes_last_n() { fn test_cache_logs_key_includes_last_n() {
let mut cache = ContextCache::new(); let mut cache = ContextCache::new();
cache.put( cache.put(
ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 }, ContextCacheKey::Logs {
source: "runtime".to_string(),
last_n: 200,
},
"LOGS last_n=200".to_string(), "LOGS last_n=200".to_string(),
); );
cache.put( cache.put(
ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 }, ContextCacheKey::Logs {
source: "runtime".to_string(),
last_n: 500,
},
"LOGS last_n=500".to_string(), "LOGS last_n=500".to_string(),
); );
assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 }).unwrap().contains("200")); assert!(cache
assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 }).unwrap().contains("500")); .get(&ContextCacheKey::Logs {
source: "runtime".to_string(),
last_n: 200
})
.unwrap()
.contains("200"));
assert!(cache
.get(&ContextCacheKey::Logs {
source: "runtime".to_string(),
last_n: 500
})
.unwrap()
.contains("500"));
} }
#[test] #[test]
@ -763,7 +845,10 @@ FILE[src/main.rs]:
fn main() {}"#; fn main() {}"#;
let m = extract_file_sha256_from_context(ctx); let m = extract_file_sha256_from_context(ctx);
assert_eq!(m.len(), 1); assert_eq!(m.len(), 1);
assert_eq!(m.get("src/parser.py").map(|s| s.as_str()), Some("7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a")); assert_eq!(
m.get("src/parser.py").map(|s| s.as_str()),
Some("7f3f2a0c9f8b1a0c9b4c0f9e3d8a4b2d8c9e7f1a0b3c4d5e6f7a8b9c0d1e2f3a")
);
// src/main.rs без sha256 — не попадёт // src/main.rs без sha256 — не попадёт
assert!(m.get("src/main.rs").is_none()); assert!(m.get("src/main.rs").is_none());
@ -787,7 +872,9 @@ fn main() {}"#;
fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2"); std::env::set_var("PAPAYU_PROTOCOL_VERSION", "2");
let reqs = vec![serde_json::json!({"type": "read_file", "path": "src/main.rs", "start_line": 1, "end_line": 10})]; let reqs = vec![
serde_json::json!({"type": "read_file", "path": "src/main.rs", "start_line": 1, "end_line": 10}),
];
let result = fulfill_context_requests(root, &reqs, 200, None, None); let result = fulfill_context_requests(root, &reqs, 200, None, None);
std::env::remove_var("PAPAYU_PROTOCOL_VERSION"); std::env::remove_var("PAPAYU_PROTOCOL_VERSION");
assert!(result.content.contains("FILE[src/main.rs] (sha256=")); assert!(result.content.contains("FILE[src/main.rs] (sha256="));

View File

@ -0,0 +1,176 @@
//! Distill OnlineAnswer into a short domain note via LLM (topic, tags, content_md).
use jsonschema::JSONSchema;
use serde::Deserialize;
use super::storage::{
load_domain_notes, notes_max_chars_per_note, notes_ttl_days, save_domain_notes, DomainNote,
NoteSource,
};
use std::path::Path;
const DISTILL_SYSTEM_PROMPT: &str = r#"Сожми текст до 510 буллетов, только факты из источников, без воды.
Максимум 800 символов в content_md. topic короткая тема (до 10 слов). tags до 8 ключевых слов (python, testing, api и т.д.).
confidence от 0 до 1 по надёжности источников. Не выдумывай."#;
#[derive(Debug, Deserialize)]
struct DistillOutput {
topic: String,
tags: Vec<String>,
content_md: String,
confidence: f64,
}
/// Distills answer_md + sources into a short note via LLM, then appends to project notes and saves.
pub async fn distill_and_save_note(
project_path: &Path,
query: &str,
answer_md: &str,
sources: &[(String, String)],
_confidence: f64,
) -> Result<DomainNote, String> {
let max_chars = notes_max_chars_per_note();
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../config/llm_domain_note_schema.json"))
.map_err(|e| format!("schema: {}", e))?;
let sources_block = sources
.iter()
.take(10)
.map(|(url, title)| format!("- {}: {}", title, url))
.collect::<Vec<_>>()
.join("\n");
let user_content = format!(
"Запрос: {}\n\nОтвет (сжать):\n{}\n\nИсточники:\n{}\n\nВерни topic, tags (до 8), content_md (макс. {} символов), confidence (0-1).",
query,
if answer_md.len() > 4000 {
format!("{}...", &answer_md[..4000])
} else {
answer_md.to_string()
},
sources_block,
max_chars
);
let response_format = serde_json::json!({
"type": "json_schema",
"json_schema": {
"name": "domain_note",
"schema": schema,
"strict": true
}
});
let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set")?;
let api_url = api_url.trim();
if api_url.is_empty() {
return Err("PAPAYU_LLM_API_URL is empty".into());
}
let model = std::env::var("PAPAYU_ONLINE_MODEL")
.or_else(|_| std::env::var("PAPAYU_LLM_MODEL"))
.unwrap_or_else(|_| "gpt-4o-mini".to_string());
let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
let body = serde_json::json!({
"model": model.trim(),
"messages": [
{ "role": "system", "content": DISTILL_SYSTEM_PROMPT },
{ "role": "user", "content": user_content }
],
"temperature": 0.2,
"max_tokens": 1024,
"response_format": response_format
});
let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(30);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_sec))
.build()
.map_err(|e| format!("HTTP: {}", e))?;
let mut req = client.post(api_url).json(&body);
if let Some(ref key) = api_key {
if !key.trim().is_empty() {
req = req.header("Authorization", format!("Bearer {}", key.trim()));
}
}
let resp = req.send().await.map_err(|e| format!("Request: {}", e))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| format!("Response: {}", e))?;
if !status.is_success() {
return Err(format!("API {}: {}", status, text));
}
let chat: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("JSON: {}", e))?;
let content = chat
.get("choices")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or("No content in response")?;
let report: serde_json::Value =
serde_json::from_str(content).map_err(|e| format!("Report JSON: {}", e))?;
let compiled = JSONSchema::options()
.with_draft(jsonschema::Draft::Draft7)
.compile(&schema)
.map_err(|e| format!("Schema: {}", e))?;
if let Err(e) = compiled.validate(&report) {
let msg: Vec<String> = e.map(|ve| format!("{}", ve)).collect();
return Err(format!("Validation: {}", msg.join("; ")));
}
let out: DistillOutput = serde_json::from_value(report).map_err(|e| format!("Parse: {}", e))?;
let content_md = if out.content_md.chars().count() > max_chars {
out.content_md.chars().take(max_chars).collect::<String>() + "..."
} else {
out.content_md
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let id = format!("note_{}_{:03}", now, (now % 1000).unsigned_abs());
let note_sources: Vec<NoteSource> = sources
.iter()
.take(10)
.map(|(url, title)| NoteSource {
url: url.clone(),
title: title.clone(),
})
.collect();
let note = DomainNote {
id: id.clone(),
created_at: now,
topic: out.topic,
tags: out.tags.into_iter().take(8).collect(),
content_md,
sources: note_sources,
confidence: out.confidence,
ttl_days: notes_ttl_days(),
usage_count: 0,
last_used_at: None,
pinned: false,
};
let mut data = load_domain_notes(project_path);
data.notes.push(note.clone());
save_domain_notes(project_path, data)?;
Ok(note)
}

View File

@ -0,0 +1,15 @@
//! Domain notes: curated short notes from online research, stored per project.
//!
//! File: `.papa-yu/notes/domain_notes.json`
//! Env: PAPAYU_NOTES_MAX_ITEMS, PAPAYU_NOTES_MAX_CHARS_PER_NOTE, PAPAYU_NOTES_MAX_TOTAL_CHARS, PAPAYU_NOTES_TTL_DAYS
mod distill;
mod selection;
mod storage;
pub use distill::distill_and_save_note;
pub use selection::get_notes_block_for_prompt;
pub use storage::{
clear_expired_notes, delete_note, load_domain_notes, pin_note, save_domain_notes, DomainNote,
DomainNotes, NoteSource,
};

View File

@ -0,0 +1,101 @@
//! Select relevant notes for goal and build PROJECT_DOMAIN_NOTES block.
use std::path::Path;
use super::storage::{
load_domain_notes, mark_note_used, notes_max_total_chars, save_domain_notes, DomainNote,
};
/// Simple tokenize: split on whitespace, lowercase, non-empty.
fn tokenize(s: &str) -> std::collections::HashSet<String> {
s.to_lowercase()
.split_whitespace()
.filter(|w| w.len() > 1)
.map(|w| w.to_string())
.collect()
}
/// Score note relevance to goal by token overlap (tags, topic, content_md).
fn score_note(goal_tokens: &std::collections::HashSet<String>, note: &DomainNote) -> usize {
let topic_tags = tokenize(&note.topic);
let tags: std::collections::HashSet<String> =
note.tags.iter().map(|t| t.to_lowercase()).collect();
let content = tokenize(&note.content_md);
let mut all = topic_tags;
all.extend(tags);
all.extend(content);
goal_tokens.intersection(&all).count()
}
/// Select notes most relevant to goal_text, up to max_total_chars. Returns (selected notes, total chars).
pub fn select_relevant_notes(
goal_text: &str,
notes: &[DomainNote],
max_total_chars: usize,
) -> Vec<DomainNote> {
let goal_tokens = tokenize(goal_text);
if goal_tokens.is_empty() {
return notes.iter().take(10).cloned().collect();
}
let mut scored: Vec<(usize, DomainNote)> = notes
.iter()
.map(|n| (score_note(&goal_tokens, n), n.clone()))
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
let mut out = Vec::new();
let mut total = 0usize;
for (_, note) in scored {
let len = note.content_md.len() + note.topic.len() + 50;
if total + len > max_total_chars && !out.is_empty() {
break;
}
total += len;
out.push(note);
}
out
}
/// Build PROJECT_DOMAIN_NOTES block text.
fn build_notes_block(notes: &[DomainNote]) -> String {
let mut s = String::from("\n\nPROJECT_DOMAIN_NOTES (curated, may be stale):\n");
for n in notes {
s.push_str(&format!("- [{}] {}\n", n.topic, n.content_md));
if !n.sources.is_empty() {
let urls: Vec<&str> = n.sources.iter().take(3).map(|x| x.url.as_str()).collect();
s.push_str(&format!(" sources: {}\n", urls.join(", ")));
}
}
s
}
/// Load notes, select relevant to goal, build block, mark used, save. Returns (block, note_ids, chars_used).
pub fn get_notes_block_for_prompt(
project_path: &Path,
goal_text: &str,
) -> Option<(String, Vec<String>, usize)> {
let mut data = load_domain_notes(project_path);
if data.notes.is_empty() {
return None;
}
let max_chars = notes_max_total_chars();
let selected = select_relevant_notes(goal_text, &data.notes, max_chars);
if selected.is_empty() {
return None;
}
let ids: Vec<String> = selected.iter().map(|n| n.id.clone()).collect();
let block = build_notes_block(&selected);
let chars_used = block.chars().count();
for id in &ids {
if let Some(n) = data.notes.iter_mut().find(|x| x.id == *id) {
mark_note_used(n);
}
}
let _ = save_domain_notes(project_path, data);
Some((block, ids, chars_used))
}

View File

@ -0,0 +1,258 @@
//! Load/save domain_notes.json and eviction.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainNotes {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub updated_at: i64,
pub notes: Vec<DomainNote>,
}
fn default_schema_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainNote {
pub id: String,
#[serde(default)]
pub created_at: i64,
pub topic: String,
#[serde(default)]
pub tags: Vec<String>,
pub content_md: String,
#[serde(default)]
pub sources: Vec<NoteSource>,
#[serde(default)]
pub confidence: f64,
#[serde(default = "default_ttl_days")]
pub ttl_days: u32,
#[serde(default)]
pub usage_count: u32,
#[serde(default)]
pub last_used_at: Option<i64>,
#[serde(default)]
pub pinned: bool,
}
fn default_ttl_days() -> u32 {
notes_ttl_days()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteSource {
pub url: String,
#[serde(default)]
pub title: String,
}
/// PAPAYU_NOTES_MAX_ITEMS (default 50)
pub fn notes_max_items() -> usize {
std::env::var("PAPAYU_NOTES_MAX_ITEMS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(50)
.clamp(5, 200)
}
/// PAPAYU_NOTES_MAX_CHARS_PER_NOTE (default 800)
pub fn notes_max_chars_per_note() -> usize {
std::env::var("PAPAYU_NOTES_MAX_CHARS_PER_NOTE")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(800)
.clamp(128, 2000)
}
/// PAPAYU_NOTES_MAX_TOTAL_CHARS (default 4000)
pub fn notes_max_total_chars() -> usize {
std::env::var("PAPAYU_NOTES_MAX_TOTAL_CHARS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(4000)
.clamp(512, 16000)
}
/// PAPAYU_NOTES_TTL_DAYS (default 30)
pub fn notes_ttl_days() -> u32 {
std::env::var("PAPAYU_NOTES_TTL_DAYS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(30)
.clamp(1, 365)
}
fn notes_file_path(project_path: &Path) -> std::path::PathBuf {
project_path
.join(".papa-yu")
.join("notes")
.join("domain_notes.json")
}
/// Load domain notes from project. Returns empty notes if file missing or invalid.
pub fn load_domain_notes(project_path: &Path) -> DomainNotes {
let path = notes_file_path(project_path);
let Ok(data) = fs::read_to_string(&path) else {
return DomainNotes {
schema_version: 1,
updated_at: 0,
notes: vec![],
};
};
match serde_json::from_str::<DomainNotes>(&data) {
Ok(mut d) => {
d.notes.retain(|n| !is_note_expired(n));
d
}
Err(_) => DomainNotes {
schema_version: 1,
updated_at: 0,
notes: vec![],
},
}
}
/// Returns true if note is past TTL.
pub fn is_note_expired(note: &DomainNote) -> bool {
let ttl_sec = (note.ttl_days as i64) * 24 * 3600;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
now - note.created_at > ttl_sec
}
/// Evict: drop expired, then by LRU (least recently used first: last_used_at, usage_count, created_at) until <= max_items.
/// Pinned notes are never evicted.
fn evict_notes(notes: &mut Vec<DomainNote>, max_items: usize) {
notes.retain(|n| !is_note_expired(n) || n.pinned);
if notes.len() <= max_items {
return;
}
let (pinned, mut non_pinned): (Vec<DomainNote>, Vec<DomainNote>) =
notes.drain(..).partition(|n| n.pinned);
non_pinned.sort_by(|a, b| {
let a_used = a.last_used_at.unwrap_or(0);
let b_used = b.last_used_at.unwrap_or(0);
a_used
.cmp(&b_used)
.then_with(|| a.usage_count.cmp(&b.usage_count))
.then_with(|| a.created_at.cmp(&b.created_at))
});
let keep_count = max_items.saturating_sub(pinned.len());
let to_take = keep_count.min(non_pinned.len());
let start = non_pinned.len().saturating_sub(to_take);
let kept: Vec<DomainNote> = non_pinned.drain(start..).collect();
notes.extend(pinned);
notes.extend(kept);
}
/// Save domain notes to project. Creates .papa-yu/notes if needed. Applies eviction before save.
pub fn save_domain_notes(project_path: &Path, mut data: DomainNotes) -> Result<(), String> {
let max_items = notes_max_items();
evict_notes(&mut data.notes, max_items);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|e| e.to_string())?;
data.updated_at = now;
let path = notes_file_path(project_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create notes dir: {}", e))?;
}
let json = serde_json::to_string_pretty(&data).map_err(|e| format!("serialize: {}", e))?;
fs::write(&path, json).map_err(|e| format!("write: {}", e))?;
Ok(())
}
/// Mark a note as used (usage_count += 1, last_used_at = now). Call after injecting into prompt.
pub fn mark_note_used(note: &mut DomainNote) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
note.usage_count = note.usage_count.saturating_add(1);
note.last_used_at = Some(now);
}
/// Delete note by id. Returns true if removed.
pub fn delete_note(project_path: &Path, note_id: &str) -> Result<bool, String> {
let mut data = load_domain_notes(project_path);
let len_before = data.notes.len();
data.notes.retain(|n| n.id != note_id);
let removed = data.notes.len() < len_before;
if removed {
save_domain_notes(project_path, data)?;
}
Ok(removed)
}
/// Remove expired notes (non-pinned). Returns count removed.
pub fn clear_expired_notes(project_path: &Path) -> Result<usize, String> {
let mut data = load_domain_notes(project_path);
let before = data.notes.len();
data.notes.retain(|n| !is_note_expired(n) || n.pinned);
let removed = before - data.notes.len();
if removed > 0 {
save_domain_notes(project_path, data)?;
}
Ok(removed)
}
/// Set pinned flag for a note.
pub fn pin_note(project_path: &Path, note_id: &str, pinned: bool) -> Result<bool, String> {
let mut data = load_domain_notes(project_path);
let mut found = false;
for n in &mut data.notes {
if n.id == note_id {
n.pinned = pinned;
found = true;
break;
}
}
if found {
save_domain_notes(project_path, data)?;
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_note_expired_fresh() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let note = DomainNote {
id: "x".into(),
created_at: now - 1000,
topic: "t".into(),
tags: vec![],
content_md: "c".into(),
sources: vec![],
confidence: 0.8,
ttl_days: 30,
usage_count: 0,
last_used_at: None,
pinned: false,
};
assert!(!is_note_expired(&note));
}
#[test]
fn test_notes_limits_defaults() {
std::env::remove_var("PAPAYU_NOTES_MAX_ITEMS");
assert!(notes_max_items() >= 5 && notes_max_items() <= 200);
assert!(notes_max_chars_per_note() >= 128);
assert!(notes_max_total_chars() >= 512);
}
}

View File

@ -1,7 +1,11 @@
mod agent_sync;
mod commands; mod commands;
mod snyk_sync;
mod context; mod context;
mod online_research; mod domain_notes;
mod memory; mod memory;
mod net;
mod online_research;
mod patch; mod patch;
mod protocol; mod protocol;
mod store; mod store;
@ -9,14 +13,32 @@ mod tx;
mod types; mod types;
mod verify; mod verify;
use commands::{add_project, agentic_run, analyze_project, analyze_weekly_reports, append_session_event, apply_actions, apply_actions_tx, export_settings, fetch_trends_recommendations, generate_actions, generate_actions_from_report, get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd, import_settings, list_projects, list_sessions, load_folder_links, preview_actions, propose_actions, redo_last, run_batch, save_folder_links, save_report_to_file, set_project_settings, undo_available, undo_last, undo_last_tx, undo_status};
use tauri::Manager;
use commands::FolderLinks; use commands::FolderLinks;
use commands::{
add_project, agentic_run, analyze_project, analyze_weekly_reports, append_session_event,
apply_actions, apply_actions_tx, apply_project_setting_cmd, export_settings,
fetch_trends_recommendations, generate_actions, generate_actions_from_report,
get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd,
import_settings, list_projects, list_sessions, load_folder_links, preview_actions,
propose_actions, redo_last, run_batch, save_folder_links, save_report_to_file,
set_project_settings, undo_available, undo_last, undo_last_tx, undo_status,
};
use tauri::Manager;
use types::{ApplyPayload, BatchPayload}; use types::{ApplyPayload, BatchPayload};
#[tauri::command] #[tauri::command]
fn analyze_project_cmd(paths: Vec<String>, attached_files: Option<Vec<String>>) -> Result<types::AnalyzeReport, String> { async fn analyze_project_cmd(
analyze_project(paths, attached_files) paths: Vec<String>,
attached_files: Option<Vec<String>>,
) -> Result<types::AnalyzeReport, String> {
let report = analyze_project(paths, attached_files)?;
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
snyk_sync::fetch_snyk_code_issues().await.ok()
} else {
None
};
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
Ok(report)
} }
#[tauri::command] #[tauri::command]
@ -30,7 +52,10 @@ fn apply_actions_cmd(app: tauri::AppHandle, payload: ApplyPayload) -> types::App
} }
#[tauri::command] #[tauri::command]
async fn run_batch_cmd(app: tauri::AppHandle, payload: BatchPayload) -> Result<Vec<types::BatchEvent>, String> { async fn run_batch_cmd(
app: tauri::AppHandle,
payload: BatchPayload,
) -> Result<Vec<types::BatchEvent>, String> {
run_batch(app, payload).await run_batch(app, payload).await
} }
@ -62,15 +87,75 @@ async fn analyze_weekly_reports_cmd(
analyze_weekly_reports(std::path::Path::new(&project_path), from, to).await analyze_weekly_reports(std::path::Path::new(&project_path), from, to).await
} }
/// Online research: поиск + fetch + LLM summarize. /// Online research: поиск + fetch + LLM summarize. Optional project_path → cache in project .papa-yu/cache/.
#[tauri::command] #[tauri::command]
async fn research_answer_cmd(query: String) -> Result<online_research::OnlineAnswer, String> { async fn research_answer_cmd(
online_research::research_answer(&query).await query: String,
project_path: Option<String>,
) -> Result<online_research::OnlineAnswer, String> {
let path_ref = project_path.as_deref().map(std::path::Path::new);
online_research::research_answer(&query, path_ref).await
}
/// Domain notes: load for project.
#[tauri::command]
fn load_domain_notes_cmd(project_path: String) -> domain_notes::DomainNotes {
domain_notes::load_domain_notes(std::path::Path::new(&project_path))
}
/// Domain notes: save (after UI edit).
#[tauri::command]
fn save_domain_notes_cmd(
project_path: String,
data: domain_notes::DomainNotes,
) -> Result<(), String> {
domain_notes::save_domain_notes(std::path::Path::new(&project_path), data)
}
/// Domain notes: delete note by id.
#[tauri::command]
fn delete_domain_note_cmd(project_path: String, note_id: String) -> Result<bool, String> {
domain_notes::delete_note(std::path::Path::new(&project_path), &note_id)
}
/// Domain notes: clear expired (non-pinned). Returns count removed.
#[tauri::command]
fn clear_expired_domain_notes_cmd(project_path: String) -> Result<usize, String> {
domain_notes::clear_expired_notes(std::path::Path::new(&project_path))
}
/// Domain notes: set pinned.
#[tauri::command]
fn pin_domain_note_cmd(
project_path: String,
note_id: String,
pinned: bool,
) -> Result<bool, String> {
domain_notes::pin_note(std::path::Path::new(&project_path), &note_id, pinned)
}
/// Domain notes: distill OnlineAnswer into a short note and save.
#[tauri::command]
async fn distill_and_save_domain_note_cmd(
project_path: String,
query: String,
answer_md: String,
sources: Vec<domain_notes::NoteSource>,
confidence: f64,
) -> Result<domain_notes::DomainNote, String> {
let path = std::path::Path::new(&project_path);
let sources_tuples: Vec<(String, String)> =
sources.into_iter().map(|s| (s.url, s.title)).collect();
domain_notes::distill_and_save_note(path, &query, &answer_md, &sources_tuples, confidence).await
} }
/// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md. /// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md.
#[tauri::command] #[tauri::command]
fn save_report_cmd(project_path: String, report_md: String, date: Option<String>) -> Result<String, String> { fn save_report_cmd(
project_path: String,
report_md: String,
date: Option<String>,
) -> Result<String, String> {
save_report_to_file( save_report_to_file(
std::path::Path::new(&project_path), std::path::Path::new(&project_path),
&report_md, &report_md,
@ -83,6 +168,8 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
analyze_project_cmd, analyze_project_cmd,
preview_actions_cmd, preview_actions_cmd,
@ -111,11 +198,19 @@ pub fn run() {
append_session_event, append_session_event,
get_trends_recommendations, get_trends_recommendations,
fetch_trends_recommendations, fetch_trends_recommendations,
commands::design_trends::research_design_trends,
export_settings, export_settings,
import_settings, import_settings,
analyze_weekly_reports_cmd, analyze_weekly_reports_cmd,
save_report_cmd, save_report_cmd,
research_answer_cmd, research_answer_cmd,
load_domain_notes_cmd,
save_domain_notes_cmd,
delete_domain_note_cmd,
clear_expired_domain_notes_cmd,
pin_domain_note_cmd,
distill_and_save_domain_note_cmd,
apply_project_setting_cmd,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -154,47 +154,88 @@ pub fn build_memory_block(mem: &EngineeringMemory) -> String {
if !mem.user.is_default() { if !mem.user.is_default() {
let mut user = serde_json::Map::new(); let mut user = serde_json::Map::new();
if !mem.user.preferred_style.is_empty() { if !mem.user.preferred_style.is_empty() {
user.insert("preferred_style".into(), serde_json::Value::String(mem.user.preferred_style.clone())); user.insert(
"preferred_style".into(),
serde_json::Value::String(mem.user.preferred_style.clone()),
);
} }
if mem.user.ask_budget > 0 { if mem.user.ask_budget > 0 {
user.insert("ask_budget".into(), serde_json::Value::Number(serde_json::Number::from(mem.user.ask_budget))); user.insert(
"ask_budget".into(),
serde_json::Value::Number(serde_json::Number::from(mem.user.ask_budget)),
);
} }
if !mem.user.risk_tolerance.is_empty() { if !mem.user.risk_tolerance.is_empty() {
user.insert("risk_tolerance".into(), serde_json::Value::String(mem.user.risk_tolerance.clone())); user.insert(
"risk_tolerance".into(),
serde_json::Value::String(mem.user.risk_tolerance.clone()),
);
} }
if !mem.user.default_language.is_empty() { if !mem.user.default_language.is_empty() {
user.insert("default_language".into(), serde_json::Value::String(mem.user.default_language.clone())); user.insert(
"default_language".into(),
serde_json::Value::String(mem.user.default_language.clone()),
);
} }
if !mem.user.output_format.is_empty() { if !mem.user.output_format.is_empty() {
user.insert("output_format".into(), serde_json::Value::String(mem.user.output_format.clone())); user.insert(
"output_format".into(),
serde_json::Value::String(mem.user.output_format.clone()),
);
} }
obj.insert("user".into(), serde_json::Value::Object(user)); obj.insert("user".into(), serde_json::Value::Object(user));
} }
if !mem.project.is_default() { if !mem.project.is_default() {
let mut project = serde_json::Map::new(); let mut project = serde_json::Map::new();
if !mem.project.default_test_command.is_empty() { if !mem.project.default_test_command.is_empty() {
project.insert("default_test_command".into(), serde_json::Value::String(mem.project.default_test_command.clone())); project.insert(
"default_test_command".into(),
serde_json::Value::String(mem.project.default_test_command.clone()),
);
} }
if !mem.project.default_lint_command.is_empty() { if !mem.project.default_lint_command.is_empty() {
project.insert("default_lint_command".into(), serde_json::Value::String(mem.project.default_lint_command.clone())); project.insert(
"default_lint_command".into(),
serde_json::Value::String(mem.project.default_lint_command.clone()),
);
} }
if !mem.project.default_format_command.is_empty() { if !mem.project.default_format_command.is_empty() {
project.insert("default_format_command".into(), serde_json::Value::String(mem.project.default_format_command.clone())); project.insert(
"default_format_command".into(),
serde_json::Value::String(mem.project.default_format_command.clone()),
);
} }
if !mem.project.package_manager.is_empty() { if !mem.project.package_manager.is_empty() {
project.insert("package_manager".into(), serde_json::Value::String(mem.project.package_manager.clone())); project.insert(
"package_manager".into(),
serde_json::Value::String(mem.project.package_manager.clone()),
);
} }
if !mem.project.build_command.is_empty() { if !mem.project.build_command.is_empty() {
project.insert("build_command".into(), serde_json::Value::String(mem.project.build_command.clone())); project.insert(
"build_command".into(),
serde_json::Value::String(mem.project.build_command.clone()),
);
} }
if !mem.project.src_roots.is_empty() { if !mem.project.src_roots.is_empty() {
project.insert("src_roots".into(), serde_json::to_value(&mem.project.src_roots).unwrap_or(serde_json::Value::Array(vec![]))); project.insert(
"src_roots".into(),
serde_json::to_value(&mem.project.src_roots)
.unwrap_or(serde_json::Value::Array(vec![])),
);
} }
if !mem.project.test_roots.is_empty() { if !mem.project.test_roots.is_empty() {
project.insert("test_roots".into(), serde_json::to_value(&mem.project.test_roots).unwrap_or(serde_json::Value::Array(vec![]))); project.insert(
"test_roots".into(),
serde_json::to_value(&mem.project.test_roots)
.unwrap_or(serde_json::Value::Array(vec![])),
);
} }
if !mem.project.ci_notes.is_empty() { if !mem.project.ci_notes.is_empty() {
project.insert("ci_notes".into(), serde_json::Value::String(mem.project.ci_notes.clone())); project.insert(
"ci_notes".into(),
serde_json::Value::String(mem.project.ci_notes.clone()),
);
} }
obj.insert("project".into(), serde_json::Value::Object(project)); obj.insert("project".into(), serde_json::Value::Object(project));
} }
@ -223,28 +264,82 @@ pub fn apply_memory_patch(
if key.starts_with("user.") { if key.starts_with("user.") {
let field = &key[5..]; let field = &key[5..];
match field { match field {
"preferred_style" => if let Some(s) = value.as_str() { user.preferred_style = s.to_string(); }, "preferred_style" => {
"ask_budget" => if let Some(n) = value.as_u64() { user.ask_budget = n as u8; }, if let Some(s) = value.as_str() {
"risk_tolerance" => if let Some(s) = value.as_str() { user.risk_tolerance = s.to_string(); }, user.preferred_style = s.to_string();
"default_language" => if let Some(s) = value.as_str() { user.default_language = s.to_string(); }, }
"output_format" => if let Some(s) = value.as_str() { user.output_format = s.to_string(); }, }
"ask_budget" => {
if let Some(n) = value.as_u64() {
user.ask_budget = n as u8;
}
}
"risk_tolerance" => {
if let Some(s) = value.as_str() {
user.risk_tolerance = s.to_string();
}
}
"default_language" => {
if let Some(s) = value.as_str() {
user.default_language = s.to_string();
}
}
"output_format" => {
if let Some(s) = value.as_str() {
user.output_format = s.to_string();
}
}
_ => {} _ => {}
} }
} else if key.starts_with("project.") { } else if key.starts_with("project.") {
let field = &key[8..]; let field = &key[8..];
match field { match field {
"default_test_command" => if let Some(s) = value.as_str() { project.default_test_command = s.to_string(); }, "default_test_command" => {
"default_lint_command" => if let Some(s) = value.as_str() { project.default_lint_command = s.to_string(); }, if let Some(s) = value.as_str() {
"default_format_command" => if let Some(s) = value.as_str() { project.default_format_command = s.to_string(); }, project.default_test_command = s.to_string();
"package_manager" => if let Some(s) = value.as_str() { project.package_manager = s.to_string(); }, }
"build_command" => if let Some(s) = value.as_str() { project.build_command = s.to_string(); }, }
"src_roots" => if let Some(arr) = value.as_array() { "default_lint_command" => {
project.src_roots = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(); if let Some(s) = value.as_str() {
}, project.default_lint_command = s.to_string();
"test_roots" => if let Some(arr) = value.as_array() { }
project.test_roots = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(); }
}, "default_format_command" => {
"ci_notes" => if let Some(s) = value.as_str() { project.ci_notes = s.to_string(); }, if let Some(s) = value.as_str() {
project.default_format_command = s.to_string();
}
}
"package_manager" => {
if let Some(s) = value.as_str() {
project.package_manager = s.to_string();
}
}
"build_command" => {
if let Some(s) = value.as_str() {
project.build_command = s.to_string();
}
}
"src_roots" => {
if let Some(arr) = value.as_array() {
project.src_roots = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
"test_roots" => {
if let Some(arr) = value.as_array() {
project.test_roots = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
"ci_notes" => {
if let Some(s) = value.as_str() {
project.ci_notes = s.to_string();
}
}
_ => {} _ => {}
} }
} }
@ -292,9 +387,16 @@ mod tests {
#[test] #[test]
fn apply_patch_updates_user_and_project() { fn apply_patch_updates_user_and_project() {
let mut patch = HashMap::new(); let mut patch = HashMap::new();
patch.insert("user.preferred_style".into(), serde_json::Value::String("brief".into())); patch.insert(
patch.insert("project.default_test_command".into(), serde_json::Value::String("pytest -q".into())); "user.preferred_style".into(),
let (user, project) = apply_memory_patch(&patch, &UserPrefs::default(), &ProjectPrefs::default()); serde_json::Value::String("brief".into()),
);
patch.insert(
"project.default_test_command".into(),
serde_json::Value::String("pytest -q".into()),
);
let (user, project) =
apply_memory_patch(&patch, &UserPrefs::default(), &ProjectPrefs::default());
assert_eq!(user.preferred_style, "brief"); assert_eq!(user.preferred_style, "brief");
assert_eq!(project.default_test_command, "pytest -q"); assert_eq!(project.default_test_command, "pytest -q");
} }

8
src-tauri/src/net.rs Normal file
View File

@ -0,0 +1,8 @@
//! Единая точка сетевого доступа.
//!
//! Политика:
//! - Все fetch внешних URL (от пользователя, API, конфига) — через `fetch_url_safe`.
//! - LLM/API вызовы на доверенные URL из env — через reqwest с таймаутами.
//! - Запрет: прямой `reqwest::get()` для URL извне без проверки.
pub use crate::online_research::fetch_url_safe;

View File

@ -1,120 +1,120 @@
//! Извлечение текста из HTML. //! Извлечение текста из HTML.
use scraper::{Html, Selector}; use scraper::{Html, Selector};
pub(crate) const MAX_CHARS: usize = 40_000; pub(crate) const MAX_CHARS: usize = 40_000;
/// Извлекает текст из HTML: убирает script/style, берёт body, нормализует пробелы. /// Извлекает текст из HTML: убирает script/style, берёт body, нормализует пробелы.
pub fn extract_text(html: &str) -> String { pub fn extract_text(html: &str) -> String {
let doc = Html::parse_document(html); let doc = Html::parse_document(html);
let body_html = match Selector::parse("body") { let body_html = match Selector::parse("body") {
Ok(s) => doc.select(&s).next().map(|el| el.html()), Ok(s) => doc.select(&s).next().map(|el| el.html()),
Err(_) => None, Err(_) => None,
}; };
let fragment = body_html.unwrap_or_else(|| doc.root_element().html()); let fragment = body_html.unwrap_or_else(|| doc.root_element().html());
let without_script = remove_tag_content(&fragment, "script"); let without_script = remove_tag_content(&fragment, "script");
let without_style = remove_tag_content(&without_script, "style"); let without_style = remove_tag_content(&without_script, "style");
let without_noscript = remove_tag_content(&without_style, "noscript"); let without_noscript = remove_tag_content(&without_style, "noscript");
let cleaned = strip_tags_simple(&without_noscript); let cleaned = strip_tags_simple(&without_noscript);
let normalized = normalize_whitespace(&cleaned); let normalized = normalize_whitespace(&cleaned);
truncate_to(&normalized, MAX_CHARS) truncate_to(&normalized, MAX_CHARS)
} }
fn remove_tag_content(html: &str, tag: &str) -> String { fn remove_tag_content(html: &str, tag: &str) -> String {
let open = format!("<{}", tag); let open = format!("<{}", tag);
let close = format!("</{}>", tag); let close = format!("</{}>", tag);
let mut out = String::with_capacity(html.len()); let mut out = String::with_capacity(html.len());
let mut i = 0; let mut i = 0;
let bytes = html.as_bytes(); let bytes = html.as_bytes();
while i < bytes.len() { while i < bytes.len() {
if let Some(start) = find_ignore_case(bytes, i, &open) { if let Some(start) = find_ignore_case(bytes, i, &open) {
let after_open = start + open.len(); let after_open = start + open.len();
if let Some(end) = find_ignore_case(bytes, after_open, &close) { if let Some(end) = find_ignore_case(bytes, after_open, &close) {
out.push_str(&html[i..start]); out.push_str(&html[i..start]);
i = end + close.len(); i = end + close.len();
continue; continue;
} }
} }
if i < bytes.len() { if i < bytes.len() {
out.push(html.chars().nth(i).unwrap_or(' ')); out.push(html.chars().nth(i).unwrap_or(' '));
i += 1; i += 1;
} }
} }
if out.is_empty() { if out.is_empty() {
html.to_string() html.to_string()
} else { } else {
out out
} }
} }
fn find_ignore_case(haystack: &[u8], start: usize, needle: &str) -> Option<usize> { fn find_ignore_case(haystack: &[u8], start: usize, needle: &str) -> Option<usize> {
let needle_bytes = needle.as_bytes(); let needle_bytes = needle.as_bytes();
haystack[start..] haystack[start..]
.windows(needle_bytes.len()) .windows(needle_bytes.len())
.position(|w| w.eq_ignore_ascii_case(needle_bytes)) .position(|w| w.eq_ignore_ascii_case(needle_bytes))
.map(|p| start + p) .map(|p| start + p)
} }
fn strip_tags_simple(html: &str) -> String { fn strip_tags_simple(html: &str) -> String {
let doc = Html::parse_fragment(html); let doc = Html::parse_fragment(html);
let root = doc.root_element(); let root = doc.root_element();
let mut text = root.text().collect::<Vec<_>>().join(" "); let mut text = root.text().collect::<Vec<_>>().join(" ");
text = text.replace("\u{a0}", " "); text = text.replace("\u{a0}", " ");
text text
} }
fn normalize_whitespace(s: &str) -> String { fn normalize_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
let mut prev_space = false; let mut prev_space = false;
for c in s.chars() { for c in s.chars() {
if c.is_whitespace() { if c.is_whitespace() {
if !prev_space { if !prev_space {
out.push(' '); out.push(' ');
prev_space = true; prev_space = true;
} }
} else { } else {
out.push(c); out.push(c);
prev_space = false; prev_space = false;
} }
} }
out.trim().to_string() out.trim().to_string()
} }
pub(crate) fn truncate_to(s: &str, max: usize) -> String { pub(crate) fn truncate_to(s: &str, max: usize) -> String {
if s.chars().count() <= max { if s.chars().count() <= max {
s.to_string() s.to_string()
} else { } else {
s.chars().take(max).collect::<String>() + "..." s.chars().take(max).collect::<String>() + "..."
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_extract_text_basic() { fn test_extract_text_basic() {
let html = r#"<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>"#; let html = r#"<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>"#;
let t = extract_text(html); let t = extract_text(html);
assert!(t.contains("Title")); assert!(t.contains("Title"));
assert!(t.contains("Paragraph")); assert!(t.contains("Paragraph"));
} }
#[test] #[test]
fn test_extract_removes_script() { fn test_extract_removes_script() {
let html = r#"<body><p>Hello</p><script>alert(1)</script><p>World</p></body>"#; let html = r#"<body><p>Hello</p><script>alert(1)</script><p>World</p></body>"#;
let t = extract_text(html); let t = extract_text(html);
assert!(!t.contains("alert")); assert!(!t.contains("alert"));
assert!(t.contains("Hello")); assert!(t.contains("Hello"));
assert!(t.contains("World")); assert!(t.contains("World"));
} }
#[test] #[test]
fn test_truncate_to() { fn test_truncate_to() {
let s = "a".repeat(50_000); let s = "a".repeat(50_000);
let t = super::truncate_to(&s, super::MAX_CHARS); let t = super::truncate_to(&s, super::MAX_CHARS);
assert!(t.ends_with("...")); assert!(t.ends_with("..."));
assert!(t.chars().count() <= super::MAX_CHARS + 3); assert!(t.chars().count() <= super::MAX_CHARS + 3);
} }
} }

Some files were not shown because too many files have changed in this diff Show More