[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:
@@ -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`)
|
||||
@@ -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 год рекомендуется **3–3.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 (1–2 года):** кэш/деньги — текущие расходы
|
||||
- **Bucket 2 (3–7 лет):** облигации
|
||||
- **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 (2014–2024)
|
||||
- **Плюс:** убирает эмоции, дисциплина правил
|
||||
- **Минус:** не адаптируется к среде
|
||||
|
||||
### 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.03–0.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 50–200 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 (Q1–Q3 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 (4–7%)
|
||||
- Вывод: 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)
|
||||
@@ -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 сохранена ниже для ретроспективы.
|
||||
|
||||
Reference in New Issue
Block a user