[2026-06-25] taiga-vault: family/how-to/htpc-emulators-setup.md family/how-to/htpc-gaming-plans.md family/how-to/kraken-access.md family/how-to/openmediavault-rpi5.md family/how-to/time-machine.md family/how-to/wireguard-vpn.md personal/documents/todo-list.md personal/plans/extract-stable-prompt-blocks.md personal/plans/hermes-whale-system-prompt.md personal/plans/thread-scoped-memory.md

This commit is contained in:
Taiga
2026-06-25 05:23:46 +00:00
parent ac0d753ec0
commit 16987d69f3
26 changed files with 2656 additions and 457 deletions
+160 -172
View File
@@ -1,197 +1,185 @@
# Балда / Валера — эксплуатация
# Балда / Валера — эксплуатация & MCP debug
## Расположение
- Repo: `~/Developer/balda/` (ветка `main`)
- Code: upstream `normahq/balda` + `feat/zulip-transport` влит, `normahq/norma` v0.0.10
- **Рабочий compose**: `~/Docker/balda-agent/docker-compose.yaml` → контейнер `balda-agent-valera-1`
- Config (persistent): `~/Docker/balda-agent/.config/balda/config.yaml`
- Env: `~/Docker/balda-agent/.env`
- Owner: `allowed_owners` в config.yaml (статически, `/start owner=` не нужен)
- **Валера** = `balda-agent-valera-1`, конфиг: `~/Docker/balda-agent/.config/balda/config.yaml`
- **Клавдий** = `claudio-agent-claudio-1`, конфиг: `~/Docker/claudio-agent/.config/balda/config.yaml`
- **Общий образ**: собирается из `~/Docker/claudio-agent/Dockerfile.claudio`, контекст `/Users/admin/Developer`
- **Norma-local** (форк с MCP tools): `~/Developer/norma-local/`
- `go.mod` replace: `github.com/normahq/norma => ./norma-local``~/Developer/balda/go.mod`)
- Модифицирован `pkg/runtime/hostedagent/openai.go` + `pkg/runtime/agentfactory/agentfactory.go`
- **Dockerfile**: `~/Docker/claudio-agent/Dockerfile.claudio`
- Копирует `norma-local/` в `/src/norma-local/`
- `go mod edit -replace github.com/normahq/norma=./norma-local` перед `go mod download`
## Запуск / рестарт
## Архитектура
- **Валера** = DeepSeek (`provider: deepseek`, модель `deepseek-chat`)
- **Клавдий** = Claude через прокси (`provider: claude`, `claude-sonnet-4-6`)
- Валера использует **Balda runtime** — OpenAIModel из openai.go вызывается (hostedagent провайдер).
- `agentfactory.go` используется обоими — там лог версии на старте.
## Версионный лог (norma-tools)
При старте Валеры в логах:
```
norma-tools version=v0.0.10 build=whale-YYYYMMDD-N
```
Где:
- `version` — номер версии нормы (из `openAIVersion`)
- `build` — тег сборки (из `buildTag`)
**Перед каждым билдом апать `buildTag`** в `openai.go`:
```go
const openAIVersion = "v0.0.10"
const buildTag = "whale-YYYYMMDD-N" // ← менять!
```
Файлы где апать:
- `~/Developer/norma-local/pkg/runtime/hostedagent/openai.go``buildTag` константа
## Статус: DeepSeek MCP tools — РАБОТАЕТ
### Что сделано (openai.go)
Добавлена полная поддержка OpenAI tool_calls:
- `openAIToolDefinition`, `openAIFunction`, `openAIToolCall` — структуры
- `Tools []openAIToolDefinition` в `openAIChatRequest`
- `openAIToolsFromConfig()` — конвертация genai.Tool[] → OpenAI definitions
- `parseChatResponse()` — парсинг tool_calls из ответа DeepSeek (ID сохраняется)
- `contentToOpenAI()` — конвертация FunctionCall/FunctionResponse в историю
- `generate()` — TurnComplete=false при tool_calls (ADK делает второй раунд)
### Исправленные проблемы
#### 1. DeepSeek возвращает аргументы с двойной сериализацией ✓ 22.06.2026
**Симптом:** `read_note`/`write_note` падали — Obsidian MCP возвращал `"Cannot read properties of undefined (reading 'replace')"`.
**Корень:** DeepSeek возвращает `function.arguments` как JSON-строку (экранированную), а не как JSON-объект.
**Фикс в `parseChatResponse`:** сперва пробуем распарсить Arguments как строку (`json.Unmarshal(&argsStr)`), потом эту строку как объект (`json.Unmarshal([]byte(argsStr), &args)`).
#### 2. Obsidian MCP: get_vault_stats работает, read/write/delete нет ✓ 22.06.2026
**Корень:** двойная сериализация аргументов (см. проблему 1). После её фикса всё работает.
#### 3. Key `output` не проверялся в contentToOpenAI ✓ 22.06.2026
Obsidian MCP возвращает response как `{"output":"..."}` — добавлена проверка на ключ `output`.
### Текущий билд
- buildTag: `whale-20260622-7`
- Все фиксы закоммичены в `master` (норма-локаль)
- OPENAI-DEBUG стэш дропнут — логи в рабочей копии (не коммитятся)
- Replace на норму работает через `go.mod` + Dockerfile
- `norma-tools` лог подтверждён: `time=2026-06-22T13:00:37.170Z level=INFO msg=norma-tools version=v0.0.10 build=whale-20260622-7`
## Промежуточные статусы — ПОЧИНЕНО (23.06.2026)
**Статус:** работает.
- `RunSessionTurnPayload` (zulip_handler.go) — публикует промежуточный текст и ⚙️ function call статусы для `!ev.TurnComplete` ивентов через `sendPlain`.
- `balda.go` (Telegram) — те же промежуточные публикации.
- `handleAutoClaimMention` / `handleMessage` / `enqueueTurn` — передают `messageID` для точного Zulip threading.
- Коммит: `05159d1` (main), запушен.
## Стэши debug-логов (23.06.2026)
Дебаг логи не коммитятся, хранятся в стэшах.
**balda** (`~/Developer/balda`):
- `stash@{0}`: `debug: whale-20260623 — TASK-ACTOR logging in swarm_task_actor, event count & response_len debug in zulip_handler`
- `internal/apps/balda/actors/swarm_task_actor.go` — TASK-ACTOR: dispatching/dispatch OK/FAILED
- `internal/apps/balda/handlers/zulip_handler.go` — eventCount, response_len, running session turn log
- `stash@{1}`: WIP on feat/zulip-events-polling
**Восстановление balda стэша:**
```bash
cd ~/Docker/balda-agent
docker-compose restart # без пересборки, только конфиг/env не менялись
docker-compose up -d # пересоздать контейнер, перечитать .env
# Rebuild после изменений в коде:
cd ~/Docker/claudio-agent && docker-compose build
docker tag claudio-agent-claudio:latest balda-agent-valera:latest
cd ~/Docker/claudio-agent && docker-compose up -d
cd ~/Docker/balda-agent && docker-compose up -d
cd ~/Developer/balda && git stash pop stash@{0}
```
> Валера и Клавдий — **один образ** (`balda-agent-valera`), собирается из одного Dockerfile (`Dockerfile.claudio`). Разница только в конфиге (`config.yaml`) и `.env`, которые монтируются volumes. Сборка идёт из `~/Docker/claudio-agent/` — там есть build секция.
**norma-local** (`~/Developer/norma-local`):
- OPENAI-DEBUG стэш дропнут — 9x fmt.Fprintf(os.Stderr, "OPENAI-DEBUG:...") в рабочей копии openai.go
## Архитектура получения сообщений
Валера — **outgoing webhook bot** (bot_type=3). Zulip отправляет webhook только для @mention и DM.
## Бранчи (23.06.2026)
- `events_polling.enabled: false` в config.yaml
- Сообщения приходят только через **outgoing webhook** (webhook_token в .env)
- После @mention создаётся сессия; последующие сообщения без @mention обрабатываются
**norma-local:**
- `feat/hostedagent-mcp-tools` — коммит `56a6e1e` (DeepSeek tool_calls фикс), чистая бранча от `origin/master`
- `master` — коммит `56a6e1e` (тот же, DeepSeek фиксы)
**После перезапуска**: написать `@Валера <текст>` чтобы создать сессию.
**balda:**
- `main``05159d1` (intermediate status фикс запушен)
- `backup/our-main-before-upstream` — старый main
- `feat/zulip-transport-intermediate` — от коммита `bc5fd03`
## MCP Obsidian — конфиг
Balda через SSE подключается к obsidian-mcp. **URL обязательно с `/sse`**:
## Сборка
```yaml
runtime:
mcp_servers:
obsidian:
type: sse
url: http://obsidian-mcp:3101/sse # <-- без /sse → 404 Not Found
name: obsidian
```
## Контекст (история сообщений)
**Работает** (на upstream `normahq/balda` с поддержкой hosted LLM agent sessions). ADK inject'ит историю корректно для OpenAI-совместимых провайдеров.
Раньше не работало — исправлено в upstream `32e33a8 fix: support hosted MCP toolsets` / `d774c78 fix: use hosted llmagent sessions`.
## Проблема: MCP не работают у Валеры (provider: type=openai)
### Коренная причина
hostedagent (`pkg/runtime/hostedagent/`) не поддерживает MCP инструменты.
`openAIConstructor` в `agentfactory.go` получает `resolvedMCP map[string]agentconfig.MCPServerConfig`,
но не передаёт их в `hostedagent.Config` — у Config нет поля для MCP.
`openAIConstructor` вызывает `newHostedAgent(hostedagent.Config{...})` без MCPServers.
Фикс: в `hostedagent.Config` добавить `MCPServers map[string]agentconfig.MCPServerConfig`,
а в `openAIConstructor` передавать `toRuntimeMCPServers(resolvedMCP)`.
Важно: hostedagent использует OpenAI-compatible API (не ACP). MCP инструменты нужно
интегрировать через OpenAI tool_calls — модель шлёт tool_call, код выполняет MCP вызов.
## Проблема: MCP не работают у Валеры — попытка фикса
### Что было сделано (2026-06-19)
#### 1. go.mod
Добавлен `replace github.com/normahq/norma => ../norma-local` — для локальной разработки.
#### 2. hostedagent — добавлена поддержка MCP
- `pkg/runtime/hostedagent/agent.go`:
- В Config добавлено `MCPServers map[string]acpagent.MCPServerConfig`
- Сохранено в `runtimeAgent`
- В `run()`: после создания модели вызывается `model.SetTools(openAIToolsFromMCPServers(r.MCPServers))`
- `pkg/runtime/hostedagent/openai.go`:
- В `openAIChatRequest` добавлено `Tools []openAIToolDefinition`
- В `OpenAIModel` добавлены `tools []openAIToolDefinition + `SetTools()`
- Добавлен `tool_calls` в парсинг ответа (`tool_calls` → content string)
#### 3. agentfactory — передача MCP
- В `openAIConstructor` добавлено `MCPServers: toRuntimeMCPServers(resolvedMCP)` в вызов `newHostedAgent()`
#### 4. Dockerfile
В `Dockerfile.claudio` добавлен `COPY norma-local/ ./norma-local/` — чтобы `replace` работал в контейнере.
#### 5. Сборка
Образ собран и запущен как `balda-agent-valera:latest`.
#### 6. Диагностика
- `mcpServerIDs` приходят корректно: `[balda obsidian fast-rlm]`
- Добавлен stderr-лог в `agentfactory.go` в `Build()`:
```go
fmt.Fprintf(os.Stderr, "BALDA-DEBUG: mcpServerIDs=%v resolvedMCP=%v\n", mcpServerIDs, resolvedMCP)
```
- Для просмотра логов нужен `docker logs balda-agent-valera-1 2>&1 | grep BALDA-DEBUG`
### Почему не заработало
Точная причина не установлена — нужно увидеть `resolvedMCP` в логах контейнера. Возможные варианты:
- `resolveMCPServers` возвращает пустой map
- `toRuntimeMCPServers` неправильно конвертирует
- `SetTools` не влияет на уже созданные сообщения в сессии
### Что осталось
1. Прочитать stderr из контейнера: `docker logs balda-agent-valera-1 2>&1 | grep BALDA-DEBUG`
2. Если `resolvedMCP` пустой — исправлять цепочку resolveMCPServers
3. Если не пустой — тестировать response с tool_calls в DeepSeek ответе
## Диагностика молчания
### 1. MCP ошибка: `failed to list MCP tools: failed to connect: Not Found`
**Причина**: в config.yaml url без `/sse` на конце.
**Фикс**: `url: http://obsidian-mcp:3101/sse` → `docker-compose restart`.
### 2. В логах `command running → command handled` за секунду, без `received provider event`
**Причина**: стухший embedded NATS. Swarm внутри процесса не доставляет команду до session/task actor'ов.
**Фикс**: `docker-compose restart` (если не помогло → `up -d`).
### 3. Удалён / пустой state.db
После удаления balda не может зарегистрировать swarm акторы.
**Фикс**: `rm state.db` и `up -d` (balda создаст заново).
### 4. Нет owner
`handleMessage` выходит при `getOwnerID() == 0`.
**Фикс**: прописать `allowed_owners` в config.yaml (не нужен `/start owner=`).
### 5. stream_only_with_session
Сообщение в теме без сессии молча дропается.
**Фикс**: написать `@Валера <текст>` чтобы создать сессию.
### 6. Проверить webhook со стороны
### Полный цикл сборки
```bash
curl -X POST http://localhost:8091/zulip/webhook \
-H "Content-Type: application/json" \
-d '{"type":"test"}'
# 1. Инкремент buildTag в openai.go (строка buildTag)
# ~/Developer/norma-local/pkg/runtime/hostedagent/openai.go
# buildTag = "whale-YYYYMMDD-N"
# 2. Применить OPENAI-DEBUG стэш (если нужно дебажить)
cd ~/Developer/norma-local && git stash pop stash@{0}
# 3. Сборка
docker build --no-cache \
-f ~/Docker/claudio-agent/Dockerfile.claudio \
-t balda-agent-valera:latest \
/Users/admin/Developer
# 4. Деплой
cd ~/Docker/balda-agent && docker-compose up -d --force-recreate
# 5. Проверка версии в логах
docker logs balda-agent-valera-1 2>&1 | grep "norma-tools"
```
## Ключевые грабли
### Особенности сборки
- Dockerfile копирует `norma-local/` в `/src/norma-local/`
- `go.mod` replace: `github.com/normahq/norma => ./norma-local`
- `go mod edit -replace` применяется **до** `go mod download`
- `--no-cache` обязателен при изменении norma-local
- Образ надо таргетировать под нужное имя: `balda-agent-valera` для Валеры, `claudio-agent` для Клавдия
- После сборки `docker-compose up -d --force-recreate` чтобы подхватить новый образ
## Диагностика молчания / проблем
### NORMA-TOOLS не появляется в логах
Причина: агент использует не hostedagent провайдер. openai.go задействован только при provider=deepseek через ADK.
### MCP ошибка: `failed to list MCP tools: failed to connect: Not Found`
Фикс: в config.yaml url должен заканчиваться на `/sse`: `url: http://obsidian-mcp:3101/sse`
### В логах `command running → command handled` за секунду, без `received provider event`
Причина: стухший embedded NATS. Фикс: `docker-compose restart` или `up -d`.
### Удалён / пустой state.db
Фикс: `rm state.db` и `up -d` (balda создаст заново).
### Нет owner
Фикс: прописать `allowed_owners` в config.yaml.
### stream_only_with_session
Фикс: написать `@Валера <текст>` чтобы создать сессию.
## Известные грабли
### OPENAI_BASE_URL — обязательная env var
Norma не читает `base_url` из config.yaml для `provider: deepseek`. Читает env `OPENAI_BASE_URL`.
Без неё запросы уходят на `api.openai.com` (401).
**В `.env` обязательно:**
```
OPENAI_BASE_URL=https://api.deepseek.com/v1
```
Norma не читает `base_url` из config.yaml для `provider: deepseek`. Читает env.
### DOCKER_OPTS — пустая строка убивает старт
Должен быть валидный JSON:
```
DOCKER_OPTS={"max_tokens": 2048, "temperature": 0.7}
```
Должен быть валидный JSON: `DOCKER_OPTS={"max_tokens": 2048}`
### composerestart vs compose up -d
- `restart` — не перечитывает `.env`
- `up -d` — пересоздаёт контейнер с обновлённым `.env`
### docker compose up -d не работает
На этом хосте `docker compose` (без дефиса) не принимает `-d`. Использовать `docker-compose up -d`.
### Docker кеш COPY norma-local/
Слой копирования кешируется. `--no-cache` обязателен при изменении norma-local.
---
### go.mod replace
`replace github.com/normahq/norma => ./norma-local` — путь относительно `/src` в контейнере, куда копируется `norma-local/`.
## Диагноз: MCP не работают — инструменты не передаются модели
### zulip-router owner-форвард падал с 400 (23.06.2026)
**Симптом:** Валера не отвечал на сообщения в закреплённых тредах без @mention. Router лог: `forward failed (owner) error="post ... status 400 body=bad request"`. Валера лог: `invalid zulip webhook payload error="unsupported message.type \"\""`.
**Статус (2026-06-21):** MCP резолвятся, но не вызываются.
**Корень:** В `zulip-router/config.go` структура `Message` не имела поля `Type`. Zulip Events API присылает `message.type` ("stream"/"private"), но роутер его не парсил — в вебхук приходил пустой `""`. Balda-agent валидирует message.type.
**Установлено через BALDA-DEBUG:**
```
BALDA-DEBUG: mcpServerIDs=[balda obsidian fast-rlm]
resolvedMCP=map[balda:{http http://127.0.0.1:33545/mcp}
fast-rlm:{http http://fast-rlm-mcp:3333/mcp}
obsidian:{sse http://obsidian-mcp:3101/sse}]
```
MCP серверы зарезолвлены, тулсеты переданы в `hostedagent.Config`.
**Почему не работают:**
`OpenAIModel.generate()` → `buildChatRequest()` не читает `req.Config.Tools` (поле `genai.GenerateContentConfig`). ADK кладёт инструменты туда, но `buildChatRequest` сериализует только `model`, `messages`, `temperature`, `top_p`, `max_tokens`, `stop`. DeepSeek получает запрос без `tools` → отвечает текстом.
**Логи подтверждают:** `function_call_part_count=0` в ответе DeepSeek.
**Фикс:** добавить передачу tools в OpenAI запрос и парсинг tool_calls из ответа.
**Сделано (2026-06-21):** в `openai.go` добавлены:
- `openAIToolDefinition`, `openAIFunction`, `openAIToolCall` — структуры для API
- Поле `Tools` в `openAIChatRequest`
- Функция `openAIToolsFromConfig()` — конвертирует `genai.Tool[]` → OpenAI tool definitions
- Функция `contentToOpenAI()` — обрабатывает FunctionCall/FunctionResponse из ADK
- Парсинг `tool_calls` в `parseChatResponse()`
- **Тесты:** 17 тестов на new функциональность + 3 существующих = все проходят
1. Добавить поле `Tools` в `openAIChatRequest` + `openAIToolDefinition`
2. В `buildChatRequest` читать `req.Config.Tools` → конвертировать в OpenAI tool definitions
3. В `parseChatResponse` парсить `tool_calls` из ответа (ADK сам выполнит MCP)
**Важно:** upstream `norma` v0.0.10 (`bf2b25d`) уже корректно передаёт MCP тулсеты через `agentfactory.hostedToolsets()`. Проблема только в том, что `OpenAIModel` — кастомная реализация, не умеющая прокидывать tools. Если бы использовался Gemini/ACP provider — MCP бы работали.
**Фикс:** Добавлен `Type string \`json:"type,omitempty"\`` в `Message struct`. Коммит `803ae68` (локальный, без remote). docker-compose up -d --build.
Фикс: messageID int в сигнатуру handleAutoClaimMention, передаётся payload.Message.ID из processMessage.
Файл: `~/Developer/balda/internal/apps/balda/handlers/zulip_handler.go` (в коммите `05159d1`)
+4 -2
View File
@@ -1,10 +1,12 @@
# Balda — Setup & Config
_Последнее обновление: 2026-06-16_
_Последнее обновление: 2026-06-24_
## Хост
Mac (Eagle), Docker Desktop (arm64 native). Контейнеры собираются из исходников в `~/Developer/balda/`.
Mac (Eagle), **Colima** (arm64, 8 CPU, 24 GB RAM, 100 GB sparse disk). Контейнеры собираются из исходников в `~/Developer/balda/`.
**Важно:** `buildx_buildkit_arm64builder0_state` volume — основная причина зависаний Zulip при сборке. См. [[tech/docker-mac-disk-issues]].
## Компоненты
@@ -0,0 +1,355 @@
# Импорт банковских выписок за последний год
**Создано:** 2026-06-23
**Цель:** Получить все транзакции за последний год (середина 2025 — июнь 2026) в Budget App, автоматизируя импорт банковских выписок как можно полнее.
## Проблема
В `Budget.xlsx` данные заканчиваются в **середине 2025** (май-июнь 2025, в зависимости от счёта). Последние ~12 месяцев транзакций не внесены в Excel. Вручную вспомнить каждую трату за год — нереалистично.
## Существующий инструмент: budget-bank-statement-converter
**Путь:** `~/Developer/budget-bank-statement-converter/`
**Язык:** Swift (macOS command-line tool)
**Формат вывода:** CSV с колонками `[дата, сумма, дебет, кредит, категория, комментарий, курс]` — совпадает с форматом Excel.
### Поддерживаемые банки
| Банк | Формат входа | Конфиг | Статус |
|------|-------------|--------|--------|
| Demir | CSV (из PDF → Adobe Extract → CSV) | `demir-config.json` | ✅ Работает |
| Сбер | CSV (выгрузка из СберБизнес) | `sber-config.json` | ✅ Работает |
| Тинькофф | CSV (выгрузка из Тинькофф) | `tinkoff-config.json` | ✅ Работает |
| ВТБ | CSV (из PDF → Adobe Extract → CSV) | `vtb-config.json` | ✅ Работает |
| Альфа | — | — | ❌ `fatalError("Alfa not implemented")` |
### Как работает
1. **PDF → CSV**: использует Adobe PDF Extract API (`pdfservices-api-credentials.json`) — загружает PDF, получает ZIP с CSV-таблицами.
2. **CSV → формат App**: разбирает CSV, маппит категории через regex-конфиг, нормализует double-entry (дебет/кредит/конверсии).
3. Если нужно — автоматически конкатенирует `fileoutpart0001.csv`… файлы.
4. Использует OpenAI GPT-3.5-turbo для AI-категоризации (закомментировано, `aiMaxTokens = 80`).
### Что нужно для использования
- Xcode (для сборки Swift-проекта)
- `OPENAI_API_KEY` в env (не обязательно, выключено)
- `pdfservices-api-credentials.json` для Adobe Extract
- JSON config для каждого банка: `Сбер config`, `Tinkoff config`, `Demir config`, `VTB config`
## План импорта
### Шаг 1: Получить выписки из банков
**Что нужно выгрузить за июнь 2025 — июнь 2026:**
| Счёт | Банк | Как получить выписку |
|------|------|---------------------|
| Нал RUB | Наличные | Ручной ввод (см. ниже про наличные) |
| Нал KGS | Наличные | Ручной ввод |
| Нал USD | Наличные | Ручной ввод |
| Нал KZT | Наличные | Ручной ввод |
| Demir ИП | Demir | CSV (интернет-банк/моб. приложение) |
| Demir ИП USD | Demir | CSV (интернет-банк/моб. приложение) |
| Demir KGS | Demir | CSV (интернет-банк/моб. приложение) |
| Demir USD | Demir | CSV (интернет-банк/моб. приложение) |
| Тинькофф Black | Тинькофф | CSV (выгрузка из Тинькофф) |
| Тинькофф Кредитка | Тинькофф | CSV (выгрузка из Тинькофф) |
| Сбер | Сбер | CSV (СберБизнес / PDF) |
| Сбер Кредитка | Сбер | CSV (СберБизнес / PDF) |
| Альфа | Альфа | CSV — но конвертер Альфу не поддерживает |
| Альфа Кредитка | Альфа | — |
| ВТБ | ВТБ | PDF → Adobe Extract → CSV |
| ВТБ Кредитка | ВТБ | PDF → Adobe Extract → CSV |
### Шаг 2: Конвертировать выписки в CSV формата App
Запуск для каждого банка:
```bash
./budget-bank-statement-converter --bank <bank> --account <account> <input.csv>
```
Выход: `_processed.csv` с колонками `дата, сумма, дебет, кредит, категория, комментарий, курс`.
### Шаг 3: Написать Python импортёр CSV → Budget App DB
Существующий `xlsx_import.py` читает из Excel. Нужен новый: `csv_bank_import.py`, который:
- Читает CSV в формате App (колонки из `csvHeaders` в `Common.swift`)
- Привязывает `дебет`/`кредит` к существующим счетам в БД (по имени)
- Маппит категории из CSV на существующие категории в БД (по имени подкатегории)
- Игнорирует дубликаты (hash по `date + amount + source + dest + comment`)
- Поддерживает несколько CSV-файлов за раз (много выписок)
- Выводит отчёт: сколько добавлено, сколько пропущено (дубликаты), какие категории не найдены
### Шаг 4: Импортировать в Budget App
```bash
cd ~/Developer/budget-app
uv run python src/budget/importers/csv_bank_import.py <output1.csv> <output2.csv> ...
```
### Шаг 5: Дописать недостающее в Swift-конвертере
- **AlfaToCSV**: реализовать парсер для Альфа-банка (CSV выгрузка из моб. банка/СберБизнес)
- **AI-категоризация**: раскомментировать и обновить (GPT-3.5 → DeepSeek/local LLM?)
## Наличные расходы — проблема и решение
### Проблема
Наличные траты не трекались последний год. У нас есть конечный остаток налички на руках сейчас, но нет истории по категориям.
### Подходы
#### A. Снять остаток наличных сейчас → счёт в БД (простой)
- Посчитать физическую наличку сейчас → записать как `initial_balance` для `Нал RUB`, `Нал KGS`, `Нал USD`, `Нал KZT`.
- Все траты наличными за год никогда не будут зафиксированы.
- **Минус:** дыра в данных большого объёма (вероятно значительная часть расходов).
#### B. Экстраполяция по историческим трендам (средний)
- Взять помесячные тренды наличных трат по категориям за 2023–первую половину 2025.
- Экстраполировать на июнь 2025 — июнь 2026 с учётом сезонности.
- Создать транзакции-плейсхолдеры с пометкой `import_id = 'cash_estimate'`.
- **Минус:** неточность, может не отражать реальные изменения.
#### C. Ретроспектива через месяц-два + экстраполяция (предпочтительный)
- **Сейчас:** начать трекать наличные расходы (вручную или через мобильный интерфейс Budget App).
- **Через 1–2 месяца:** по собранным данным наличных трат вычислить реальные помесячные паттерны.
- Экстраполировать на пропущенный год с этими паттернами.
- **Плюс:** база для экстраполяции будет основана на реальных данных, а не на исторических.
#### D. None of the above — принять дыру
- Сделать только безналичный импорт. Наличные начинаем трекать с сегодня.
- В аналитике отмечать периоды как "без наличных".
- **Плюс:** не надо ничего выдумывать.
### Рекомендация: C+D combined
1. Трекать наличку вручную через UI Budget App начиная с сегодня.
2. Через 2 месяца посчитать реальные тренды и решить, стоит ли экстраполировать на прошлый год.
3. Если нет — просто принять дыру и жить с хорошей аналитикой начиная с 2026-06.
## Что уже реализовано в Budget App для импорта
-`xlsx_import.py` — полный импорт из Budget.xlsx (34k строк)
- ✅ Все счета, категории, курсы, транзакции — в БД
- ✅ Идемпотентный UPSERT для счетов и курсов
- ✅ Транзакции добавляются обычным insert (без import_hash после фикса)
## Реализованный скрипт: bank_scraper
**Путь:** `~/Developer/budget-app/scripts/bank_scraper/`
Структура:
```
scripts/bank_scraper/
├── __init__.py
├── .gitignore # config.yaml + data/imports/ не коммитятся
├── config.example.yaml # шаблон для копирования в config.yaml
├── base_driver.py # base class BankDriver + load_config()
├── orchestrator.py # entry point (н.п.)
└── drivers/
└── demir.py # Demir IB драйвер (н.п.)
```
**Статус:** ✅ base + Demir driver написаны, Playwright установлен. **НО — Demir требует QR-логин через мобильное приложение**, не логин/пароль на сайте.
## Реальность Demir IB
Сайт `93.171.215.109``apps.demirbank.kg/ib/`) — **Flutter web SPA** с QR-аутентификацией. Нет формы логина с паролем — нужно сканировать QR мобильным приложением Demir.
**Варианты решения:**
### A. Продолжить с Playwright + session persistence
- Один раз залогиниться руками (QR → моб. приложение)
- Сохранить session cookies/storage в persistent context
- Дальше переиспользовать сессию для выгрузок (пока не протухнет)
- **Плюс:** минимум кода
- **Минус:** сессия рано или поздно протухнет, нужен ручной ре-логин
### B. Appium / ADB — эмуляция мобильного приложения
- Демонстратор Android/iOS эмулятора с мобильным приложением Demir
- Appium для UI automation внутри приложения
- **Плюс:** полный контроль
- **Минус:** сложно, накладно
### C. Заменить Demir на первый банк с логином/паролем
- Тинькофф имеет API для разработчиков (OAuth)
- Сбер — есть API SberBusinessAPI (хотя для юрлиц)
- Можно начать с Тинькофф: Tinkoff API → выписка без браузера
- **Плюс:** самый простой tech-wise
- **Минус:** Demir пока под вопросом
### D. Парсить CSV выписки, которые уже есть в mobile/email
- Возможно Demir присылает выписки на email
- Или можно скачать через мобильное приложение → экспорт → AirDrop/email себе
- Это полу-ручной подход (но быстрее чем QR scraping)
## Решение
**Рекомендация: A + D**
1. Самый ценный банк — **Тинькофф** (есть API) — начинаем с него
2. Demir — разово выгрузить через мобильное приложение (Export CSV/email)
3. Если сессия Demir долго живёт — Playwright persistent context отработает
### Новый порядок разработки
1. ✅ Demir driver (написан, но упирается в QR)
2. **Tinkoff API driver** — следующий приоритет (без браузера, REST API)
3. **Сбер / ВТБ / Альфа** — Playwright или Tinkoff-style API
4. **Parse Demir CSV** — Python-версия DemirToCSV для уже скачанных файлов
## Файл вывода Swift-конвертера
```
csvHeaders = ["дата", "сумма", "дебет", "кредит", "категория", "комментарий", "курс"]
```
- `дата``dd.MM.yyyy HH:mm` (формат EUR)
- `сумма` — строка с суммой (±знак)
- `дебет` — имя счёта-источника (пусто = доход извне)
- `кредит` — имя счёта-получателя (пусто = расход вовне)
- `категория` — имя подкатегории
- `комментарий` — очищенный текст
- `курс` — кросс-курс при внутреннем переводе между валютами
## Автоматизация выгрузки выписок из банков
### 1. Browser automation libraries (CV-driven)
| Библиотека | Язык | Браузеры | CV | 2FA/SMS |
|-----------|------|----------|----|---------|
| **Playwright** (MS) | Python, JS, Java, .NET ⭐ | Chromium, Firefox, WebKit | Есть (locator screenshots) | `page.wait_for_selector` на поле ввода кода |
| **Puppeteer** (Google) | JS (Python через pyppeteer) | Chromium | Есть | — |
| **Selenium** | Python, Java, JS и др. | Все major | Через сторонние утилиты | — |
**Рекомендация: Playwright Python** — де-факто стандарт в 2025, cross-browser, async, видит элементы даже в SPA, встроенные ожидания. Подходит и для РФ-банков (Сбер, Тинькофф, Альфа-клик — все на SPA).
### 2. Готовые решения на GitHub
**AploBankParsers** ([github.com/Zaurrex1/AploBankParsers](https://github.com/Zaurrex1/AploBankParsers)):
- Парсер выписок **СберБизнес** (production-ready) — читает xlsx/сsv из уже выгруженного файла
- Заглушки для Альфа, ВТБ, Тинькофф
- Это парсер **уже скачанных файлов**, не скрапер
**bank_scrapers** ([github.com/eebette/bank_scrapers](https://github.com/eebette/bank_scrapers)):
- Playwright-based для scraping bank websites
- Generic, не специфичен под РФ-банки
**Sber API** — официальный REST API Сбера:
- `developers.sber.ru/docs/ru/sber-api/specifications/statement/transactions`
- Получение выписки по счёту за 5 лет
- **Требует** корпоративного доступа (SberBusinessAPI / ДБО), не подойдёт для личного СберБанк
**Готового решения "под ключ" для РФ-банков** (Playwright → bank login → 2FA → CSV выписка) **нет** в открытом доступе. Каждый банк — свой уникальный UI и flow. Придётся писать самим.
### 3. Архитектура скрипта
```
┌─────────────────────────────────┐
│ Telegram Bot (Hermes/кит) │ ← запрашивает SMS-код
├─────────────────────────────────┤
│ Orchestrator (Python) │ ← запускает по крону / кнопке
│ ┌─────────────────────────┐ │
│ │ Playwright browser │ │ ← drives bank login page
│ │ - headless=false │ │ (visible для отладки)
│ │ - persistent context │ │ (сессия не слетает)
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ Bank drivers: │ │
│ │ - tinkoff.py │ │
│ │ - sber.py │ │
│ │ - alfa.py │ │
│ │ - demir.py │ │
│ │ - vtb.py │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ Output: CSV в формате │ │
│ │ budget-bank-statement- │ │
│ │ converter │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
```
### 4. Flow для каждого банка
```
1. Запустить headless Playwright (или visible=False для отладки)
2. Открыть страницу логина банка
3. Ввести credentials (из конфига, НЕ скрипта)
4. Если запрошен SMS-код:
→ отправить в Telegram: "Код из смс для {bank}:"
→ ждать ответа (polling/async)
→ ввести полученный код
5. Дождаться загрузки дашборда
6. Перейти на страницу выписок/истории
7. Указать период: 2025-06-01 — 2026-06-23
8. Скачать CSV/Excel
9. Сохранить в ~/Developer/budget-app/data/imports/{bank}/{date}.csv
10. Конвертировать через budget-bank-statement-converter (или Python-версию)
11. Импортировать в БД
12. Закрыть браузер
```
### 5. Обработка SMS-кодов (Telegram)
Скрипт не должен хранить сессию банка, каждый запуск — новая авторизация.
**Варианты:**
1. **Telegram Bot (inline keyboard)**: скрипт ждёт сообщение, когда нужен код — присылает кнопку "Отправить код для {bank}", пользователь вводит → скрипт вставляет
2. **Hermes-агент**: крон-джоб спрашивает в Telegram нужный код, ждёт ответа через webhook
3. **Простой stdin**: скрипт пишет "Введите код для Тинькофф:" и ждёт ввод (если запуск из терминала)
**Рекомендация: вариант 1** — TG bot минимальная зависимость, полный контроль.
Для реализации: существующий Hermes/Zulip может служить relay. Или простой скрипт на Python + python-telegram-bot с `await incoming_message`.
### 6. Чувствительность данных — ограничения
Скрипт будет:
- Знать **логины/пароли** банков (хранятся в локальном конфиге, НЕ в коде)
- Открывать **браузер на машине Алекса** (никаких VPN/прокси)
- Передавать только SMS-коды через TG — пароли не передаются
- Работать **локально**, без LLM/агентов в browser automation
Код пишем так, чтобы ни одна строка credentials не была в скрипте:
```python
# config.yaml (chmod 600)
banks:
tinkoff:
login: "7999..."
password: "..."
phone: "7999..."
sber:
login: "..."
password: "..."
```
### 7. Альтернатива: API банков (без browser)
| Банк | REST API для личных счетов | Комментарий |
|------|---------------------------|-------------|
| Тинькофф | Есть (Tinkoff API для разработчиков) | Требует регистрации приложения, OAuth |
| Сбер | Sber API для юрлиц, нет для личных | Не подходит |
| Альфа | Альфа-Бизнес API (юрлица) | Не подходит |
| Demir | Нет публичного API | — |
| ВТБ | Нет публичного API | — |
Тинькофф — единственный из списка, у кого есть адекватный API для физлиц (Tinkoff API / Tinkoff Invest API). Можно получить выписку через API, без browser. Остальные — только SPA scraping.
**Код:** 10 swift-файлов, ~2 400 строк.
**Что хорошо:**
- Хорошая архитектура: каждый банк = отдельный struct с чётким интерфейсом
- Конфиги вынесены из кода (JSON)
- Regex-маппинг категорий гибкий
- Умеет объединять multi-part CSV и извлекать из PDF через Adobe API
- Формат вывода совпадает со структурой Excel/Budget App
**Чего не хватает:**
- Парсер Альфа-банка (только заглушка)
- AI-категоризация закомментирована (GPT-3.5, устарела)
- Нет интеграции с Budget App (только → CSV, не → БД)
- Нет обработки для Demir ИП USD / Demir USD / Demir KGS отдельно (DemirToCSV один конфиг на все)
- PDF-парсер привязан к Adobe PDF Extract API (платный сервис, credentials нужны)
- Нет обработки для Сбер Кредитка как отдельного счёта (SberToCSV один конфиг)
- Нет автоматического определения новых форматов CSV от банков
@@ -0,0 +1,70 @@
# Остатки счетов из Excel (Budget.xlsx)
Файл: `~/Downloads/Budget.xlsx`
Лист: `транзакции`
Балансы **совпадают** с API после фиксов (2026-06-23).
| Счёт | Баланс | Валюта | Последняя операция |
|---|---|---|---|
| Нал RUB | 815 555.70 | RUB | 2025-05-16 (R6029, 60 000 deb) |
| Нал KGS | 1 794.00 | KGS | 2025-06-17 (R6042, deb) |
| Нал KZT | -22 820.00 | KZT | 2024-12-15 (R5505, deb) |
| Нал USD | 1.00 | USD | 2025-02-04 (R5976, deb) |
| Нал AED | 0.00 | AED | 2023-11-16 (R2335, deb) |
| Нал EUR | — | EUR | нет операций |
| Нал UZS | 18 000.35 | UZS | 2024-03-31 (R3337, deb) |
| Demir ИП | 192 308.54 | KGS | 2025-01-31 (R5943, deb) |
| Demir ИП USD | 284 842.96 | USD | 2026-05-29 (R6053, cred) |
| Demir KGS | 96 013.12 | KGS | 2025-01-31 (R5947, deb) |
| Demir USD | 3 394.29 | USD | 2025-01-31 (R5950, cred) |
| Тинькофф Black | 521 263.65 | RUB | 2025-01-31 (R5959, deb) |
| Тинькофф Кредитка | 7 170.17 | RUB | 2025-01-28 (R5901, deb) |
| Сбер | 266 905.98 | RUB | 2025-01-28 (R5898, deb) |
| Сбер Кредитка | 3 059.00 | RUB | 2025-01-31 (R5946, deb) |
| Альфа | 78 030.00 | RUB | 2025-01-28 (R5925, cred) |
| Альфа Кредитка | 0.50 | RUB | 2025-01-28 (R5924, deb) |
| ВТБ | 265 178.95 | RUB | 2025-01-27 (R5887, deb) |
| ВТБ Кредитка | 297 189.00 | RUB | 2025-01-31 (R5952, deb) |
| Райффайзен | — | RUB | нет операций |
## Формула API
`balance = incoming_transfer + incoming_income - outgoing + initial_balance`
Где:
- `incoming_transfer``SUM(cross_rate * amount)` где source IS NOT NULL (cross_rate конвертирует валюту source в валюту dest)
- `incoming_income``SUM(amount)` где source IS NULL (amount уже в валюте счёта, cross_rate — только для отчёта)
- `outgoing``SUM(amount)` (всегда в валюте счёта-источника)
## Фиксы (2026-06-23)
### 1. incoming для income и transfer — разный расчёт
Было: `incoming = SUM(COALESCE(cross_rate, 1.0) * amount)` — cross_rate применялся ко всем включая income (где amount уже в валюте счёта). Для KGS-счетов с income-пополнениями (например "Конвертация USD по курсу 87") incoming умножался на 87, давая баланс ×87.
Стало: incoming разделён на две части:
- `incoming_transfer (source IS NOT NULL)`: cross_rate применяется (конвертирует валюту source→dest)
- `incoming_income (source IS NULL)`: просто amount (cross_rate — только для отчётной валюты)
### 2. import_hash и on_conflict_do_nothing — удалены
Было: дедупликация по `import_hash = SHA256(date|amount|source|dest|comment)`. В Excel есть 141 дублирующаяся строка с одинаковыми этими полями (реальные повторные списания). `on_conflict_do_nothing` молча пропускал их, но `tx_count` врал что импортировал.
Стало: обычный `session.add(tx)`. Колонка `import_hash` и уникальный индекс дропнуты из таблицы. Функция `_make_import_hash` удалена.
### 3. Accounts — идемпотентность
Было: каждый запуск импорта создавал 20 новых аккаунтов (session.add). После нескольких запусков — 40+ аккаунтов с разными UUID, транзакции привязаны к разным наборам.
Стало: UPSERT — ищет существующий account по `name + user_id`, переиспользует.
### 4. UserSettings — UPSERT
Было: `session.add(UserSettings(...))` — падало с UniqueViolation при повторном запуске.
Стало: `pg_insert(...).on_conflict_do_update(...)`.
### 5. Нюанс Excel col I/L
Для некоторых строк Excel не кеширует вычисленные значения col I (остаток деб) — показывает None. Это не баг, а особенность data_only=True — если Excel не пересчитал формулы перед сохранением, кеш пуст. В таких случаях последний корректный баланс берётся из предыдущей строки минус amount.
@@ -0,0 +1,264 @@
---
aliases:
- Freedom Strategy
- Budget app FIRE
- Budget app strategy
related:
- '[[personal/projects/budget-app/index]]'
- '[[personal/projects/budget-app/fire-investment-strategies-2026]]'
tags:
- personal
- budget-app
- finance
- FIRE
- freedom
- roadmap
title: Финансовая стратегия + FIRE-адаптация для Budget App
updated: '2026-06-23T00:00:00.000Z'
---
# Финансовая стратегия + FIRE-адаптация для Budget App
**Обновлено:** 2026-06-23
**Основание:** анализ данных budget-app + личные вводные Alex
---
## 1. Текущая позиция (June 2026)
### 1.1 Балансы счетов
| Счёт | Валюта | Баланс | Статус |
|------|--------|--------|--------|
| Нал USD | USD | $925,511 | **Накопления** |
| Demir ИП USD | USD | $284,843 | **Накопления** |
| Demir USD | USD | $3,394 | Остаток |
| Нал KGS | KGS | 1,794 | Остаток (текущие) |
| Demir ИП | KGS | -359,861 | На расход |
| Demir KGS | KGS | -3,542,775 | Ушёл в минус |
| RUB счета (Альфа, Сбер, ВТБ, Тинькофф, Нал) | RUB | ~-29.5M | Кредитки + овердрафты |
| Нал RUB | RUB | -12,709,478 | Долг |
| Альфа | RUB | -18,187,976 | Долг |
**Итого накопления:** ~$1,210,000 USD ≈ **112.5M KGS** (по курсу 93)
**Реально свободные:** ~$100k (нал USD), остальное — предпринимательские счета + оборотка
### 1.2 Расходы (из БД, 2024 — июнь 2025, 17 мес)
| Показатель | Значение |
|------------|----------|
| Средние расходы/мес | ~1,075,000 KGS |
| Средний доход/мес | ~851,000 KGS |
| Норма сбережений (по БД) | -26% (данные неполные — часть трат не проведена) |
Категоризация в БД сломана: все расходы свалены в "📉 Расходы" (11.4M), остальное — депозиты, кэшбэк, аренда (~378k). Данные после июня 2025 не вносились, последняя транзакция май 2026 — видимо разовый импорт.
### 1.3 Внешние активы и доходы
| Статья | Цифра |
|--------|-------|
| **Накопления (ликвид)** | ~$100k (Нал USD) |
| **Аренда 2 квартир** | 60-80k KGS/мес |
| **Образование старшей** | $8-10k/год = 65-77k KGS/мес |
| **Младшая (дистант)** | Запуск в этом году — точные цифры появятся |
| **Частный дом** | Текущее содержание |
| **Indie dev** | Проекта пока нет |
---
## 2. Стратегия (Freedom, не FIRE)
Классический FIRE (накопить 25x и сидеть без дела) **тебе не подходит** по нескольким причинам:
1. **Валютный риск** — живёшь в KGS, доход в RUB/USD. KGS волатильна. FIRE-расчёт в KGS ненадёжен
2. **Образование детей** — крупный обязательный платёж на ~10 лет вперёд. Это не "сократить", это фиксированная статья
3. **Ты не хочешь "не работать"** — инди-дев показывает что хочешь заниматься проектами, а не сидеть на пляже
4. **Две квартиры** — актив, который уже почти покрывает образование старшей
### 2.1 Твоя цель: "Freedom Gap"
Не FIRE number, а **Freedom Gap** — разница между расходами и пассивным/полупассивным доходом, которую нужно закрыть капиталом.
```
Freedom Gap = (расходы/мес) − (аренда + дивиденды + проектный доход)
```
| Сценарий | Расходы/мес | Аренда | Проект | Gap/мес | Капитал для 4% |
|----------|------------|--------|--------|---------|----------------|
| **Сейчас** | ~900k KGS | 70k | 0 | **830k KGS** | **249M KGS** ($2.7M) |
| **Аренда → образование** | 900k | 70k→на образование | 0 | 830k | 249M KGS ($2.7M) |
| **+Проект $2k/мес** | 900k | 70k | 186k | **644k KGS** | **193M KGS** ($2.1M) |
| **+Проект $5k/мес** | 900k | 70k | 465k | **365k KGS** | **110M KGS** ($1.2M) |
### 2.2 Реалистичные вехи
| Веха | Условие | Цифра |
|------|---------|-------|
| **🟢 1ая: аренда = образование** | Аренда 80k покрывает старшую ~77k | ✅ **УЖЕ почти** |
| **🟢 2ая: проект = жизнь** | Проект $3k/мес покрывает ~280k KGS | Нужен работающий проект |
| **🟡 3ая: капитал + аренда = всё** | Накопить 110M KGS ($1.2M) при проекте $5k | 10-15 лет |
| **🔴 4ая: полная свобода** | Накопить 249M KGS ($2.7M) | Долгий горизонт |
---
## 3. Инвестиционная стратегия для Alex
### 3.1 Принципы
1. **Core-Satellite** — 70% широкий рынок (VT / VWRA), 30% дивиденды + защита
2. **Мультивалютность** — портфель в USD (защита от девальвации KGS)
3. **Дивиденды как income stream** — снижают sequence-of-returns risk, частично покрывают расходы
4. **Буфер 12 мес** — в USD, не трогать
5. **Ребаланс раз в год** — не дёргаться
### 3.2 Рекомендуемая аллокация
| Класс | % | Инструмент | Назначение |
|-------|---|-----------|------------|
| **Global Equity ETF** | 60% | VWRA (VT) — IRSH / LSE | Рост капитала, мультивалютная диверсификация |
| **Dividend ETF** | 15% | VIG, SCHD, или HDV | Стабильный дивидендный поток |
| **Bonds (TIPS)** | 10% | TIP (iShares TIPS) | Защита от инфляции |
| **Cash USD** | 10% | HYSA / money market | Буфер, ~12 мес расходов |
| **Real Estate (REIT)** | 5% | VNQ / O | Доход от недвижимости без управления |
**Почему не BND:** для тебя облигации в USD не дают премии, а TIPS защищают от инфляции которая в KGS выше номинальной.
### 3.3 Ребаланс
- **Раз в год** в декабре
- **Автоматический триггер:** любая позиция отклонилась >5% от цели
- **Новые деньги:** направляются в самый отстающий класс (автоматический buy-low)
---
## 4. Что внедрить в budget-app (Phase 6 — Roadmap)
### 4.1 Приоритеты (по ценности)
| # | Фича | Зачем | Оценка сложности | Статус |
|---|------|-------|-----------------|--------|
| **P0** | **Savings Rate Dashboard** | Увидеть реальную норму сбережений. Сейчас -26% — надо понять куда уходят деньги | Medium | ❌ Не начато |
| **P0** | **Multi-Currency Net Worth** | Общий капитал в USD: наличка + счета + квартиры + пассивы | Low | ❌ Не начато |
| **P1** | **Investment Snapshots** | Раз в месяц вбить "сколько на Нал USD / IBKR / крипте" — увидеть динамику | Low | ❌ Не начато |
| **P1** | **Freedom Gap Dashboard** | Доход (аренда+дивиденды+проект) - расходы = gap | Medium | ❌ Не начато |
| **P1** | **Passive Income Tracker** | Сколько приносят аренда, дивиденды, депозиты в месяц | Low | ❌ Не начато |
| **P2** | **Projection Engine** | "Если докладываю X/мес и проекты дают Y/мес — через N лет свобода" | Medium+ | ❌ Не начато |
| **P2** | **What-if Simulator** | "Что если аренда упадёт / курс изменится / проект взлетит" | Medium+ | ❌ Не начато |
| **P3** | **Monte Carlo FI Calculator** | Классический 4% vs 3.5% vs variable, probability of success | High | ❌ Не начато |
| **P3** | **Withdrawal Strategy Planner** | Бакетная / guardrails / dividend | High | ❌ Не начато |
### 4.2 Схема расширения БД
```sql
-- Инвестиционные снапшоты (ручной ввод раз в месяц)
CREATE TABLE investment_snapshot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id),
account_id UUID REFERENCES account(id), -- NULL для внешних брокеров
name VARCHAR(128) NOT NULL, -- "Нал USD", "Interactive Brokers", "Крипта"
portfolio_value NUMERIC(16,2) NOT NULL,
currency_code VARCHAR(5) NOT NULL REFERENCES currency(code),
snapshot_date DATE NOT NULL,
asset_class VARCHAR(32), -- cash, bonds, stocks, real_estate, crypto, business
notes TEXT,
UNIQUE(user_id, name, snapshot_date)
);
-- Цели свободы
CREATE TABLE freedom_goal (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id),
name VARCHAR(128) NOT NULL, -- "Свобода", "Образование детей", "Ремонт дома"
target_amount NUMERIC(16,2) NOT NULL,
target_currency_code VARCHAR(5) NOT NULL REFERENCES currency(code),
current_amount NUMERIC(16,2) DEFAULT 0, -- ручной или вычисляемый
category VARCHAR(32), -- freedom, education, major_purchase
monthly_contribution NUMERIC(16,2), -- план пополнения
target_date DATE,
expected_return_rate NUMERIC(5,4), -- 0.07 = 7%
is_active BOOLEAN DEFAULT true
);
```
### 4.3 UI макет (какие страницы добавить)
```
/dashboard
├── Savings Rate (график за 12 мес + норма)
├── Net Worth (USD, KGS — два числа)
├── Freedom Gap (прогресс-бар: 0% → 100%)
└── Cash Reserve (мес жизни / норма 12)
/freedom
├── Goals (список целей с прогресс-барами)
├── Investment Snapshots (таблица + график)
├── Projection (график: сегодня → свобода)
└── What-if (слайдеры: аренда, проект, курс)
/finances
├── Passive Income (аренда, дивиденды, депозиты за месяц)
└── Allocations (pie chart портфеля)
```
### 4.4 API эндпоинты (новые)
```
GET /api/freedom/goals — список целей свободы
POST /api/freedom/goals — создать цель
PUT /api/freedom/goals/:id — обновить
GET /api/freedom/goals/:id/projection — проекция к цели
GET /api/investments/snapshots — список снапшотов (с пагинацией)
POST /api/investments/snapshots — добавить снапшот
GET /api/investments/snapshots/latest — последний по каждому инструменту
GET /api/dashboard/net-worth — общий капитал (USD + KGS)
GET /api/dashboard/freedom-gap — gap месяца
GET /api/dashboard/passive-income — аренда + дивиденды за период
```
---
## 5. Первый шаг (что сделать прямо сейчас)
### Step 0: Заполнить данные в budget-app
Без актуальных расходов любой расчёт — гадание. Нужно:
1. Импортировать выписки за июнь 2025 — июнь 2026 (Альфа, Сбер, Demir)
2. Разнести по категориям (хотя бы крупные статьи: дети, дом, стройка, еда)
3. Вбить балансы квартир и других активов как investment_snapshot
### Step 1: Net Worth Dashboard
Показать общий капитал в USD без лишних действий.
Уже можно сделать — данные по счетам есть в БД, квартиры добавляются как manual entry.
### Step 2: Investment Snapshots
Форма: дата, инструмент, сумма, валюта → график роста капитала с проекцией.
Без автоматизации — раз в месяц ввести руками.
### Step 3: Freedom Goal
После того как есть:
- реальные расходы (не -26%)
- актуальный капитал с квартирами
- доход от проекта (хоть какой-то)
→ построить Freedom Gap Dashboard с what-if сценариями.
---
## 6. Что не надо делать (анти-приоритеты)
| Не надо | Почему |
|---------|--------|
| Monte Carlo Simulation | Сложно, данных мало, толку для твоего случая 0 |
| Withdrawal Strategy Planner | Ты не на пенсии, не нужен |
| Roth Conversion Ladder | Не применимо (не US resident) |
| FIRE Number Calculator в классике | Тебе нужен Freedom Gap, не 25x |
| Авто-импорт курсов валют | Не влияет на решение — достаточно раз в месяц |
| Сложные прогнозы в KGS | Курс KGS непредсказуем, считай в USD |
---
## 7. Связанные заметки
- [[personal/projects/budget-app/index|Budget App — главный документ]]
- [[personal/projects/budget-app/fire-investment-strategies-2026|Исходное исследование FIRE + инвестстратегий]]
- [[personal/documents/budget-app-features|Список всех идей фич]]
@@ -0,0 +1,246 @@
---
title: FIRE и инвестиционные стратегии для Budget App
tags:
- personal
- budget-app
- finance
- FIRE
- investments
related: '[[personal/projects/budget-app/index]]'
updated: '2026-06-23T00:00:00.000Z'
---
# FIRE и инвестиционные стратегии для Budget App
**Создано:** 2026-06-23
**Источник:** исследование whale (FIRE guides, Goldman Sachs, PIMCO, Bogleheads, 2026)
---
## 1. FIRE-типы и целевые показатели
### 1.1 Виды FIRE
| Тип | Годовые расходы | Целевой портфель | Суть |
|-----|----------------|-------------------|------|
| **Lean FIRE** | < $40,000 | < $1,000,000 | Минимализм, быстрый выход |
| **Chubby FIRE** | $40k$100k | $1M$2.5M | Комфорт без излишеств |
| **Fat FIRE** | $100k+ | $2.5M$6M+ | Высокий уровень жизни |
| **Barista FIRE** | Переменные | 50–80% от полного | Частичная занятость покрывает часть |
| **Coast FIRE** | Любые | Достаточно чтобы дорасти | Перестать пополнять, дать сложному % работать |
### 1.2 FIRE Number
**Формула:** `FIRE Number = Annual Expenses × 25` (4% rule)
На 2026 год рекомендуется **33.5%** вместо 4%:
- Ранний выход = 40–60 лет на пенсии (Trinity Study считала 30 лет)
- Повышенные оценки рынка (Shiller CAPE выше исторической нормы)
- Низкая доходность облигаций
| Норма сбережений | Годовые расходы | FIRE (4%) | FIRE (3.5%) |
|------------------|----------------|-----------|-------------|
| $30,000 | $30,000 | $750,000 | $857,000 |
| $40,000 | $40,000 | $1,000,000 | $1,143,000 |
| $50,000 | $50,000 | $1,250,000 | $1,429,000 |
| $75,000 | $75,000 | $1,875,000 | $2,143,000 |
| $100,000 | $100,000 | $2,500,000 | $2,857,000 |
### 1.3 Скорость до FIRE от нормы сбережений
| Норма сбережений | Лет до FIRE |
|-----------------|-------------|
| 10% | 51 лет |
| 20% | 37 лет |
| 30% | 28 лет |
| 40% | 22 года |
| 50% | 17 лет |
| 60% | 12.5 лет |
| 70% | 8.5 лет |
| 80% | 5.5 лет |
*Assumes 5% real returns, starting from zero*
---
## 2. Стратегии вывода (Withdrawal Strategies)
### 2.1 4% Rule (Trinity Study)
- 95% success rate для 30 лет с 60/40 портфелем
- **Для FIRE (40+ лет):** риск sequence-of-returns выше → используй 3.5%
### 2.2 Flexible Spending (Variable Withdrawal)
- В плохие годы режешь дискреционные траты на 10–20%
- В хорошие — тратишь больше
- **Guardrails:** увеличиваешь withdrawal когда портфель выше цели, уменьшаешь когда ниже
### 2.3 Bucket Strategy
- **Bucket 1 (12 года):** кэш/деньги — текущие расходы
- **Bucket 2 (37 лет):** облигации
- **Bucket 3 (8+ лет):** акции
- Ребалансируешь только когда акции переросли
### 2.4 Dividend Investing
- Покрываешь расходы дивидендами (не продавая акции)
- Ниже общая доходность, но выше стабильность
### 2.5 Roth Conversion Ladder
- Конвертируешь traditional IRA → Roth IRA ежегодно (1 год расходов)
- Через 5 лет конвертированные средства доступны без штрафа
- Нужен 5-летний bridge из taxable счетов
---
## 3. Инвестиционные стратегии на 2026
### 3.1 Strategic Asset Allocation (классика)
- Фиксированные цели, ребаланс раз в квартал/год
- **Типичный model:** 60% equities / 30% fixed income / 10% alternatives
- **Историческая доходность 60/40:** ~6.5% annualized (20142024)
- **Плюс:** убирает эмоции, дисциплина правил
- **Минус:** не адаптируется к среде
### 3.2 Three-Fund Portfolio (Bogleheads)
- US Total Stock Market (VTI/VTSAX)
- International Total Stock Market (VXUS/VTIAX)
- US Total Bond Market (BND/VBTLX)
- **Allocation:** 60/20/20 (или агрессивнее для молодых)
- **Expense ratios:** 0.030.05%
- **Разница в комиссиях:** 0.03% vs 1.0% на $500k за 20 лет = ~$100k+
### 3.3 Tactical Asset Allocation (2026 context)
- Отклонения 5–20% от стратегической аллокации на основе макро
- **Overweight:** энергия (commodities supercycle), floating-rate securities, инфраструктура
- **Underweight:** long-duration bonds, перегретые US tech
- **Добавить:** emerging market debt, inflation-protected assets
- **Alpha:** ~2.1% annually above static (multi-asset, 2024 study)
### 3.4 Enhanced Passive (Goldman Sachs 2026)
- **Alpha Enhanced:** tracking error 50200 bps, чуть выше комиссии
- **Зачем 2026:** снижение ожидаемой рыночной доходности, концентрация индексов, неопределённость
- **Systematic factor tilts** — небольшие ставки на value, momentum, quality
### 3.5 Insured / Dynamic Allocation
- **Insured:** автоматический переход в консерватив при drawdown > X%
- **Dynamic:** 7.8% annualized vs static с меньшими просадками (2024)
- **Рекомендация 2026:** infrastructure, private credit, low-duration fixed income
### 3.6 Goldman Sachs 2026: активные ETF + alternatives
- **Active ETFs:** AUM растёт 46% CAGR с 2020
- **Derivative-income ETFs:** $47B inflows (Q1Q3 2025)
- **Private assets:** Millennials держат ~20%, Boomers ~6% — поколенческий сдвиг
- **Tail-risk hedging:** нужен более широкий набор инструментов (не только bonds/USD)
---
## 4. Тактики для нашего контекста (Alex, мультивалютный, KGS-based)
### 4.1 Особенности
- **Базовая валюта:** KGS (высокая волатильность, зависимость от переводов РФ)
- **Счета:** RUB, USD, EUR, KGS, KZT, UZS, AED, CNY
- **Доход:** в основном RUB/USD
- **Расходы:** KGS (жизнь), USD (крупные/накопления), RUB (регулярные)
### 4.2 Что адаптировать в budget-app
#### Phase 6 — что строить (приоритеты):
1. **FIRE Number Calculator**
- Поле: годовые расходы (берутся из фактических транзакций за 12 мес)
- Поле: текущий инвестированный капитал
- Поле: expected return (47%)
- Вывод: FIRE number (25x / 28.6x / 33x), years to FIRE
- Вывод: сколько нужно докладывать в месяц для выхода через N лет
2. **Savings Rate Dashboard**
- `Норма сбережений = (Доходы - Расходы) / Доходы`
- График: savings rate по месяцам
- График: накопленный капитал vs FIRE trajectory (projection line)
3. **FireGoal модель**
- `fire_goal` таблица: user_id, target_amount, target_currency_id, expected_return_rate, monthly_contribution, target_date
- Progress bar: сколько % от цели накоплено
- What-if: "что если увеличу savings rate на 5%?"
4. **Multi-currency FIRE number**
- Пересчёт цели в base currency (KGS) и в USD
- Проблема: валютный риск при пенсии в KGS — нужно показывать и USD-эквивалент
5. **Investment Tracking**
- `investment` / `portfolio` таблицы: date, account_id, value, currency_id
- Ввод: ручные snapshots (когда обновляешь брокерский счёт)
- График: капитал по месяцам + проекция 7% CAGR
- Автоматический импорт: нет (ручной ввод раз в месяц)
6. **Withdrawal Simulator (+FI Calc)**
- Monte Carlo simulation (по историческим данным)
- Поля: начальный капитал, годовые расходы, asset allocation, withdrawal rate
- Результат: probability of success (не остаться без денег)
- Модель 4% vs 3.5% vs variable
7. **"Буфер" / Cash Reserve Dashboard**
- Сколько месяцев расходов в кэше (по счетам типа cash/debit)
- Целевой буфер: 6–12 месяцев расходов
- Trigger: < 3 мес → alert
### 4.3 Архитектурные решения для budget-app
```
investment_account (таблица):
id, user_id, account_id FK → account,
portfolio_value, currency_id, snapshot_date,
asset_class {cash, bonds, stocks, real_estate, crypto, other},
notes
fire_goal (уже есть в схеме):
id, user_id, target_amount, target_currency_id,
expected_return_rate (decimal, 0.07 = 7%),
monthly_contribution (decimal),
target_date NULL,
current_portfolio_value (вычисляется из investment_account snapshot)
fire_projection:
computed view: по месяцам от текущей даты
columns: month, contribution, return, portfolio_value, is_fire (bool если >= target)
```
### 4.4 Какие FIRE-варианты реалистичны для Alex
| FIRE-тип | Расходы/мес | Год | FIRE Number (4%) | Годовая норма сбережений | Лет* |
|----------|-------------|-----|------------------|-------------------------|------|
| Lean FIRE | Минимальные | ??? | ??? | ??? | ??? |
| Chubby FIRE | Текущие | ??? | ??? | ??? | ??? |
| Barista FIRE | С part-time | ??? | ??? | ??? | ??? |
*\* — нужно подставить фактические цифры из budget-app*
**Рекомендуемая стратегия для Alex:**
- Core: Three-Fund Portfolio (VT + BNDW) — глобальная диверсификация, не привязана к KGS
- Satellite: TIPS / real assets (инфляция в KGS выше чем в USD)
- Дивидендная составляющая: покрывает часть расходов (снижает sequence-of-returns risk)
- Буфер: 12+ месяцев расходов в USD (не KGS — защита от девальвации)
- Ребаланс: раз в год, или при drift > 5%
---
## 5. Ключевые метрики на дашборд
| Метрика | Откуда берётся | Формула |
|---------|---------------|---------|
| **Норма сбережений** | Транзакции за 12 мес | (Доходы - Расходы) / Доходы |
| **FIRE Number** | Средние расходы × 25 | (AvgExpenses × 12) × 25 |
| **FIRE Progress** | Инвестиционный капитал | PortfolioValue / FIRE_Number × 100% |
| **Years to FIRE** | Калькулятор | (ln(FIRE/R) - ln(FIRE/(R - P×12))) / ln(1+r) — сложно |
| **Safe Withdrawal Amount** | Портфель × 4% | PortfolioValue × 0.04 |
| **Runway (мес)** | Кэш / расходы в месяц | CashBalance / MonthlyExpenses |
| **Dollar-Cost Avg Equity** | Инвестиции / куплено единиц | — |
---
## 6. Связанные ресурсы
- [[personal/projects/budget-app/index|Budget App — главный документ]]
- [[personal/documents/budget-app-features|Исходный список фич]]
- [WealthVieu FIRE Guide 2026](https://wealthvieu.com/retirement/fire/)
- [Goldman Sachs — Portfolio Construction 2026](https://am.gs.com/en-us/advisors/insights/article/investment-outlook/portfolio-construction-2026)
- [Bogleheads Safe Withdrawal Rates](https://www.bogleheads.org/wiki/Safe_withdrawal_rates)
- [PIMCO — Investment Ideas for 2026](https://www.pimco.com/us/en/insights/charting-the-year-ahead-investment-ideas-for-2026)
+189 -10
View File
@@ -81,7 +81,7 @@
- Категоризация — ручная.
- Невозможно нормально работать с мобильного.
- Анализ ограничен сводными в Excel: нет нормальных графиков по подкатегориям, кросс-фильтрации, прогнозов.
- Нет ничего про FIRE: проекций пенсии, моделирования инвестиций, целевых процентов нормы сбережений.
- Нет ничего про Freedom Gap / инвестиции: нет трекинга портфеля, нормы сбережений, проекции "когда аренда + проекты покроют расходы".
- История курсов хранится в строках листа `курсы` — это не нормализованная таблица.
- Нет API: нельзя интегрировать с банковскими экспортами, нельзя автоматически тянуть в дашборд.
@@ -95,7 +95,7 @@
2. Считает аналитику и сводки **на лету** через SQL, а не через `VLOOKUP`.
3. Импортирует банковские выписки (Альфа, Сбер, Demir, …) автоматически + AI-резолвер для неоднозначных случаев.
4. Даёт мобильный UI для быстрого ввода трат на ходу.
5. Моделирует **FIRE-сценарии** (отдельная фаза после готового бюджетирования).
5. **Freedom Gap** — разница между расходами и пассивным/полупассивным доходом (аренда, дивиденды, проекты). График прогресса к нулевому gap. What-if сценарии: "если проект даёт X, аренда Y — через N лет свобода".
6. Прогнозирует траты на N месяцев вперёд по сезонной модели (категории `🧱 Стройка`, `🏖️ Отдых` цикличны).
### Доменная модель (первая итерация)
@@ -144,13 +144,34 @@ Base currency не хардкодится — выбирается в `Settings`
1. **Phase 0 — Discovery & schema**. Полностью разобрать Excel, утвердить доменную модель. *(в процессе — этот док)*
2. **Phase 1 — Skeleton + import**. Создать `~/Developer/budget-app/` (git init), FastAPI + Vue в Docker Compose в `~/docker/budget-app/` с подключением к существующему Postgres-кластеру на хосте (новая БД `budget_app`). Миграция xlsx → БД. Read-only viewer транзакций + остатков + годовые отчёты. Внешний доступ через `budget.qentra.top` с auth (FastAPI Users + JWT).
3. **Phase 2 — Курсы и настройки**. Cron-джоб для НБ КР с back-fill. Базовая валюта в настройках. Все пересчёты привязаны к ней.
4. **Phase 3 — Ручной ввод**. Форма добавления транзакции, CRUD категорий/счетов, ручная правка курсов.
5. **Phase 4 — Импорт банков + AI-резолвер**. Автофетч/парсеры (Альфа, Сбер, Demir). LLM-резолвер. TG-бот для ручных подтверждений.
6. **Phase 5 — Аналитика**. Дашборды: расход по категориям, динамика, бёрндаун по бюджету. Прогнозы.
7. **Phase 6 — Mobile / PWA**. Быстрый ввод с телефона.
8. **Phase 7 — MCP HTTP**. Экспонировать MCP-эндпоинт для Hermes.
9. **Phase 8 — Инвестпортфели + FIRE**. Тикеры/цены/дивы. Калькулятор FIRE. *(отдельное планирование когда бюджетирование готово.)*
3. **Phase 2 — Полноценный Viewer + CRUD**. Довести до уровня Excel по функциональности:
- Валюта у счетов (символ)
- Категории с иерархией (группа → подкатегория)
- Свёртка по годам/месяцам (drill-down как в Excel)
- Dynamic scrolling (infinite scroll вместо кнопок пагинации)
- CRUD транзакций: добавление, редактирование, удаление
- CRUD категорий
- CRUD счетов
- Ручная правка курсов
- Дашборды: расход по категориям, динамика, бёрндаун по бюджету
- Прогнозы (сезонная модель по категориям)
- Multi-currency: отображение балансов в валюте счёта + в base currency
- Сводная таблица по годам (как лист `сводная` в Excel)
- Налоговый учёт (как лист `налоги 22-24` в Excel)
4. **Phase 3 — Импорт банков + AI-резолвер**. Автофетч/парсеры (Альфа, Сбер, Demir). LLM-резолвер. TG-бот для ручных подтверждений.
5. **Phase 4 — Mobile / PWA**. Быстрый ввод с телефона.
6. **Phase 5 — MCP HTTP**. Экспонировать MCP-эндпоинт для Hermes.
7. **Phase 6 — Freedom / Инвестиции**. Замена классического FIRE на Freedom Gap — разница между расходами и пассивным доходом.
- P0: Savings Rate Dashboard (норма сбережений из транзакций)
- P0: Multi-Currency Net Worth (общий капитал USD/KGS)
- P1: Investment Snapshots (ручной ввод раз в месяц, таблица + график)
- P1: Freedom Gap Dashboard (доход-расход-аренда-проект = gap)
- P1: Passive Income Tracker (аренда + дивиденды + депозиты)
- P2: Freedom Goal with Projection Engine (what-if сценарии)
- P3: What-if Simulator (слайдеры: курс, аренда, проект, норма сбережений)
- Новые таблицы: `investment_snapshot`, `freedom_goal`
- Новые страницы: `/freedom`, `/finances`
- *Не делать:* Monte Carlo, Withdrawal Strategy Planner, Roth Conversion Ladder — не применимы
### Multi-tenant readiness (для будущего public SaaS)
@@ -340,6 +361,12 @@ CLOUDFLARE_TUNNEL_TOKEN=...
- `echarts` (скаффолд под Phase 5, не используем активно)
- dev: `vite`, `typescript`, `eslint`, `prettier`
### Правила работы
1. **Тесты — обязательны** для каждого нового API-роута или изменения. Если код не покрыт тестом — он не готов.
2. **Обновление доку** — после каждой завершённой задачи обновлять таблицу прогресса и Acceptance criteria в этом доке.
3. **Комит** — после каждой логически завершённой задачи (не раз в 10 шагов).
### Acceptance criteria Phase 1
- [ ] `https://budget.qentra.top` открывается, login работает.
@@ -364,4 +391,156 @@ CLOUDFLARE_TUNNEL_TOKEN=...
---
**Создано:** 2026-06-21
**Статус:** Phase 0 закрыт, Phase 1 спланирован
**Статус:** Phase 1 — в работе
## Phase 1 progress
| Шаг | Статус | Кем |
| -------------------------------------- | ------ | ---- |
| Init репо | ✅ | Орёл |
| Backend skeleton | ✅ | Орёл |
| Postgres bootstrap — роль + БД | ✅ | Кит |
| Alembic initial migration | ✅ | Кит |
| FastAPI Users + auth routes | ✅ | Кит |
| Bootstrap первого юзера | ✅ | Кит |
| XLSX импортёр (6 045 транзакций) | ✅ | Кит |
| Read-only API (12 тестов) | ✅ | Кит |
| Frontend skeleton + read-only страницы | ✅ | Кит |
| Docker Compose (2 сервиса, работает) | ✅ | Кит |
| Cloudflare Tunnel budget.qentra.top | ✅ | Alex |
| Сверка данных | ✅ | Кит |
| | | |
## Disaster recovery: CASCADE data loss
**Сценарий:** Удалён пользователь (A-click → user delete). Из-за `ON DELETE CASCADE` на `transaction_user_id_fkey` все транзакции этого пользователя удалены мгновенно (6 045 строк). Балансы обнулены.
### Recovery шаги (на будущее)
```bash
# 1. Пересоздать пользователя с тем же email (admin123)
curl -XPOST .../api/auth/register -H... -d'{"email":"alex@qentra.top","password":"admin123"}'
# 2. Переимпортировать транзакции из xlsx
cd ~/Developer/budget-app
uv run python src/budget/importers/__init__.py
# 3. Проверить балансы — все 18 счетов должны совпасть с excel-balances.md
# 4. Пересобрать и передеплоить backend (дата формат) + frontend (любые изменения)
docker-compose build backend && docker-compose up -d backend
cd frontend && npm run build && cd .. && docker-compose build --no-cache frontend && docker-compose up -d frontend
```
### Формат даты на фронте (актуальный)
API возвращает `t.date.isoformat()` → `2026-05-29T17:00:00`.
Фронт режет: `{{ t.date.slice(0, 10) }} {{ t.date.slice(11, 16) }}` → `2026-05-29 17:00`.
Если время = `00:00` — в Excel не было времени для этой транзакции. Это корректно.
### Символы валют вместо колонки
Колонка "Валюта" убрана из таблиц Transactions, Accounts, TaxRecords.
Вместо неё символ валюты показывается непосредственно перед суммой (Transactions, TaxRecords) или в ячейке (Accounts).
Маппинг на фронте (постоянный, не из БД):
| Код | Символ | Валюта |
|-----|--------|--------|
| USD | $ | Доллар |
| EUR | € | Евро |
| RUB | ₽ | Рубль |
| KGS | С̲ | Сом (с с нижней чертой) |
| KZT | ₸ | Тенге (уже есть в Unicode) |
Файлы: `frontend/src/pages/Transactions.vue`, `Accounts.vue`, `TaxRecords.vue` — каждая содержит `CURRENCY_SYMBOLS` маппинг и функцию `getCurrencySymbol`/`currencySymbol`.
### Пароль
- `admin123` — совпадает с `ADMIN_PASSWORD` в `.env`
## Phase 2 progress
| Шаг | Статус | Кем |
| ---------------------------------------------------------------------------- | ------ | -------- |
| Валюта у счетов (символ) | ✅ | Кит |
| Категории с иерархией (API + фронт) | ✅ | Кит |
| Фронт: формы CRUD (транзакции, категории, счета) | ✅ | Кит |
| Свёртка по годам/месяцам (drill-down) | ✅ | Кит |
| Dynamic scrolling (infinite scroll) | ✅ | Кит |
| CRUD транзакций (API + тесты) | ✅ | Кит |
| CRUD категорий (API + тесты) | ✅ | Кит |
| CRUD счетов (API + тесты) | ✅ | Кит |
| Ручная правка курсов + API | ✅ | Кит |
| Дашборды (расход по категориям, динамика, сводная) | ✅ | Кит |
| Multi-currency отображение (баланс в валюте счёта + base currency) | ✅ | Кит |
| Сводная по годам | ✅ | Кит |
| Налоговый учёт | ✅ | Кит |
| DateTime в транзакциях (date → DateTime, datetime-local на фронте, миграция) | ✅ | Кит |
| Символ валюты вместо колонки (Transactions, Accounts, TaxRecords) | ✅ | Кит |
### Phase 2 — что сделано (подробно)
**API (новые эндпоинты):**
- `GET /api/reports/monthly?year=` — помесячная разбивка доходов/расходов
- `GET /api/reports/summary` — сводная по годам
- `GET /api/reports/category-breakdown?year=&month=` — расходы по группам категорий (данные для дашборда)
- `GET/POST/PUT/DELETE /api/exchange-rates` — CRUD курсов валют
- `GET /api/exchange-rates/summary` — группировка по типу налога
- `GET/POST/PUT/DELETE /api/tax-records` — CRUD налоговых записей
- Accounts API теперь возвращает `balance_in_base` и `base_currency` (мультивалютность)
**БД:**
- Новая таблица `tax_record` (alembic migration)
**Фронтенд (новые страницы):**
- `/dashboard` — дашборд с помесячной динамикой (CSS-chart), расходами по категориям, сводной по годам
- `/exchange-rates` — таблица курсов с фильтрами, CRUD через модалку
- `/tax-records` — таблица налогов с фильтрами и сводкой по типам
**Фронтенд (доработки):**
- `/transactions` — infinite scroll вместо пагинации (scroll-based)
- `/accounts` — отображение баланса в валюте счёта + в базовой валюте
- Навигация обновлена — добавлены ссылки на Дашборд, Курсы, Налоги
## Тесты
Запуск всех тестов одной командой (из `backend/`):
```bash
cd backend && uv run pytest
```
Verbose: `cd backend && uv run pytest -v`
### Фикстуры
Общий `conftest.py` в `tests/` предоставляет:
- `engine` — Postgres test DB (`budget_app_test`) с `create_all`/`drop_all` на каждый тест + seed валют
- `client` — ASGI клиент с зарегистрированным тестовым юзером
- `auth_headers` — JWT Bearer token
Все тесты используют **Postgres** (не sqlite). Настройка через `settings.test_database_url`.
### Покрытие
**37 тестов + 1 skipped**:
| Файл | Тестов | Что проверяет |
|------|--------|---------------|
| `test_health.py` | 1 | Health endpoint |
| `test_models.py` | 3 | Импорт моделей, метаданные, create_all в sqlite |
| `test_auth.py` | 2 | Auth flow (register→login→me), unauthorized |
| `test_api.py` | 6 | Транзакции (list, filter, search), accounts, reports, unauthorized |
| `test_crud.py` | 6 | CRUD транзакций |
| `test_categories.py` | 8 | CRUD групп и категорий |
| `test_accounts.py` | 6 | CRUD счетов, удаление с транзакциями |
| `test_account_balances.py` | 6 | **Баланс: доход+расход, переводы, cross_rate, initial_balance** |
### Вычисление баланса
`balance = initial_balance + incoming - outgoing`
- `incoming` = SUM(amount) if cross_rate IS NULL, SUM(amount * cross_rate) если перевод между валютами (для dest_account)
- `outgoing` = SUM(amount) — всегда в валюте источника
@@ -39,7 +39,7 @@ FastAPI + HTMX + Tailwind CDN + Alpine.js. No build step, CDN-only frontend.
├── services.yaml # Service definitions (source of truth)
├── .env # EAGLE_TOKEN=<secret> (not committed)
├── templates/
│ └── index.html # Dashboard UI — 3 tabs: Services, Pages, Files
│ └── index.html # Dashboard UI — 4 tabs: Services, Crons, Pages, Files
└── com.eagle.dashboard.plist
```
@@ -133,14 +133,102 @@ PID-file-based — survives Dashboard restarts without crashing.
---
## Tabs — заглушка урл
Каждый таб имеет свой URL через `?tab=` query-параметр. Переключение через Alpine.js `setUrlTab()`, который использует `history.replaceState()`. При загрузке `activeTab` инициализируется из `URLSearchParams` (с fallback на `'services'`). При клике на таб URL обновляется без перезагрузки страницы. Бэкенд также принимает `?tab=` в `GET /` и передаёт его как `initial_tab` в шаблон (пока не используется).
**Урлы:**
- `http://dashboard.qentra.top/` — Services
- `http://dashboard.qentra.top/?tab=crons` — Crons
- `http://dashboard.qentra.top/?tab=pages` — Pages
- `http://dashboard.qentra.top/?tab=files` — Files
## Tabs
**Services** — health cards with: status badge, PID, uptime, memory (RSS + children), start/stop/restart controls, log tail drawer (last 200 lines).
**Pages** — URL input + iframe for quick local page testing.
**Pages** — URL input + metadata table for `/Library/WebServer/Documents/*.html` test pages. Each row shows filename, title, purpose, contents, modified date, and opens the page in a new browser tab.
**Files** — FileBrowser iframe at `http://localhost:8181`.
**Crons** (2-я вкладка) — управление cron jobs из 3 источников:
- **Hermes Eagle** (22 jobs) — read/write напрямую в `~/.hermes/cron/jobs.json`
- **Hermes Whale** (0 jobs) — read/write в `~/.hermes/hermes-whale/cron/jobs.json`
- **Launchd (User)** — launchd plist-ы из `~/Library/LaunchAgents/` с `StartInterval` или `StartCalendarInterval` (редактируемые через launchctl + plistlib)
Для каждого крона на карточке: name, schedule, enabled/disabled, last run, prompt preview, chips (skills, toolsets, model, workdir). Кнопки:
- **+ Cron** — создать новый крон (выбор source: Hermes Eagle / Hermes Whale / Launchd (User)). При выборе Launchd поля Deliver, Prompt, Model/Provider, Skills/Toolsets динамически скрываются через `x-show="cronModal.source !== 'launchd'"`.
- ✎ — открыть модал редактирования всех полей (name, schedule, deliver, prompt, script, model, provider, skills, toolsets, workdir)
- ⏸/▶ — pause/resume (toggle enabled)
- 🗑 — удалить крон
|**Launchd create** — `POST /api/crons` с `source=launchd` генерирует plist. Принимает: `name` (→ Label, префикс `com.` если не указан), `schedule` (только `every Ns/m/h/d` → StartInterval в секундах), `script` (→ ProgramArguments, разбивка по пробелам), `workdir` (→ WorkingDirectory). Cron-выражения и ISO-даты для launchd **не поддерживаются** — валидатор на фронтенде их отклоняет с сообщением "Launchd: используйте every 30m / every 2h / every 1d". Поля prompt, deliver, skills, toolsets, model, provider игнорируются. Backend парсит `every N` + суффикс s/m/h/d → множитель 1/60/3600/86400.
**`_list_launchd_crons()`** теперь показывает **все** plist-ы из `~/Library/LaunchAgents/`, не только с StartInterval/StartCalendarInterval. Для plist без schedule — `schedule: "none (manual)"`. Это фикс: раньше plist без schedule (например созданный с cron-выражением) не отображался в дашборде.
**Schedule input — формат подсказки и валидация зависят от source:**
- Для **launchd**: подсказка только `every 30s / 30m / 2h / 1d — интервал`. Плейсхолдер `every 30m / every 2h`. Валидатор reject cron и ISO.
- Для **Eagle/Whale**: полная cron-схема (5 полей), пояснения `*`, `*/N`, `N-M`, `A,B`, `N`, плюс `every 30s / 30m / 2h / 1d` и ISO-дата.
**Schedule hint** — сворачиваемый multiline блок над полем ввода, открывается по кнопке `? подсказка`. Финальный рабочий текст:
```
┌── минута (0-59)
│ ┌── час (0-23)
│ │ ┌── день месяца (1-31)
│ │ │ ┌── месяц (1-12)
│ │ │ │ ┌── день недели (0-6, вс=0)
│ │ │ │ │
* * * * *
* — любое значение
*/N — с шагом N (*/30 = каждые 30)
N-M — диапазон (1-5 = пн-пт)
A,B — список (0,6 = вс,сб)
N — точное число (0 = в 0 мин)
every 30s / 30m / 2h / 1d — человеческий формат
2026-07-01T09:00 — ISO, одноразово (только Eagle/Whale)
```
- `scheduleHintOpen` — boolean в `cronModal`, по умолчанию `false`
- При открытии модала сбрасывается в `false`
- ISO-строка скрыта для launchd через `x-if="cronModal.source !== 'launchd'"`
**API эндпоинты:**
- `GET /` — главная страница, опционально `?tab=crons|pages|files|services`
- `GET /api/crons` — список всех кронов
- `POST /api/crons` — создать новый крон (body: source, name, schedule, prompt, script, deliver, skills, enabled_toolsets, model, provider, workdir). Для source=launchd: только name, schedule, script, workdir используются — генерируется plist и загружается через launchctl
- `PATCH /api/crons/{source}/{job_id}` — редактировать поля
- `POST /api/crons/{source}/{job_id}/toggle` — включить/выключить
- `DELETE /api/crons/{source}/{job_id}` — удалить
**Формат Hermes cron jobs.json:**
```json
{
"jobs": [
{
"id": "531b242c30ad",
"name": "data-pipeline",
"prompt": "[SILENT] bash ...",
"skills": [],
"skill": null,
"model": null,
"provider": null,
"script": null,
"schedule": { "kind": "cron", "expr": "*/30 7-21 * * 1-5", "display": "*/30 7-21 * * 1-5" },
"schedule_display": "*/30 7-21 * * 1-5",
"enabled": false,
"state": "paused",
"deliver": "local",
"enabled_toolsets": null,
"workdir": null
}
]
}
```
---
## Deployment
@@ -171,10 +259,32 @@ launchctl load ~/Library/LaunchAgents/com.eagle.dashboard.plist
**Ghost launchctl entries** — after removing plists, use `launchctl remove <label>` (not `bootout`) to clear bootstrap session entries.
**`_launchd_toggle()` known bug** — `_launchd_toggle()` in `main.py` uses `launchctl list <label>` exit code (0 = loaded) to determine "enabled". But `launchctl list` returns 0 even when process is **not running**, as long as plist is registered. After creating a launchd cron via API (`launchctl load -w`), plist is loaded → `enabled=true` → UI shows Pause. Clicking calls unload → `enabled=false` → "✓ paused". Functionally correct but button state appears inverted if user expected Resume. Fix: rewrite `_launchd_toggle()` to check `Disabled` key in plist after unload rather than `launchctl list` exit code.
## Troubleshooting — launchd cron doesn't run (EX_CONFIG)
When a launchd cron is created and `launchctl print gui/<uid>/<label>` shows `last exit code = 78: EX_CONFIG`:
1. **`~` not expanded in ProgramArguments.** launchd does not expand `~`. Use full path: `/Users/admin/scripts/foo.sh`
2. **Script itself exits 78.** Check script logic: `exit 78` is `EX_CONFIG` (configuration error). Fix the script.
## Troubleshooting — sync-vault appeared as "none (manual)"
**Root cause:** Launchd create accepted cron expression `*/5 * * * *` (pre-fix), couldn't parse it into StartInterval, wrote plist without schedule → `_list_launchd_crons()` (pre-fix) filtered it out. Fixed in two ways:
- Backend create now rejects non-`every` for launchd (frontend validator also blocks)
- `_list_launchd_crons()` now shows ALL plists (schedule: "none (manual)" if no StartInterval/StartCalendarInterval)
**Virfield port conflict** — if previously a LaunchAgent (plist deleted but still loaded), stop it: `launchctl stop com.virfield.server && launchctl remove com.virfield.server`.
---
## History
- 2026-06-04: Initial build. Migrated 14 LaunchAgent plist services under Dashboard supervisor. Single `com.eagle.dashboard` Login Item. cloudflared tunnel at `dashboard.qentra.top`. Ghost entries cleaned via `launchctl remove`.
- 2026-06-25 (round 8): **Multiple bugfixes.** `_msg` timer fix — stale object reference replaced with `find` in fresh array (both cron toggle and service action). `_list_launchd_crons()` enabled fix — removed `and pid is not None` (timer-based launchd jobs always had `enabled: false`). `openAddCron()` — source-dependent default schedule (`every 30m` for launchd, `*/30 * * * *` for Hermes), `scheduleErr` init via `validateSchedule()`. `@change` on source selector replaces default if switching hermes→launchd. Backend create for launchd: only `every Ns/m/h/d` parsed (cron-parser removed). `_list_launchd_crons()` now shows ALL plists.
- 2026-06-25 (round 7): **Launchd schedule — source-dependent validation.** `validateSchedule()` now takes `source` param. For `launchd`: only `every Ns/m/h/d` accepted; cron and ISO rejected with explicit message. For Eagle/Whale: all three formats. Hints, placeholders, and `prettySchedule()` also differ by source. `Query` import added to FastAPI.
- 2026-06-25 (round 6): **Tab URL routing.** Each tab now persists in URL via `?tab=` query-param + `history.replaceState()`. Frontend initializes `activeTab` from URL. Backend accepts `?tab=` on `GET /`. Added `setUrlTab()` to Alpine.js data.
- 2026-06-25 (round 4): **Schedule hint final format applied.** Multiline cron reference (no `#` prefix), field labels (m/h/d/m/w), value notation (`*/N`, `N-M`, `A,B`, `N`), human format (`every 30s / 30m / 2h / 1d`), ISO date. Collapsible via `? подсказка` button. ISO line hidden for launchd. Validator also blocks Save on invalid input.
- 2026-06-25 (round 3): **Schedule hint/validator polish.** Replaced `*/30 * * * *` with `every 30m` in hint text. Added `prettySchedule()`. Removed all prefatory labels from hint.
- 2026-06-25 (round 1): Added **launchd create** support — +Cron source selector now includes "⏰ Launchd (User)". Backend generates plist. Fields for Hermes-only dynamically hidden for launchd.
- 2026-06-24: Added **Crons** tab — management of Hermes Eagle/Whale cron jobs + Launchd (User) agents. Tab moved to 2nd position. `+Cron` button with source selector (Eagle/Whale). Full edit modal: name, schedule, prompt, script, deliver, skills, toolsets, model, provider, workdir. Toggle pause/resume, delete. All sections editable — launchd crons toggle via `launchctl unload/load -w`, edit via `plistlib`+reload, delete via unload+rm. API: `GET/POST/PATCH/DELETE /api/crons`. `json` and `datetime` imports added to main.py.
- 2026-06-04: Initial build.
@@ -2,56 +2,88 @@
title: Hermes Cron Jobs
aliases: [hermes cron, scheduled jobs, cron jobs]
tags: [personal-os, hermes, automation, cron]
updated: 2026-06-16
updated: 2026-06-24
---
# Hermes Cron Jobs
All scheduled jobs running in Hermes on Eagle.
All scheduled jobs running in Hermes on Eagle. Managed via **Eagle Dashboard → Crons tab** (`http://localhost:8880`).
## Active Jobs
Управление через Dashboard API: `GET/PATCH/POST/DELETE /api/crons`.
Файл: `~/.hermes/cron/jobs.json` (Eagle), `~/.hermes/hermes-whale/cron/jobs.json` (Whale).
## Job Format (jobs.json)
```json
{
"id": "531b242c30ad",
"name": "data-pipeline",
"prompt": "[SILENT] Run the data pipeline: bash ~/scripts/run-pipeline.sh",
"skills": [],
"script": null,
"schedule": { "kind": "cron", "expr": "*/30 7-21 * * 1-5", "display": "*/30 7-21 * * 1-5" },
"schedule_display": "*/30 7-21 * * 1-5",
"enabled": false,
"state": "paused",
"deliver": "local",
"enabled_toolsets": null,
"workdir": null,
"model": null,
"provider": null,
"created_at": "...",
"last_run_at": "...",
"last_status": "ok"
}
```
Editable fields via API: name, schedule, prompt, script, deliver, skills, model, provider, enabled_toolsets, workdir.
## All 22 Jobs (Eagle)
### Active (1)
| Job | Schedule | Description | Delivery |
|-----|----------|-------------|----------|
| `data-pipeline` | `*/30 7-21 * * 1-5` | Runs `bash ~/scripts/run-pipeline.sh` | local |
| `eod-summary` | `0 18 * * 1-5` | EOD brief with completed/carries-over/blockers | zulip: daily-brief::EOD Summary |
| `inbox-check` | `*/30 9-19 * * 1-5` | Inbox triage via Claude CLI | discord: #inbox |
| `weekly-plan` | `0 8 * * 1` | Weekly plan via `weekly-plan.md` prompt | zulip: daily-brief::Weekly Plan |
| `weekly-review` | `0 17 * * 5` | Weekly review via `weekly-review.md` prompt | zulip: daily-brief::Weekly Review |
| `generate-daily-brief` | `0 7 * * 1-5` | Daily brief via `daily-brief.md` prompt | zulip: daily-brief::Daily Brief |
| `sync-vault` | `*/5 * * * *` | Syncs obsidian vault via `sync-vault.sh` | local |
| `watchlist-sync` | `0 * * * *` | Watchlist sync (TMDB resolver) | local |
## Paused Jobs
### Paused (21)
| Job | Schedule | Notes |
|-----|----------|-------|
| `retrospector` | `30 17 * * 5` | Paused — experiment, not stabilised |
| `data-pipeline` | `*/30 7-21 * * 1-5` | Runs `bash ~/scripts/run-pipeline.sh` |
| `eod-summary` | `0 18 * * 1-5` | EOD brief → zulip:daily-brief::EOD Summary |
| `inbox-check` | `*/30 9-19 * * 1-5` | Inbox triage → discord:#inbox |
| `weekly-plan` | `0 8 * * 1` | Weekly plan → zulip:daily-brief::Weekly Plan |
| `weekly-review` | `0 17 * * 5` | Weekly review → zulip:daily-brief::Weekly Review |
| `generate-daily-brief` | `0 7 * * 1-5` | Daily brief → zulip:daily-brief::Daily Brief |
| `retrospector` | `30 17 * * 5` | Retrospector → discord:#retrospector |
| `executor-autonomous` | `*/30 * * * *` | Paused — replaced by executor-runner |
| `executor-runner` | `*/5 * * * *` | Paused — waiting for new executor arch |
| `executor-analyzer` | `*/5 * * * *` | Paused — waiting for new executor arch |
| `wiki-curation-daily` | `0 2 * * *` | Paused since 2026-05-29 |
| `AI-психолог` | `0 23 * * 1,4` | Paused since 2026-05-29 — experiment |
| `vault-enrichment` | `0 3 * * 0` | Paused since 2026-05-29 |
| `cross-enrichment` | `0 4 * * 0` | Paused since 2026-05-29 |
| `memory-curation` | `0 4 * * 0` | Paused since 2026-05-29 |
| `proactive-research` | `0 5 * * 6` | Paused since 2026-05-29 |
| `wiki-curation-daily` | `0 2 * * *` | wiki curator (skills: llm-wiki, toolsets: file,web,terminal) |
| `AI-психолог` | `0 23 * * 1,4` | Психолог сессия (Пн/Чт) (toolsets: file,web) |
| `vault-enrichment` | `0 3 * * 0` | Vault enrichment (toolsets: file,terminal) |
| `vault-cross-enrichment` | `0 4 * * 0` | Cross-domain enrichment (toolsets: file,web,terminal) |
| `vault-cross-enrichment-overflow` | `30 5 * * 0` | Overflow handler (toolsets: file,web,terminal) |
| `memory-curation` | `0 4 * * *` | Memory curator (toolsets: file,terminal) |
| `proactive-research` | `0 5 * * 6` | Research queue (toolsets: file,web,terminal) |
| `watchlist-nightly` | `0 1 * * *` | Watchlist nightly (toolsets: terminal) |
| `watchlist-discover` | `0 9 * * 0` | Watchlist discover (toolsets: terminal) |
| `claude-auth-login` | `28 6 * * *` | Claude auth refresh |
| `obsidian-inbox-sort` | `0 1 * * *` | Inbox sort (skills: obsidian-inbox-sort) |
## Known Issues
## Whale Jobs
- `weekly-review` — last run (2026-06-12) failed with `HTTP 500: init handshake timed out after 30000ms`
- `generate-daily-brief` — delivery to Zulip hit 502 on 2026-06-16 morning (Zulip was down); run itself succeeded
0 jobs currently. Whale cron state at `~/.hermes/hermes-whale/cron/jobs.json`.
## Delivery Targets
- `local` — saved to `~/.hermes/cron/output/`, not delivered to any chat
- `discord: #inbox` — Discord inbox channel
- `zulip: daily-brief::*` — Zulip stream `daily-brief`, various topics
- `origin` — back to the Zulip thread where the job was created
- `zulip:stream:topic` — Zulip stream+topic
- `discord:#channel` — Discord channel
## Notes
## Known Issues
- Data pipeline and inbox-check run on overlapping intervals during workdays
- `sync-vault` runs every 5 min always (not restricted to workdays)
- Jobs use `claude` CLI (`/Users/admin/.local/bin/claude`) for LLM calls
- `watchlist-sync` added 2026-06-09, uses skill `watchlist-sync-resolver`
- `weekly-review` — last run (2026-06-12) failed with `HTTP 500: init handshake timed out after 30000ms`
- `generate-daily-brief` — delivery to Zulip hit 502 on 2026-06-16 morning (Zulip was down)
@@ -0,0 +1,84 @@
# Obsidian MCP — текущая архитектура
## Факт: что стоит сейчас
**Hermes native MCP client** (встроен в Hermes Agent, не wrapper).
Конфиг в `~/.hermes/config.yaml` (и в `~/.hermes/hermes-whale/config.yaml`):
```yaml
mcp_servers:
obsidian:
command: mcpvault
args:
- /Users/admin/obsidian
```
Пакет: **`@bitbonsai/mcpvault`** v0.12.1 (npm). Команда `mcpvault`.
Hermes на старте:
1. Читает `mcp_servers` из config.yaml
2. Спавнит `mcpvault /Users/admin/obsidian` как subprocess
3. Init + list_tools → регистрирует инструменты как `mcp_obsidian_*`
4. Агент видит инструменты `mcp_obsidian_read_note`, `mcp_obsidian_patch_note`, и т.д.
**Схема:**
```
Hermes (native MCP client) → spawn: mcpvault → reads/writes /Users/admin/obsidian/
```
### Инструменты, доступные через эту связку
`read_note`, `write_note`, `patch_note`, `list_directory`, `delete_note`, `search_notes`, `move_note`, `move_file`, `read_multiple_notes`, `update_frontmatter`, `get_notes_info`, `get_frontmatter`, `manage_tags`, `get_vault_stats`, `list_all_tags`.
## Факт: что такое ~/scripts/obsidian-mcp-wrapper.js и почему он НЕ используется
**Создан** 2026-05-09, когда вместо `mcpvault` стоял `npx obsidian-mcp` (старый пакет, автор Steven, ещё до переименования в `@bitbonsai/mcpvault`). Пакет постоянно падал:
- ZodError на `"id": null` в notifications — `.strict()` валидация
- Race condition при рестарте gateway
- UTF-8 chunk split портил большие JSON
- Зависания без watchdog
Wrapper решал всё это. **Сейчас НЕ используется.** Конфиг в `~/.hermes/config.yaml`:
```yaml
mcp_servers:
obsidian:
command: mcpvault # ← не node wrapper.js
args:
- /Users/admin/obsidian
```
**Почему:** после перехода на `@bitbonsai/mcpvault` (пришёл на смену старому obsidian-mcp), пакет стабильно работает с Hermes native MCP client. Wrapper стал не нужен. Конфиг поменяли, а doc не обновили.
**Файл `~/scripts/obsidian-mcp-wrapper.js`** лежит на диске, не используется. Надо удалить.
## Факт: корень проблем с patch_note
`mcp_obsidian_patch_note` падает с `"String not found"` — это **НЕ проблема транспорта**. Это проблема **самого mcpvault**:
- Файл: `dist/src/filesystem.js`, строка 217
- Механизм: `fullContent.split(oldString).length - 1`
- **Exact string match** — без trim, без fuzzy, без нормализации whitespace
Workaround: использовать Hermes `patch()` (fuzzy matching, 9 стратегий) для сложных строк. `mcp_obsidian_patch_note` — только для простого текста без спецсимволов.
## Эволюция Obsidian MCP у нас
| Период | Что было | Проблемы |
|--------|----------|----------|
| До 2026-05-09 | `npx obsidian-mcp` (пакет Steven, прямой) | ZodError, race condition, UTF-8 chunk split, зависания |
| 2026-05-09 → ? | `npx obsidian-mcp` через wrapper | Wrapper решил проблемы |
| Сейчас | `mcpvault` (Hermes native MCP client) | Стабильно. patch_note exact match — единственная боль |
## Рекомендация по замене (2026-06-24)
При проблемах с mcpvault (зависания, память, exact match) — **cyanheads/obsidian-mcp-server**:
- `obsidian_replace_in_note` с regex + flexible whitespace (решает exact match)
- 9.7K dl/week, dual transport (stdio + HTTP), active
- Требует Obsidian Local REST API plugin (Obsidian должен быть открыт)
- Есть Docker support (`ghcr.io/cyanheads/obsidian-mcp-server`)
Подробнее: `personal/tech/obsidian-mcp-ecosystem.md`
## См. также
- `personal/tech/obsidian-mcp-ecosystem.md` — обзор всех 6 реализаций
- ⚠️ `personal/projects/personal-os/obsidian-mcp-wrapper.md`**УСТАРЕЛ**, не отражает реальность
@@ -1,114 +1,25 @@
# obsidian-mcp-wrapper
> **Файл**: `~/scripts/obsidian-mcp-wrapper.js`
> **Назначение**: прокси-обёртка над `obsidian-mcp`, решает четыре системных бага
---
status: deprecated
superseded_by: personal/projects/personal-os/obsidian-mcp-setup.md
reason: >-
wrapper не используется с 2026-05. Реальность: Hermes native MCP client →
mcpvault
---
## Проблемы, которые решает
# obsidian-mcp-wrapper — УСТАРЕЛ
### 1. ZodError при инициализации (obsidian-mcp v1.0.6)
> **⚠️ Этот документ не отражает реальность.** Wrapper не используется с мая 2026.
> См. `personal/projects/personal-os/obsidian-mcp-setup.md` и
> `personal/tech/obsidian-mcp-ecosystem.md`.
`obsidian-mcp` падал с ZodError сразу после запуска. Причина: Hermes отправляет
`notifications/initialized` с полем `"id": null`, а obsidian-mcp v1.0.6 использует
`.strict()` валидацию и не принимает лишние поля.
Актуальная архитектура: **Hermes native MCP client → spawn `mcpvault /Users/admin/obsidian`**
(пакет `@bitbonsai/mcpvault` v0.12.1). Конфиг в `~/.hermes/config.yaml``mcp_servers.obsidian`.
**Fix**: wrapper перехватывает все notification-сообщения (без `result`/`error`) с `id === null`
и удаляет поле `id` перед передачей в child.
Wrapper (`~/scripts/obsidian-mcp-wrapper.js`) когда-то решал проблемы старого пакета `obsidian-mcp`
(автор Steven), который падал с ZodError, race condition и UTF-8 chunk split.
После смены пакета на `@bitbonsai/mcpvault` — wrapper стал не нужен.
### 2. Race condition при gateway restart
Корень проблем с `patch_note` (`"String not found"`) — exact string match в самом mcpvault,
**не в транспорте**. Workaround: использовать Hermes `patch()` с fuzzy matching.
При рестарте Hermes gateway поднимает новый процесс `obsidian-mcp-wrapper`. Первые
параллельные tool-вызовы приходят пока child ещё инициализируется (~500ms) → они
тайм-аутились, circuit breaker открывался (3 фейла → 60s cooldown).
**Fix**: wrapper буферизует все tool-вызовы до завершения handshake
(`initialize` → ответ → `notifications/initialized`), потом флашит очередь.
### 3. Corrupted large payloads (UTF-8 chunk split)
При больших tool-вызовах (~200KB+) Node.js доставляет stdin в нескольких chunk-ах.
Старый код делал string split — JSON разрезался по байтам → UTF-8 multibyte символы
портились, `JSON.parse` падал, сообщение дропалось молча.
**Симптом**: `edit_note` с большим контентом тихо зависал (30s timeout), в логах:
```
[obsidian-wrapper] Non-JSON from Hermes (forwarding verbatim): {"jsonrpc": "2.0", "method": "tools/call", "id": 3, "params": {"name": "edit-not
```
**Fix**: stdin и stdout читаются через `Buffer.concat` + `Buffer.slice` на `0x0a`.
Строка собирается полностью до передачи в `JSON.parse`.
### 4. Per-call watchdog (зависший child)
Если child не ответил на `tools/call` / `tools/list` / `resources/*` за **5s**
watchdog убивает процесс. После авторестарта call автоматически уходит в голову
очереди и ретраится.
**Fix**: `armWatchdog(callLine)``setTimeout 5000ms``child.kill()``startChild()`.
---
## Как работает
```
Hermes (stdin) → wrapper → obsidian-mcp (child)
↑ auto-restart при краше (до 10 раз)
```
**Состояния**:
- `ready = false` — child стартует, все tool-вызовы в очередь
- `ready = true` — handshake завершён, очередь флашится, всё проходит напрямую
**Restart логика**:
1. Child упал → `ready = false`, `restarts++`
2. Новый child спавнится
3. Wrapper реплеит сохранённый `initialize` → ждёт ответа с `serverInfo`
4. Отправляет `notifications/initialized` (не форвардит Hermes — он не просил)
5. `ready = true` → флаш очереди
**Shutdown**:
На stdin EOF (`Hermes` закрыл процесс) — child убивается без авторестарта, wrapper выходит чисто.
**Логи** (все в stderr с timestamp):
```
[obsidian-wrapper] 2026-05-09T12:00:00.000Z Spawning obsidian-mcp (vault=/Users/admin/obsidian)
[obsidian-wrapper] 2026-05-09T12:00:00.500Z Hermes → child (handshake complete): notifications/initialized
[obsidian-wrapper] 2026-05-09T12:00:00.501Z Child ready — flushing queue (3 items)
[obsidian-wrapper] 2026-05-09T12:00:01.200Z Stripped id:null from notification: notifications/initialized
[obsidian-wrapper] 2026-05-09T12:00:06.000Z WATCHDOG: child did not respond in 5000ms for tools/call #7 — killing and restarting
```
---
## Конфиг Hermes
`~/.hermes/config.yaml`:
```yaml
obsidian:
command: node
args: [/Users/admin/scripts/obsidian-mcp-wrapper.js]
```
Wrapper сам вызывает `/opt/homebrew/bin/obsidian-mcp /Users/admin/obsidian`.
---
## Производительность
- Инициализация: ~587ms (без ZodError)
- Overhead wrapper: negligible (pure Node.js child_process, нет npm-зависимостей)
- MAX_RETRY: 10
- CALL_TIMEOUT_MS: 5000ms (watchdog)
---
## История
**2026-05-09 (1)** — создан после диагностики 58 ошибок `obsidian/... call failed` в логах Hermes.
Корневая причина — ZodError + race condition при старте. Wrapper написан вместо патча
исходников obsidian-mcp (патч не нужен, wrapper чище и не ломается при обновлении пакета).
**2026-05-09 (2)** — фикс large payload: Buffer-based line splitting вместо string split
(`edit_note` с большим контентом молча дропался). Добавлен per-call watchdog (5s timeout → kill & retry).
MAX_RETRY повышен с 5 до 10.
Историческая документация по wrapper сохранена ниже для ретроспективы.