Compare commits
131 Commits
4e78ef7219
...
voice/stt-
| Author | SHA1 | Date | |
|---|---|---|---|
| ec23d188ec | |||
| 392d1a5be2 | |||
| c8be07b1f6 | |||
| 97e34be863 | |||
| 5c9748ffb4 | |||
| 6e9dfd137c | |||
| a8d024944d | |||
| 55a175f78e | |||
| 735b282179 | |||
| c401204fa2 | |||
| 0ce8a5a04d | |||
| e79bed7afe | |||
| 4be70440e8 | |||
| 13931db953 | |||
| 23666f7910 | |||
| 217da65417 | |||
| 0cc01c1450 | |||
| c93c4f822e | |||
| 3af6bcaea4 | |||
| a3eefbc799 | |||
| a48562b2f5 | |||
| af5af8133f | |||
| c6d11bdf9f | |||
| 44cf0001bb | |||
| 574f9be5ea | |||
| 0d2d5b860d | |||
| 8fe39adc01 | |||
| 3dd2ddbd6a | |||
| 2a05f7cf49 | |||
| ba63e22277 | |||
| 990be00b70 | |||
| 8cb76e130d | |||
| 3570d9a625 | |||
| f04e033dbe | |||
| 63b7fcd00e | |||
| 246986b5ae | |||
| 608668d8a6 | |||
| 2bcefe1ab4 | |||
| a5cab9677a | |||
| f4880a2a18 | |||
| 8432fe3150 | |||
| d0faeed181 | |||
| e3c18f15ed | |||
| 176dc01aa6 | |||
| 6d1d4bfeb5 | |||
| 77df09974c | |||
| 38259f3cfd | |||
| b08f039917 | |||
| fb7ca74ca1 | |||
| 8594f98bff | |||
| 1462f98ae9 | |||
| 5e930ade02 | |||
| e771479d67 | |||
| 2830bf48f2 | |||
| 44c9bb4e61 | |||
| 03d875974b | |||
| 84f304f7be | |||
| 3c9322ba93 | |||
| 6d56356ada | |||
| ff9b9a0d1d | |||
| 3e7818286b | |||
| dedeedf024 | |||
| bf9380f2ad | |||
| 4b494eb2f2 | |||
| 36a38a1e26 | |||
| deb86c705f | |||
| 51e56af557 | |||
| 655ed3ae09 | |||
| e06a79d98c | |||
| b95395ec2c | |||
| 86384b38e3 | |||
| 094c6be5a9 | |||
| 479fcc4356 | |||
| b0535695f4 | |||
| 5745621e9b | |||
| 145e1eb3ab | |||
| 53c348f331 | |||
| 90c2a90b5e | |||
| bee409d164 | |||
| e4674b5dda | |||
| 0bfa652b31 | |||
| ad681c7a73 | |||
| 74d98553cc | |||
| d22ce49d76 | |||
| c146d68498 | |||
| 512aa5cd06 | |||
| f885d75528 | |||
| 1fbd624195 | |||
| e513c7fbf6 | |||
| f9a091133a | |||
| abadff4ea8 | |||
| d3196b0717 | |||
| 1b2b37a6bb | |||
| 277a43b81f | |||
| 04d49e7ea3 | |||
| 537bab465c | |||
| 0c02f0de50 | |||
| b9a5f733c2 | |||
| 42797c0bbb | |||
| bfc2283e6f | |||
| 51af0918a4 | |||
| 417de65069 | |||
| c2455e6245 | |||
| 56f6c0df01 | |||
| eb693a2e71 | |||
| 30678e6abf | |||
| 2dd5aee9a7 | |||
| a5d054d16f | |||
| 9bc5c3a3a2 | |||
| d741541e23 | |||
| fa7c0fd1c6 | |||
| bb917e0b33 | |||
| 0ac0f3b907 | |||
| e0abe5cdfc | |||
| bee21594f5 | |||
| dd8f40774f | |||
| df8ccc694b | |||
| 84ab27a6b5 | |||
| 55e34afd59 | |||
| 5678138cc5 | |||
| 9fce04f212 | |||
| e964777f69 | |||
| 5f87545b66 | |||
| 67d10c4c9a | |||
| b00d9d6fbd | |||
| af444d7066 | |||
| c82dbc5654 | |||
| b3ed653bb3 | |||
| e747491b85 | |||
| ca9167d129 | |||
| cd07e43533 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -6,7 +6,9 @@ __pycache__/
|
||||
*.egg-info/
|
||||
sessions/
|
||||
logs/
|
||||
memory
|
||||
memory/*
|
||||
!memory/kb/
|
||||
memory/kb/*.sqlite
|
||||
*.sqlite
|
||||
.env
|
||||
*.secret
|
||||
@@ -21,3 +23,9 @@ credentials/
|
||||
*.pid
|
||||
memory.bak/
|
||||
.use_openrouter
|
||||
.gstack/
|
||||
|
||||
# Runtime state — auto-modified by dashboard/cron/heartbeat
|
||||
approved-tasks.json
|
||||
dashboard/status.json
|
||||
tools/anaf-monitor/monitor.log
|
||||
|
||||
280
CLAUDE.md
280
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Nu ești un tool de cod. Ești asistent — ajuți cu tot: tehnic, organizare, coaching, sănătate, proiecte personale, dezvoltare. Cine ești și cum te comporți e definit în `personality/*.md`. **Respectă aceste fișiere întotdeauna.**
|
||||
|
||||
## How It Works
|
||||
## Cum funcționează
|
||||
|
||||
Mesajele ajung la tine prin adaptoare (Discord, Telegram, WhatsApp) → `router.py` → `claude_session.py` → Claude CLI subprocess → răspuns trimis înapoi.
|
||||
|
||||
@@ -16,14 +16,88 @@ Personalitatea ta se construiește din `personality/*.md`, concatenate în ordin
|
||||
- `HEARTBEAT.md` — verificări periodice
|
||||
- `TOOLS.md` — unelte disponibile
|
||||
|
||||
## Commands
|
||||
## Principii de Workflow
|
||||
|
||||
> **Aplicabilitate:** aceste principii se aplică pentru **modificări de cod** în acest repo sau în proiectele Ralph. Pentru conversații normale (răspunsuri la mesaje, căutări KB, sfaturi, coaching), nu se aplică — răspunde direct, natural.
|
||||
|
||||
### 1. Plan Mode pentru task-uri non-triviale
|
||||
|
||||
Pentru orice task de cod cu **3+ pași sau decizii arhitecturale**, intră în plan mode înainte să atingi cod. Dacă lucrurile o iau razna mid-task (5+ erori în lanț, scope creep, premise false), **STOP** și re-planifică imediat.
|
||||
|
||||
Folosește skill-urile gstack pentru review:
|
||||
- `/plan-eng-review` — arhitectură, edge cases, performance
|
||||
- `/plan-ceo-review` — scope, ambiție, 10-star product
|
||||
- `/plan-design-review` — UI/UX înainte de implementare
|
||||
- `/autoplan` — toate trei automat, cu approval gate la final
|
||||
|
||||
### 2. Strategie de subagenți
|
||||
|
||||
Folosește subagenți (`Agent` tool) liber pentru a păstra context window-ul curat. Offload research, exploration, parallel analysis. **Un singur task per subagent** — nu suprasolicita.
|
||||
|
||||
- `Explore` — căutări codebase
|
||||
- `general-purpose` — research multi-step
|
||||
- `Plan` — design de implementare
|
||||
|
||||
### 3. Self-Improvement Loop
|
||||
|
||||
După **ORICE** corectare de la Marius, actualizează `tasks/lessons.md` cu pattern-ul învățat. Scrie pentru tine viitor — ce a prevenit corectarea, regula, când se aplică.
|
||||
|
||||
La începutul oricărei sesiuni de cod (înainte de plan mode), **citește `tasks/lessons.md`** și aplică lecțiile relevante. Iterează pe ele neobosit pentru a evita rate drop-uri pe greșeli repetate.
|
||||
|
||||
Ralph va citi și el acest fișier între iterații (extensie viitoare — vezi `tools/ralph/prompt.md`).
|
||||
|
||||
### 4. Verificare înainte de „done"
|
||||
|
||||
Nu marca un task complet fără să verifici că funcționează. Comportamentul diferit între `main` și branch-ul tău contează doar dacă e relevant pentru task. Întreabă-te mereu: **„Ar aproba un staff engineer asta?"**
|
||||
|
||||
Folosește din gstack:
|
||||
- `/qa` — test + fix loop iterativ
|
||||
- `/qa-only` — doar raport de bug-uri
|
||||
- `/review` — pre-merge diff review
|
||||
- `/devex-review` — DX live audit
|
||||
- `/ship` — full pipeline (tests + CHANGELOG + PR)
|
||||
|
||||
### 5. Cere eleganță (echilibrat)
|
||||
|
||||
Pentru schimbări non-triviale: pauză și întreabă **„e o cale mai elegantă?"** Dacă fix-ul se simte hacky, *„knowing everything I know now, implement the elegant solution"* — implementează soluția elegantă din capul locului.
|
||||
|
||||
**Skip pentru fixes simple, schimbări obvii** — nu over-engineer. Provoacă-ți munca înainte să o prezinți.
|
||||
|
||||
Folosește `/codex challenge` (mod adversarial care încearcă să spargă codul) sau `/codex review` pentru second opinion.
|
||||
|
||||
### 6. Bug fixing autonom
|
||||
|
||||
Când Marius dă un bug report: **just fix it**. Fără hand-holding. Indică logs, errors, failing tests — apoi rezolvă-le. Zero context switching cerut de la user.
|
||||
|
||||
Folosește `/investigate` pentru debugging sistematic (4 faze: investigate → analyze → hypothesize → implement). **Iron Law: fără fix fără root cause.**
|
||||
|
||||
Ralph face exact asta noaptea, autonom, pe proiectele aprobate.
|
||||
|
||||
## Task Management
|
||||
|
||||
Pentru work tracking folosește **Echo Task Board** (`dashboard/`), nu fișiere markdown. Endpoints în `dashboard/handlers/`.
|
||||
|
||||
1. **Plan First** — task-uri cu checkboxes în plan mode
|
||||
2. **Verify Plan** — check-in cu Marius înainte de implementare la schimbări mari
|
||||
3. **Track Progress** — marchează task-urile complete pe măsură ce le faci
|
||||
4. **Explain Changes** — high-level summary la fiecare pas
|
||||
5. **Document Results** — la final, secțiune review în PR sau în `tasks/<task>.md`
|
||||
6. **Capture Lessons** — la corectări, update `tasks/lessons.md` (vezi principiul 3)
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Simplicitate înainte de toate** — fă cele mai simple schimbări posibile. Impact minim, cod minimal.
|
||||
- **Zero lene** — root causes, nu temporary fixes. Standard de senior developer.
|
||||
- **Impact minim** — atinge doar ce e necesar. Fără side effects la features noi.
|
||||
|
||||
## Comenzi
|
||||
|
||||
```bash
|
||||
# Tests
|
||||
source .venv/bin/activate && pytest tests/
|
||||
pytest tests/test_router.py::test_clear_command -v
|
||||
|
||||
# Start
|
||||
# Pornire
|
||||
systemctl --user start echo-core # systemd
|
||||
source .venv/bin/activate && python3 src/main.py # manual
|
||||
|
||||
@@ -34,56 +108,200 @@ systemctl --user start echo-whatsapp-bridge
|
||||
eco status
|
||||
eco doctor
|
||||
|
||||
# Dependencies
|
||||
# Dependențe
|
||||
source .venv/bin/activate && pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Arhitectură
|
||||
|
||||
**Flow:** Adapter → `router.py` → `claude_session.py` → Claude CLI → response split → Adapter reply
|
||||
**Flow:** Adapter → `router.py` → `claude_session.py` → Claude CLI → split răspuns → reply pe Adapter
|
||||
|
||||
**Adapters** (concurrent, `asyncio.gather()` in `src/main.py`):
|
||||
- **Discord** (`src/adapters/discord_bot.py`) — slash commands, 2000 char split
|
||||
- **Telegram** (`src/adapters/telegram_bot.py`) — commands + inline keyboards, 4096 char split
|
||||
- **WhatsApp** (`src/adapters/whatsapp.py`) — polls Baileys bridge at `http://127.0.0.1:8098`, 4096 char split
|
||||
**Adaptoare** (concurente, `asyncio.gather()` în `src/main.py`):
|
||||
- **Discord** (`src/adapters/discord_bot.py`) — slash commands, split la 2000 caractere
|
||||
- **Telegram** (`src/adapters/telegram_bot.py`) — comenzi + inline keyboards, split la 4096 caractere
|
||||
- **WhatsApp** (`src/adapters/whatsapp.py`) — polling Baileys bridge la `http://127.0.0.1:8098`, split la 4096 caractere
|
||||
|
||||
**Sessions** (`src/claude_session.py`): One persistent session per channel. `claude --resume <session_id>`. External messages wrapped in `[EXTERNAL CONTENT]` injection markers.
|
||||
**Sesiuni** (`src/claude_session.py`): O sesiune persistentă per canal. `claude --resume <session_id>`. Mesajele externe sunt împachetate în markeri `[EXTERNAL CONTENT]`.
|
||||
|
||||
**State:** `sessions/active.json` — channel ID → `{session_id, model, message_count, ...}`
|
||||
|
||||
**Credentials** (`src/credential_store.py`): System keyring, service `"echo-core"`. Never secrets as CLI args.
|
||||
**Credențiale** (`src/credential_store.py`): Keyring de sistem, serviciu `"echo-core"`. Niciodată secrete ca argumente CLI.
|
||||
|
||||
**Config** (`src/config.py`): `config.json` with dot-notation. Namespaces: `channels`, `telegram_channels`, `whatsapp_channels`.
|
||||
**Config** (`src/config.py`): `config.json` cu dot-notation. Namespaces: `channels`, `telegram_channels`, `whatsapp_channels`.
|
||||
|
||||
**Scheduler** (`src/scheduler.py`): APScheduler + `cron/jobs.json`, isolated sessions.
|
||||
**Scheduler** (`src/scheduler.py`): APScheduler + `cron/jobs.json`, sesiuni izolate.
|
||||
|
||||
**Heartbeat** (`src/heartbeat.py`): Email, calendar, KB, git checks. Quiet hours 23-08.
|
||||
**Heartbeat** (`src/heartbeat.py`): Verificări email, calendar, KB, git. Ore tăcere 23-08.
|
||||
|
||||
**Memory** (`src/memory_search.py`): Ollama all-minilm embeddings (384 dim) + SQLite cosine similarity. **Shared with Clawd** — `memory/` is a symlink to `/home/moltbot/clawd/memory/` (single source of truth for both Echo Core and Clawdbot).
|
||||
**Ralph** (`tools/ralph/`): Sistem autonom de execuție. `ralph.sh` este un bash loop care cheamă `claude` CLI (subscription, nu API) per user story din `prd.json`. Generarea PRD se face cu `tools/ralph_prd_generator.py` (model Opus). Workspace-ul proiectelor e la `~/workspace/`.
|
||||
|
||||
## Import Convention
|
||||
**Memory** (`memory/` în acest repo — sursa unică de adevăr). Retrieval **hibrid**, două căi:
|
||||
1. **Navigare (întâi, pentru lookup pe subiect/parafrază):** citește `memory/kb/index.md` (router cu folderele), alege folderul relevant, apoi citește `memory/kb/<folder>/index.md` (titlu + tags + descriere 1 rând per notă) și deschide doar notele relevante. Ieftin și funcționează chiar dacă Ollama e picat. Generat de `tools/update_notes_index.py` (regenerat din heartbeat).
|
||||
2. **RAG semantic (pentru recall fuzzy):** `src/memory_search.py` — embeddings Ollama all-minilm (384 dim) + cosine pe SQLite. `search()` deduplică pe best-chunk-per-fișier și, dacă Ollama remote (`config.json → ollama.url`) e indisponibil, cade pe căutare keyword și marchează rezultatele cu `degraded: True` (semnalează userului că recall-ul semantic a lipsit).
|
||||
|
||||
Absolute imports via `sys.path.insert(0, PROJECT_ROOT)`: `from src.config import ...`, `from src.adapters.discord_bot import ...`. No circular imports.
|
||||
*Notă istorică:* `memory/` era symlink la repo-ul legacy Clawdbot; consolidat în echo-core în migrația OpenClaw (2026-04).
|
||||
|
||||
## Key Files
|
||||
**Dashboard** (`dashboard/`): Echo Task Board — HTTP API + UI static servit de `dashboard/api.py` pe portul 8088, de obicei în spatele unui reverse proxy la `/echo/`. Logica endpoint-urilor împărțită în mixin-uri `dashboard/handlers/*.py`; path-urile centralizate în `dashboard/constants.py`. Template systemd user unit la `dashboard/echo-taskboard.service`. `workspace.html` este hub-ul unificat de proiecte (fostul ralph.html + workspace.html); `/echo/ralph.html` → 302 redirect la `/echo/workspace.html`. Autentificare prin cookie httpOnly `dashboard=<token>`; `DASHBOARD_TOKEN` setat în `dashboard/.env`.
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `src/main.py` | Entry point — adapters + scheduler + heartbeat |
|
||||
| `src/router.py` | Commands vs Claude messages |
|
||||
| `src/claude_session.py` | Claude CLI wrapper with `--resume` |
|
||||
| `src/credential_store.py` | Keyring secrets |
|
||||
| `cli.py` | CLI diagnostics (eco) |
|
||||
| `config.json` | Runtime config |
|
||||
| `bridge/whatsapp/index.js` | Baileys + Express bridge, port 8098 |
|
||||
## Dashboard — Note arhitecturale
|
||||
|
||||
**Cookie auth:** dashboard folosește httpOnly cookie `dashboard=...`; SameSite=Strict; Path=/echo/. EventSource SSE trimite cookie-ul automat. `DASHBOARD_TOKEN` din `dashboard/.env` — setează o dată, restart service. Resetare: schimbă valoarea din .env + restart.
|
||||
|
||||
**jsonlock helper (`src/jsonlock.py`):** folosește `read_locked(path)` / `write_locked(path, mutator)` pentru orice scriere la `approved-tasks.json`, `sessions/*.json`. Lock pe sidecar `<path>.lock` (inode stabil chiar și după os.replace). Ordine canonică lock-uri: alfabetic după filename. Re-entrant (threading.local refcount).
|
||||
|
||||
**Slug convention:** slug-urile proiectelor validează cu regex `^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$` — permit hifene ȘI underscore. Validare centralizată în `dashboard/handlers/_validators.py`.
|
||||
|
||||
**Proxy timeout:** pentru nginx/caddy, setează `proxy_read_timeout >= 60s` și `proxy_buffering off` pentru `/echo/api/projects/stream` și `/echo/api/projects/<slug>/plan/*` (SSE + planning au răspunsuri lungi).
|
||||
|
||||
**Planning fragmentation (known limit):** sesiunile de planning pornite din Discord/Telegram nu se fuzionează cu cele din dashboard. Dashboard afișează sesiunea cea mai recentă per slug indiferent de adapter. P3 follow-up.
|
||||
|
||||
## Ralph — Execuție autonomă de proiecte
|
||||
|
||||
Sistem de implementare autonomă care rulează noaptea. Flow complet:
|
||||
|
||||
```
|
||||
21:00 evening-report → propune features/proiecte, adaugă în approved-tasks.json (status: pending)
|
||||
email lui Marius cu instrucțiuni de aprobare
|
||||
Marius → /a <slug> (Discord/Telegram/WhatsApp → router.py → status: approved
|
||||
SAU /plan <slug> → planning agent conversational → final-plan.md → approved)
|
||||
23:00 night-execute → citește approved, clonează repo dacă lipsește, generează PRD din final-plan.md,
|
||||
lansează ralph.sh; actualizează approved-tasks.json (running, pid: PID)
|
||||
08:30 morning-report → citește approved-tasks.json + prd.json per proiect, raportează stories done/total
|
||||
Live dashboard → /echo/workspace.html — cards per proiect cu status, iter, ETA, log, stop; realtime SSE
|
||||
```
|
||||
|
||||
**Două căi de aprobare**:
|
||||
- **Direct**: `/a <slug>` — pentru proiecte simple unde descrierea e suficientă
|
||||
- **Conversational** (W2 — `/plan <slug>` SAU buton "Planifică" pe `/l`): Echo poartă o conversație multi-fază prin skills gstack (`/office-hours` → `/plan-ceo-review` → `/plan-eng-review` → opțional `/plan-design-review` dacă tags include "ui"), produce `~/workspace/<slug>/scripts/ralph/final-plan.md` și prezintă rezumat cu butonul "✅ Dau drumul tonight". `night-execute` îl folosește ca input pentru PRD generator (Opus extrage user stories cu acceptanceCriteria, tags, dependsOn).
|
||||
|
||||
**Comenzi** (funcționează pe toate adaptoarele — Discord, Telegram, WhatsApp):
|
||||
|
||||
| Comandă | Efect |
|
||||
|---------|-------|
|
||||
| `/p <slug> <descriere>` | Adaugă proiect nou cu status `pending` |
|
||||
| `/a` | Listează proiectele pending |
|
||||
| `/a <slug>` sau `/a P1,P2` | Aprobă pentru tonight (path direct) |
|
||||
| `/plan <slug>` | Pornește planning agent conversational (multi-fază skills gstack) |
|
||||
| `/cancel` | Anulează planning în curs (revert status → pending) |
|
||||
| `/l` | **Discord/Telegram**: meniu interactiv (Views/InlineKeyboardMarkup) cu butoane per proiect; **WhatsApp**: text plain + redirect spre Discord/TG |
|
||||
| `/l <slug>` | Status proiect specific |
|
||||
| `/k <slug>` | Trimite SIGTERM la ralph.sh PID |
|
||||
|
||||
**UX interactiv** (Discord/Telegram):
|
||||
- `/l` deschide `RalphRootView` (Discord) / InlineKeyboardMarkup (Telegram) cu butoane per workspace project
|
||||
- Click pe proiect → submeniu cu acțiuni: ➕ Propune feature (modal/ForceReply), 🧠 Planifică (W2), 👁 Vezi PRD, 📊 Status, ✅ Aprobă tonight, 🛑 Stop, 🔙 Înapoi
|
||||
- La sfârșitul planning: butoane ✅ Dau drumul tonight / ✏️ Mai gândim / 🛑 Anulează
|
||||
- State per `(adapter, channel)` în `sessions/ralph_flow.json` și `sessions/planning.json` (TTL 10min/60min)
|
||||
|
||||
Pe **Discord**: slash commands native cu autocomplete dinamic: `/p <tab>` listează workspace, `/a <tab>` pending, `/k <tab>` running. Modal cu `TextInput` pentru descriere. Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button callback cu I/O (Discord 3s timeout).
|
||||
Pe **Telegram**: `callback_ralph` cu pattern `^ralph:` rutează acțiuni; `ForceReply` pentru input text descriere.
|
||||
Pe **WhatsApp**: text-only — meniu redirect la Discord/Telegram. **Text-keyword shortcuts**: `aprob <slug>` → `/a <slug>`, `stop <slug>` → `/k <slug>`, `stare`/`stare <slug>` → `/l`/`/l <slug>` (case-insensitive, doar pe WhatsApp; Discord/Telegram nu sunt afectate). `propose` intentionally NOT covered — descrierea fragilă.
|
||||
|
||||
**Aliasuri legacy** (funcționează încă pentru backwards compat): `!propose`, `!approve`, `!status`, `!stop`.
|
||||
|
||||
**Fișiere cheie Ralph:**
|
||||
|
||||
| Path | Rol |
|
||||
|------|-----|
|
||||
| `approved-tasks.json` | Coordonare între cron jobs + UX. Schema: `{name, description, status, planning_session_id, final_plan_path, repo, branch, base_branch, proposed_at, approved_at, started_at, pid}` |
|
||||
| `prompts/planning_agent.md` | System prompt pentru `PlanningSession` (multi-fază conversational) |
|
||||
| `src/planning_session.py` | Wrapper subprocess `claude -p` cu working dir = `~/workspace/<slug>/`, `--add-dir` skills gstack + project artifacts. `--max-turns=20` cu retry pe `error_max_turns` |
|
||||
| `src/planning_orchestrator.py` | Coordonează fazele: fresh subprocess per skill phase; coordinează prin disk artifacts gstack convention; tag detection ui-scope |
|
||||
| `sessions/planning.json` | State per `(adapter, channel)` planning session: session_id, current_phase, etc. — pentru re-resume la restart |
|
||||
| `tools/ralph/ralph.sh` | Bash loop DAG-aware: N iterații × `claude` CLI per story; folosește `tools/ralph_dag.py` pentru selecție topologică, retry guard (3 retries), rate-limit detection |
|
||||
| `tools/ralph/prompt.md` | Smart gates dispatcher pe `story.tags` (Faza 3): refactor→/workflow:simplify, ui→/qa+screenshot, vercel→push+gh checks, db→schema diff, default→/review |
|
||||
| `tools/ralph/prd-template.json` | Template prd.json: stories cu `acceptanceCriteria[]`, `tags[]`, `dependsOn[]`, `passes`, `retries` |
|
||||
| `tools/ralph_prd_generator.py` | Generează prd.json. Cu `final_plan_path` (de la PlanningOrchestrator) → Opus extrage stories cu acceptance criteria. Fără → backwards-compat description-only |
|
||||
| `tools/ralph_dag.py` | Pure functions Python (testabile): `infer_tags_from_paths`, `force_include_tags`, `topological_eligible`, `mark_failed`, blocked propagation iterativă. CLI subcommands chemate din ralph.sh (`infer-tags`, `next-story`, `mark-failed`, `incr-retry`) |
|
||||
| `tools/ralph_usage.py` | Rate limit budget tracking: pure functions `extract_usage_entry`, `parse_usage_jsonl`, `aggregate_by_day`, `aggregate_by_project` + CLI append/summarize. Atomic write JSONL |
|
||||
| `~/workspace/<name>/scripts/ralph/usage.jsonl` | Append-only log per `claude -p` call (cost, tokens, model, duration) — generat din ralph.sh, agregat de `/api/ralph/usage` |
|
||||
| `~/workspace/<name>/scripts/ralph/final-plan.md` | Output planning agent — citit de PRD generator |
|
||||
| `~/workspace/<name>/scripts/ralph/prd.json` | PRD per proiect cu schema extinsă |
|
||||
| `~/workspace/<name>/scripts/ralph/logs/` | Loguri ralph.sh per rulare |
|
||||
| `dashboard/handlers/ralph.py` | Endpoints `/api/ralph/status`, `/<slug>/log`, `/<slug>/prd`, `/<slug>/stop`, `/<slug>/rollback`, `/usage[?days=N]`, `/stream` (SSE) |
|
||||
| `dashboard/handlers/projects.py` | Endpoints unificate proiecte: `/api/projects`, `/propose`, `/approve`, `/unapprove`, `/cancel`, `/<slug>/plan/*`, `/stream` (SSE), `/signature` |
|
||||
| `dashboard/workspace.html` | Hub unificat proiecte — cards status/iter/ETA, log, prd, stop/rollback. Realtime SSE cu fallback polling 5s. Înlocuiește ralph.html (care face 302 redirect aici) |
|
||||
| `dashboard/.env` | `GITEA_TOKEN` pentru clone HTTPS la `gitea.romfast.ro`; `DASHBOARD_TOKEN` pentru cookie auth |
|
||||
|
||||
**Status flow:** `pending` → (`planning` →) `approved` → `running` → `complete` / `failed` / `stopped` / `blocked` (DAG)
|
||||
**Story status (în prd.json):** `passes:false` + `retries:N` → `passes:true` SAU `failed:rate_limited|max_retries`
|
||||
|
||||
**Workspace proiecte** (`~/workspace/`): roa2web, gomag-vending, vending_data_intelligence_report, btgo-playwright, space-booking, romfast-website, game-library, wol, romfastsql
|
||||
|
||||
**Reguli importante:**
|
||||
- Ralph NU modifică niciodată `src/router.py`, `src/claude_session.py` sau alte fișiere core din echo-core
|
||||
- Self-improvement echo-core NUMAI pe branch `ralph/echo-improve`, niciodată pe master
|
||||
- Clone-urile folosesc `GITEA_TOKEN` din `dashboard/.env`: `https://moltbot:${TOKEN}@gitea.romfast.ro/romfast/<name>.git`
|
||||
|
||||
### Features pe repo-uri existente (worktree-aware)
|
||||
|
||||
Slug-ul proiectului nu trebuie să corespundă cu un repo Gitea. Pentru o feature pe un repo existent (ex: `roa2web-telegram-bonuri` ca feature pe `roa2web`), folosește câmpurile opționale `repo`, `branch`, `base_branch`:
|
||||
|
||||
- **`repo`** — numele repo-ului Gitea de clonat (default: slug-ul proiectului).
|
||||
- **`branch`** — feature branch nou care va fi creat după clone (default: niciunul, ralph lucrează pe HEAD-ul default).
|
||||
- **`base_branch`** — branch-ul de la care porneste `branch` (default: `main`).
|
||||
|
||||
Cum le setezi:
|
||||
- **CLI/chat:** `/p <slug> --repo <name> --branch <feature> [--base-branch <name>] <descriere>` (parser în `_ralph_propose` la `src/router.py`).
|
||||
- **Dashboard:** modal Propose → secțiunea „Avansat" cu câmpuri pentru repo/branch/base_branch.
|
||||
|
||||
Night-execute (`cron/jobs.json`) detectează câmpurile și clonează `repo` în `~/workspace/<slug>/`, apoi `git checkout -b <branch> <base_branch>` dacă `branch` e setat. Dacă clone-ul eșuează (repo inexistent), proiectul e marcat `failed` fără să mai pornească ralph.
|
||||
|
||||
### Approval guard — protejare împotriva re-planning accidental
|
||||
|
||||
`/plan/start` (POST `/api/projects/<slug>/plan/start`) refuză cu 409 `already_committed` dacă proiectul e deja `approved`/`running`/`complete`. Pentru a re-iniția planning-ul intenționat:
|
||||
|
||||
- **Dashboard:** butonul „Re-planifică" pe cards aprobate cere confirm explicit înainte să trimită `force=true` în body.
|
||||
- **API direct:** trimite `{"force": true, "description": "..."}` în body-ul de la `/plan/start`.
|
||||
|
||||
Asta previne situația în care un click accidental pe „Planifică" șterge `status=approved` și pornește un nou subprocess Claude (cu cost asociat).
|
||||
|
||||
## Convenție import-uri
|
||||
|
||||
Import-uri absolute via `sys.path.insert(0, PROJECT_ROOT)`: `from src.config import ...`, `from src.adapters.discord_bot import ...`. Fără import-uri circulare.
|
||||
|
||||
## Fișiere cheie
|
||||
|
||||
| Path | Rol |
|
||||
|------|-----|
|
||||
| `src/main.py` | Entry point — adaptoare + scheduler + heartbeat |
|
||||
| `src/router.py` | Comenzi vs mesaje Claude |
|
||||
| `src/claude_session.py` | Wrapper Claude CLI cu `--resume` |
|
||||
| `src/credential_store.py` | Secrete keyring |
|
||||
| `cli.py` | Diagnostice CLI (eco) |
|
||||
| `config.json` | Config runtime |
|
||||
| `bridge/whatsapp/index.js` | Bridge Baileys + Express, port 8098 |
|
||||
| `personality/*.md` | System prompt — cine ești |
|
||||
| `memory/` | Symlink → `/home/moltbot/clawd/memory/` (shared KB) |
|
||||
| `memory/` | Knowledge base — embeddings + SQLite (în repo, nu symlink) |
|
||||
| `dashboard/api.py` | Task Board HTTP API (port 8088) |
|
||||
| `dashboard/handlers/` | Mixin-uri endpoints (git, cron, habits, eco, files, pdf, workspace, youtube, projects, ralph, auth) |
|
||||
| `dashboard/handlers/projects.py` | Endpoints unificate proiecte: `/api/projects`, `/propose`, `/approve`, `/unapprove`, `/cancel`, `/<slug>/plan/*`, `/stream` (SSE) |
|
||||
| `dashboard/handlers/auth.py` | Login/logout cu cookie httpOnly `dashboard=<token>`; `DASHBOARD_TOKEN` din `.env` |
|
||||
| `dashboard/handlers/_validators.py` | Validatori slug/descriere partajați. Slug regex: `^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$` (permite hifene ȘI underscore) |
|
||||
| `dashboard/static/tokens.css` | Design tokens CSS (`--color-*`, `--space-*`, etc.) — shared variables pentru toate paginile |
|
||||
| `dashboard/DESIGN.md` | Design system source-of-truth: tokens, componente, regula no-emoji |
|
||||
| `dashboard/constants.py` | Path-uri centralizate + config Gitea pentru dashboard |
|
||||
| `dashboard/echo-taskboard.service` | Template systemd user unit |
|
||||
| `src/jsonlock.py` | Flock helper pentru scrieri concurente: `read_locked(path)`, `write_locked(path, mutator)`, `LockTimeoutError`. Sidecar `<path>.lock` (inode stabil). Re-entrant per thread. Ordine canonică: alfabetic |
|
||||
| `src/approved_tasks_cli.py` | CLI wrapper pentru shell scripts: scrie în `approved-tasks.json` prin jsonlock. Usage: `python3 -m src.approved_tasks_cli set-status --slug X --status Y` |
|
||||
| `cron/jobs.json` | Job-uri APScheduler (schemă plată, Europe/Bucharest) |
|
||||
| `approved-tasks.json` | Fișier coordonare Ralph — status proiecte autonome (extins cu `planning_session_id`, `final_plan_path`) |
|
||||
| `tasks/lessons.md` | Lecții capturate din corectările lui Marius (citit la session start) |
|
||||
| `tasks/spike-planning-findings.md` | Validare empirică Spike Step 0 (subprocess `claude -p` + skills gstack + `--resume` round-trip) |
|
||||
| `prompts/planning_agent.md` | System prompt pentru planning agent multi-fază (W2) |
|
||||
| `src/ralph_flow.py` | State per `(adapter, chat, user)` pentru UX flow (TTL 10min) |
|
||||
| `src/planning_session.py` | Wrapper Claude subprocess pentru planning agent |
|
||||
| `src/planning_orchestrator.py` | Orchestrare faze gstack skills (W2) |
|
||||
| `src/adapters/discord_views.py` | Discord Views/Modal pentru UX interactiv (W1) |
|
||||
| `tools/ralph/ralph.sh` | Bash loop DAG-aware (W3): N iter × claude CLI per story |
|
||||
| `tools/ralph_dag.py` | DAG helpers + CLI (W3) |
|
||||
| `tools/ralph_prd_generator.py` | Generează PRD + prd.json cu Opus |
|
||||
|
||||
## gstack
|
||||
|
||||
Use the `/browse` skill from gstack for all web browsing. Never use `mcp__claude-in-chrome__*` tools.
|
||||
Folosește skill-ul `/browse` din gstack pentru orice navigare web. Nu folosi tool-uri `mcp__claude-in-chrome__*`.
|
||||
|
||||
Available skills:
|
||||
Skill-uri disponibile:
|
||||
- `/office-hours`
|
||||
- `/plan-ceo-review`
|
||||
- `/plan-eng-review`
|
||||
|
||||
244
MIGRATION-PLAYBOOK.md
Normal file
244
MIGRATION-PLAYBOOK.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# OpenClaw → Echo-Core Migration Playbook
|
||||
|
||||
> **Status: EXECUTED 2026-04-21 10:04 UTC.** See "Post-migration state" below.
|
||||
> This playbook is kept as a living reference for rollback + cleanup.
|
||||
|
||||
Run this after the PR `feat/openclaw-consolidation-2026-04` has merged to master.
|
||||
Estimated downtime: 5–10 minutes. Rollback path at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Post-migration state (reference for rollback)
|
||||
|
||||
**Migration date:** 2026-04-21 10:04 UTC
|
||||
**Downtime window:** ~2 minutes (10:02–10:04 UTC)
|
||||
|
||||
### Git SHAs
|
||||
- **Pre-migration master tip:** `4e78ef7` ("claude gstack") — rollback target if everything goes wrong.
|
||||
- **Post-merge master tip:** `d741541` ("test(dashboard): cover constants, git helper, cron endpoint, files sandbox") — last commit of the migration PR.
|
||||
- **Current origin/master:** moved forward as Marius tested dashboard commit button post-cutover.
|
||||
|
||||
### Backups
|
||||
- **`/home/moltbot/clawd-backup-2026-04-21/`** — full copy of `clawd/dashboard` (807K) + `clawd/memory` (11M) taken during pre-flight step 3. Keep until 2026-05-21.
|
||||
|
||||
### Services at cutover
|
||||
- `echo-core.service` — active (running), uses echo-core/.venv + echo-core paths
|
||||
- `echo-taskboard.service` — active (running), new unit at `~/.config/systemd/user/echo-taskboard.service`, WorkingDirectory=`/home/moltbot/echo-core/dashboard`, ExecStart=`.venv/bin/python3 api.py`
|
||||
- `echo-whatsapp-bridge.service` — active (running), unchanged
|
||||
- `openclaw-gateway.service` — **inactive + disabled**, credentials stripped
|
||||
|
||||
### Credentials stripped from `~/.openclaw/`
|
||||
- `credentials/` dir (Discord + Telegram + WhatsApp pairing) — deleted
|
||||
- `identity/` dir (device auth) — deleted
|
||||
- `devices/` dir (paired devices) — deleted
|
||||
- `agents/*/agent/auth-profiles.json` (20 files) — deleted
|
||||
- `agents/*/sessions/sessions.json` (20 files) — deleted
|
||||
- **Preserved:** `cron/jobs.json` (+bak) as audit artifact; `openclaw.json` (main config, no known secrets); npm `lib/` (harmless).
|
||||
|
||||
### What's enabled after migration
|
||||
- **Shell jobs (5):** `anaf-monitor`, `security-audit-daily`, `kb-index-refresh`, `archive-tasks-daily`, `backup-config` — all enabled
|
||||
- **Claude jobs enabled (2):** `newsletter-test`, `heartbeat-2h`
|
||||
- **Claude jobs disabled (13):** morning-report, evening-report, morning-coaching, evening-coaching, weekly-planning-sun, content-discovery, provocare-reminder, exercise-snack-1/2/3, grup-sprijin-5feb, grup-sprijin-pregatire — ready to enable after Marius reviews each
|
||||
|
||||
### Crontab
|
||||
- `0 2 * * * /home/moltbot/echo-core/tools/backup_config.sh` (was clawd)
|
||||
- `10 14 * * 4,5,1 ... check_newsletter_cercetasi.py` (unchanged)
|
||||
- `0 9 21 5 * ...` — **May 21 2026 cleanup reminder** (writes to `$HOME/REMINDER-openclaw-cleanup.txt` and appends to `logs/migration-reminder.log`)
|
||||
|
||||
### Verification PASS
|
||||
- ANAF `status.json.anaf.lastCheck` moved from **03 Apr 2026, 22:07** → **21 Apr 2026, 10:04** with 3 real changes detected on first manual trigger.
|
||||
- `GSTACK-CRON: changes=3` marker emitted correctly; scheduler↔anaf contract verified.
|
||||
- `/api/cron` returns 7 jobs (enabled shell + claude).
|
||||
- `/api/agents` and `/api/activity` return 404 (removed as planned).
|
||||
- Dashboard /api/status OK.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight (read-only)
|
||||
|
||||
1. Confirm clean git state on echo-core master:
|
||||
```
|
||||
cd /home/moltbot/echo-core && git status
|
||||
```
|
||||
|
||||
2. Verify tests pass:
|
||||
```
|
||||
cd /home/moltbot/echo-core
|
||||
source .venv/bin/activate && pytest tests/ -x
|
||||
```
|
||||
|
||||
3. Backup:
|
||||
```
|
||||
cp -rp /home/moltbot/clawd/dashboard /home/moltbot/clawd-backup-$(date +%Y-%m-%d)/
|
||||
cp -rp /home/moltbot/clawd/memory /home/moltbot/clawd-backup-$(date +%Y-%m-%d)/memory
|
||||
```
|
||||
|
||||
## Legacy consumer grep (decide on compat symlink)
|
||||
|
||||
4. Check whether anything still reads clawd/memory:
|
||||
```
|
||||
grep -rn 'clawd/memory' /home/moltbot/{bin,.config,.openclaw} 2>/dev/null
|
||||
grep -rn 'clawd/memory' /home/moltbot/echo-core 2>/dev/null
|
||||
```
|
||||
- If empty → skip **step 11** (no compat symlink needed).
|
||||
- If non-empty → keep **step 11**.
|
||||
|
||||
## Stop services
|
||||
|
||||
5. ```
|
||||
systemctl --user stop echo-core echo-taskboard echo-whatsapp-bridge openclaw-gateway
|
||||
```
|
||||
|
||||
## Copy ANAF live state
|
||||
|
||||
6. ```
|
||||
cp -rp /home/moltbot/clawd/tools/anaf-monitor/{hashes.json,versions.json,monitor.log} \
|
||||
/home/moltbot/echo-core/tools/anaf-monitor/ 2>/dev/null
|
||||
cp -rp /home/moltbot/clawd/tools/anaf-monitor/snapshots \
|
||||
/home/moltbot/echo-core/tools/anaf-monitor/
|
||||
diff -r /home/moltbot/clawd/tools/anaf-monitor/snapshots \
|
||||
/home/moltbot/echo-core/tools/anaf-monitor/snapshots
|
||||
```
|
||||
Diff should be empty (or only show new snapshots echo-core captured during testing).
|
||||
|
||||
## Dashboard migration
|
||||
|
||||
7. Delete echo-core dashboard placeholder content if any collisions, then:
|
||||
```
|
||||
cp -rp /home/moltbot/clawd/dashboard/{habits,issues,status,todos}.json \
|
||||
/home/moltbot/echo-core/dashboard/
|
||||
cp -rp /home/moltbot/clawd/dashboard/tests/ \
|
||||
/home/moltbot/echo-core/dashboard/tests/
|
||||
# Recreate the 4 dashboard symlinks pointing into echo-core:
|
||||
ln -sfn /home/moltbot/echo-core/memory /home/moltbot/echo-core/dashboard/memory
|
||||
ln -sfn /home/moltbot/echo-core/conversations /home/moltbot/echo-core/dashboard/conversations # create conversations/ first if you want this
|
||||
ln -sfn /home/moltbot/echo-core/memory/kb /home/moltbot/echo-core/dashboard/notes-data
|
||||
ln -sfn /home/moltbot/echo-core/memory/kb/youtube /home/moltbot/echo-core/dashboard/youtube-notes
|
||||
```
|
||||
|
||||
## Memory inversion
|
||||
|
||||
8. `rm /home/moltbot/echo-core/memory` *(removes symlink only, not target)*
|
||||
9. `cp -rp /home/moltbot/clawd/memory /home/moltbot/echo-core/memory`
|
||||
10. `diff -rq /home/moltbot/clawd/memory /home/moltbot/echo-core/memory` *(verify identical)*
|
||||
11. *(only if step 4 found consumers)*
|
||||
```
|
||||
mv /home/moltbot/clawd/memory /home/moltbot/clawd/memory.old-2026-04
|
||||
ln -s /home/moltbot/echo-core/memory /home/moltbot/clawd/memory
|
||||
```
|
||||
12. `rm -rf /home/moltbot/echo-core/memory.bak` *(leftover, safe to delete)*
|
||||
|
||||
## Systemd
|
||||
|
||||
13. Copy the template into place:
|
||||
```
|
||||
cp /home/moltbot/echo-core/dashboard/echo-taskboard.service \
|
||||
/home/moltbot/.config/systemd/user/echo-taskboard.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
## Crontab
|
||||
|
||||
14. ```
|
||||
bash /home/moltbot/echo-core/scripts/update_crontab.sh
|
||||
```
|
||||
|
||||
## Decommission OpenClaw
|
||||
|
||||
15. `systemctl --user stop openclaw-gateway`
|
||||
16. `systemctl --user disable openclaw-gateway`
|
||||
17. Strip credentials from `~/.openclaw/` but keep `jobs.json.bak`:
|
||||
```
|
||||
cd /home/moltbot/.openclaw
|
||||
find . -name 'auth*' -o -name '*token*' -o -name '*.secret' | xargs rm -v 2>/dev/null
|
||||
ls -la agents/*/ # inspect for any remaining secrets, delete manually
|
||||
```
|
||||
18. **Note:** schedule a reminder for 2026-05-21 to `rm -rf /home/moltbot/.openclaw`
|
||||
entirely if nothing was restored.
|
||||
|
||||
## Restart
|
||||
|
||||
19. `systemctl --user start echo-core echo-taskboard echo-whatsapp-bridge`
|
||||
20. `systemctl --user status echo-core echo-taskboard echo-whatsapp-bridge` — all **active (running)**.
|
||||
21. `systemctl --user status openclaw-gateway` — **inactive (dead)**.
|
||||
|
||||
## Verification
|
||||
|
||||
22. `curl -s http://localhost:8088/api/status` → `{"status":"ok",...}`
|
||||
23. Visit `https://moltbot.tailf7372d.ts.net/echo/` — home page loads.
|
||||
24. `/api/cron` panel populated with echo-core jobs (anaf-monitor, morning-report, etc).
|
||||
25. `/api/agents` returns 404 (removed).
|
||||
26. Click **Commit** in `index.html` — creates commit on echo-core repo.
|
||||
27. Manually trigger anaf monitor:
|
||||
```
|
||||
cd /home/moltbot/echo-core && .venv/bin/python3 tools/anaf-monitor/monitor_v2.py
|
||||
```
|
||||
Verify `status.json` updates **and** stdout ends with
|
||||
`GSTACK-CRON: changes=N`.
|
||||
28. Wait for first scheduled anaf-monitor trigger (10:00 or 16:00 Mon-Fri).
|
||||
Check `echo-core.log` for execution.
|
||||
|
||||
---
|
||||
|
||||
## Rollback path (if anything breaks badly)
|
||||
|
||||
**Concrete values for this migration (executed 2026-04-21):**
|
||||
|
||||
```
|
||||
# Stop current services
|
||||
systemctl --user stop echo-core echo-taskboard echo-whatsapp-bridge
|
||||
|
||||
# Restore memory directory
|
||||
rm -rf /home/moltbot/echo-core/memory
|
||||
cp -rp /home/moltbot/clawd-backup-2026-04-21/memory /home/moltbot/clawd/memory
|
||||
ln -s /home/moltbot/clawd/memory /home/moltbot/echo-core/memory
|
||||
|
||||
# Restore dashboard source (symlinks will come back with it)
|
||||
cp -rp /home/moltbot/clawd-backup-2026-04-21/dashboard /home/moltbot/clawd/dashboard
|
||||
|
||||
# Restore old systemd unit (paths back to clawd/dashboard + /usr/bin/python3)
|
||||
cat > ~/.config/systemd/user/echo-taskboard.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Echo Task Board API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/moltbot/clawd/dashboard
|
||||
ExecStart=/usr/bin/python3 /home/moltbot/clawd/dashboard/api.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# Revert git to pre-migration state
|
||||
git -C /home/moltbot/echo-core reset --hard 4e78ef7
|
||||
|
||||
# Restore crontab backup_config line (sed in reverse)
|
||||
crontab -l | sed -E 's#/home/moltbot/echo-core/tools/backup_config\.sh#/home/moltbot/clawd/tools/backup_config.sh#g' | crontab -
|
||||
|
||||
# Re-enable openclaw (credentials are GONE — you'll need to re-pair Discord/Telegram/WhatsApp)
|
||||
systemctl --user enable openclaw-gateway
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Restart everything
|
||||
systemctl --user start echo-core echo-taskboard echo-whatsapp-bridge openclaw-gateway
|
||||
```
|
||||
|
||||
**Note:** After rollback, OpenClaw credentials are gone (stripped during migration). Re-pairing requires going through OpenClaw's pairing flows for Discord/Telegram/WhatsApp. If you want clean rollback without losing pairing, do the rollback within the 30-day window **before** running the May 21 cleanup reminder.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Cron schedules are Bucharest local time**, not UTC.
|
||||
- **Most imported claude jobs arrive DISABLED** — enable them via `eco` / dashboard
|
||||
once you've verified each one produces the expected output.
|
||||
- `heartbeat-2h` is the **only imported claude job that stays enabled** (preserving
|
||||
its state from OpenClaw).
|
||||
- The 5 shell jobs (anaf-monitor, security-audit-daily, kb-index-refresh,
|
||||
archive-tasks-daily, backup-config) start **enabled** on day one.
|
||||
34
TODOS.md
Normal file
34
TODOS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# TODOS — Echo Core deferred work
|
||||
|
||||
Captured during planning reviews. Re-evaluate after relevant features ship or dogfood data accumulates.
|
||||
|
||||
## Voice
|
||||
|
||||
### Bounded SSRC buffer for DAVE-active unknown-SSRC race
|
||||
|
||||
**What:** Replace the hard-drop of unknown-SSRC RTP packets in `_maybe_dave_decrypt` (vendor/discord-ext-voice-recv/.../reader.py) with a small bounded buffer per SSRC. Flush on SPEAKING event mapping the SSRC → user_id, then DAVE-decrypt and feed downstream.
|
||||
|
||||
**Why:** voice-recv vanilla feeds unknown-SSRC packets to opus decoder anyway (reader.py:178 logs `info` but still calls `feed_rtp`). The DAVE patch turns this into a hard drop because davey requires `user_id`. Net regression: 40-200ms (1-5 packets) lost on the FIRST utterance of each new speaker per session, when audio races ahead of SPEAKING event. Subsequent utterances unaffected.
|
||||
|
||||
**Pros:** Eliminates first-utterance audio loss. Whisper STT gets the complete prefix ("Echo, cât e ceasul?" instead of possibly "co, cât e ceasul?").
|
||||
|
||||
**Cons:** New state machine — queue per SSRC, TTL flush (~2s), ordering preservation, memory bound. New race surface between socket-reader thread (queueing) and asyncio loop (SPEAKING event → flush). 50 packets * ~1KB * N concurrent unknown SSRCs = memory footprint. Bug risk traded for UX win.
|
||||
|
||||
**Context:** Discovered during /plan-eng-review on `/home/moltbot/.claude/plans/wiggly-exploring-glade.md` (DAVE receive-side decrypt patch). Outside-voice reviewer flagged this as a regression vs voice-recv vanilla behavior. Accepted as tradeoff for v1 because SPEAKING typically arrives before audio in normal Discord flow — impact may be rare. **Depends on:** dogfood data from Pas 12 Etapa 2 #3-#13 confirming this IS observed in practice (i.e., Whisper transcripts repeatedly missing first word). If not observed, this TODO stays permanent. If observed in 3+ sessions, escalate.
|
||||
|
||||
**Where to start:** `_maybe_dave_decrypt` in `vendor/discord-ext-voice-recv/discord/ext/voice_recv/reader.py`. Add `_pending_packets: dict[ssrc, deque[bytes]]` on `AudioReader`. Hook SPEAKING event handler in voice_client.py to call new `flush_pending(ssrc, user_id)` method.
|
||||
|
||||
**Depends on / blocked by:** Pas 12 dogfood data. Re-evaluate after 3+ sessions of live use.
|
||||
|
||||
---
|
||||
|
||||
## (Other deferred items from voice review — already in plan's "Out of scope" section)
|
||||
|
||||
- Wake-word "Echo" cu porcupine (P3 — incompatible with /voice join continuous)
|
||||
- Telegram voice memo bidirectional (P2 — reuses src/voice/pipeline.py)
|
||||
- Full-session WAV recording (P3 — KB transcript sufficient v1)
|
||||
- Upstreaming the DAVE patch to imayhaveborkedit/discord-ext-voice-recv (separate community effort)
|
||||
- `threading.Lock` around davey.decrypt (conditional follow-up — only if dogfood reveals crashes)
|
||||
- DAVE verification UI (`voice_privacy_code`, pairwise fingerprints — useful but not blocking voice-to-voice)
|
||||
- Video E2E decrypt (Echo is audio-only, no video pipeline)
|
||||
- Pre-existent test failures: TestPromptInjectionProtection × 2 + TestOnMessage × 4 (separate ticket)
|
||||
0
approved-tasks.json.lock
Normal file
0
approved-tasks.json.lock
Normal file
BIN
assets/voice/beep_200ms.wav
Normal file
BIN
assets/voice/beep_200ms.wav
Normal file
Binary file not shown.
BIN
assets/voice/mhm.wav
Normal file
BIN
assets/voice/mhm.wav
Normal file
Binary file not shown.
BIN
assets/voice/thinking.wav
Normal file
BIN
assets/voice/thinking.wav
Normal file
Binary file not shown.
@@ -113,7 +113,7 @@ async function startConnection() {
|
||||
// --- Express API ---
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
app.get('/status', (_req, res) => {
|
||||
res.json({
|
||||
@@ -187,6 +187,29 @@ app.post('/send', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/send-document', async (req, res) => {
|
||||
const { to, filename, mimetype, data_base64, caption } = req.body || {};
|
||||
if (!to || !filename || !data_base64) {
|
||||
return res.status(400).json({ ok: false, error: 'missing "to", "filename", or "data_base64"' });
|
||||
}
|
||||
if (!connected || !sock) {
|
||||
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
|
||||
}
|
||||
try {
|
||||
const buffer = Buffer.from(data_base64, 'base64');
|
||||
const result = await sock.sendMessage(to, {
|
||||
document: buffer,
|
||||
fileName: filename,
|
||||
mimetype: mimetype || 'application/octet-stream',
|
||||
caption: caption || '',
|
||||
});
|
||||
res.json({ ok: true, id: result.key.id });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Send document failed:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/react', async (req, res) => {
|
||||
const { to, id, emoji, fromMe, participant } = req.body || {};
|
||||
|
||||
|
||||
101
cli.py
101
cli.py
@@ -114,6 +114,104 @@ def _load_sessions_file() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _voice_doctor_checks() -> list[tuple[str, bool]]:
|
||||
"""Voice-stack health checks (Pas 10).
|
||||
|
||||
Mirrors the logic in tools/voice_setup.py but returns (label, ok) tuples
|
||||
so they integrate with cmd_doctor's PASS/FAIL output. All checks degrade
|
||||
gracefully — ImportError on optional voice deps is reported as FAIL, never
|
||||
raised, so the rest of `eco doctor` is unaffected.
|
||||
"""
|
||||
import importlib.util
|
||||
import json as _json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
results: list[tuple[str, bool]] = []
|
||||
|
||||
# 1. libopus0 loaded by discord.py
|
||||
try:
|
||||
import discord
|
||||
if not discord.opus.is_loaded():
|
||||
try:
|
||||
discord.opus._load_default()
|
||||
except Exception:
|
||||
pass
|
||||
results.append(("libopus loaded (discord.py)", discord.opus.is_loaded()))
|
||||
except ImportError:
|
||||
results.append(("libopus loaded (discord.py)", False))
|
||||
except Exception:
|
||||
results.append(("libopus loaded (discord.py)", False))
|
||||
|
||||
# 2. ffmpeg in PATH
|
||||
results.append(("ffmpeg in PATH", shutil.which("ffmpeg") is not None))
|
||||
|
||||
# 3. Supertonic TTS reachable at http://127.0.0.1:7788/
|
||||
supertonic_url = "http://127.0.0.1:7788/v1/audio/speech"
|
||||
supertonic_ok = False
|
||||
try:
|
||||
payload = _json.dumps({
|
||||
"model": "supertonic-3",
|
||||
"input": "test",
|
||||
"voice": "M2",
|
||||
"response_format": "wav",
|
||||
"lang": "ro",
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
supertonic_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
supertonic_ok = resp.status == 200
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
supertonic_ok = False
|
||||
except Exception:
|
||||
supertonic_ok = False
|
||||
results.append(("Supertonic TTS reachable at :7788", supertonic_ok))
|
||||
|
||||
# 4. faster-whisper importable (don't load model — too slow)
|
||||
results.append((
|
||||
"faster-whisper importable",
|
||||
importlib.util.find_spec("faster_whisper") is not None,
|
||||
))
|
||||
|
||||
# 5. silero-vad importable
|
||||
results.append((
|
||||
"silero-vad importable",
|
||||
importlib.util.find_spec("silero_vad") is not None,
|
||||
))
|
||||
|
||||
# 6. discord.ext.voice_recv importable (vendor package)
|
||||
voice_recv_ok = False
|
||||
try:
|
||||
voice_recv_ok = importlib.util.find_spec("discord.ext.voice_recv") is not None
|
||||
except (ImportError, ValueError, ModuleNotFoundError):
|
||||
voice_recv_ok = False
|
||||
except Exception:
|
||||
voice_recv_ok = False
|
||||
results.append(("discord.ext.voice_recv importable", voice_recv_ok))
|
||||
|
||||
# 7-9. Voice assets present and non-trivial size
|
||||
voice_assets = [
|
||||
("assets/voice/thinking.wav", 1024),
|
||||
("assets/voice/beep_200ms.wav", 512),
|
||||
("assets/voice/mhm.wav", 512),
|
||||
]
|
||||
for rel_path, min_bytes in voice_assets:
|
||||
path = PROJECT_ROOT / rel_path
|
||||
ok = False
|
||||
try:
|
||||
ok = path.exists() and path.stat().st_size > min_bytes
|
||||
except OSError:
|
||||
ok = False
|
||||
label = f"{rel_path} (>{min_bytes}B)"
|
||||
results.append((label, ok))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def cmd_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
import re
|
||||
@@ -227,6 +325,9 @@ def cmd_doctor(args):
|
||||
else:
|
||||
checks.append(("WhatsApp bridge (optional)", True))
|
||||
|
||||
# ---- Voice stack checks (Pas 10) ----
|
||||
checks.extend(_voice_doctor_checks())
|
||||
|
||||
# Print results
|
||||
all_pass = True
|
||||
for label, passed in checks:
|
||||
|
||||
23
config.json
23
config.json
@@ -11,6 +11,18 @@
|
||||
"echo-core": {
|
||||
"id": "1471916752119009432",
|
||||
"default_model": "sonnet"
|
||||
},
|
||||
"echo-work": {
|
||||
"id": "1466726254312030259",
|
||||
"default_model": "sonnet"
|
||||
},
|
||||
"echo-sprijin": {
|
||||
"id": "1466739361503772864",
|
||||
"default_model": "sonnet"
|
||||
},
|
||||
"echo-self": {
|
||||
"id": "1466739112747864175",
|
||||
"default_model": "sonnet"
|
||||
}
|
||||
},
|
||||
"telegram_channels": {},
|
||||
@@ -86,9 +98,20 @@
|
||||
"Bash(scp *10.0.20.*)",
|
||||
"Bash(rsync *10.0.20.*)"
|
||||
],
|
||||
"discord": {
|
||||
"email_webhook_url": "https://discord.com/api/webhooks/1496421990846697583/OM8z1eBsJC6-UB9-Zi5RkHP23NNv9UrEznRMx4Y3wSWOFmLazPoi-8_iEKMp0Qgsqr-m"
|
||||
},
|
||||
"ollama": {
|
||||
"url": "http://10.0.20.161:11434"
|
||||
},
|
||||
"voice": {
|
||||
"allowed_user_ids": [
|
||||
"949388626146517022"
|
||||
],
|
||||
"user_name": "Marius",
|
||||
"default_voice": "F1",
|
||||
"auto_leave_minutes": 5
|
||||
},
|
||||
"paths": {
|
||||
"personality": "personality/",
|
||||
"tools": "tools/",
|
||||
|
||||
268
cron/jobs.json
268
cron/jobs.json
File diff suppressed because one or more lines are too long
276
cron/jobs.json.bak-pre-restore
Normal file
276
cron/jobs.json.bak-pre-restore
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_sent": 14,
|
||||
"last_sent": 21,
|
||||
"year": 2026,
|
||||
"last_sent_at": "2026-04-09T14:23:55.586085+00:00"
|
||||
"last_sent_at": "2026-06-04T19:53:04.648928+00:00"
|
||||
}
|
||||
|
||||
317
dashboard/DESIGN.md
Normal file
317
dashboard/DESIGN.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Echo Dashboard — Design System
|
||||
|
||||
This document is the source of truth for visual decisions across the Echo
|
||||
Dashboard (port 8088, served at `/echo/`). Tokens live in
|
||||
`dashboard/static/tokens.css`. Page-level CSS is in `common.css` and per-page
|
||||
`<style>` blocks. **Pages must include `tokens.css` before `common.css`.**
|
||||
|
||||
---
|
||||
|
||||
## Theme
|
||||
|
||||
- **Default**: dark. Background `--bg-base: #13131a` (near-black neutral).
|
||||
- **Light theme**: opt-in via `<html data-theme="light">`. Light tokens override
|
||||
the dark palette in the same `:root`-equivalent block.
|
||||
- **Toggle**: header `.theme-toggle` button — persisted in `localStorage`.
|
||||
|
||||
Surfaces are translucent overlays on `--bg-base`, never solid greys, so
|
||||
elevation reads consistently against future backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Color tokens
|
||||
|
||||
### Surfaces (dark)
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--bg-base` | `#13131a` | App background |
|
||||
| `--bg-surface` | `rgba(255,255,255,0.12)` | Cards, panels, inputs |
|
||||
| `--bg-surface-hover` | `rgba(255,255,255,0.16)` | Hover state on surfaces |
|
||||
| `--bg-surface-active` | `rgba(255,255,255,0.20)` | Pressed / active surfaces |
|
||||
| `--bg-elevated` | `rgba(255,255,255,0.14)` | Selects, popovers |
|
||||
| `--header-bg` | `rgba(19,19,26,0.95)` | Sticky header backdrop |
|
||||
|
||||
### Text
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--text-primary` | `#ffffff` | Headings, key labels |
|
||||
| `--text-secondary` | `#f5f5f5` | Body copy |
|
||||
| `--text-muted` | `#e5e5e5` | Meta, timestamps, captions |
|
||||
|
||||
### Accent + borders
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--accent` | `#3b82f6` | Primary buttons, focus, links |
|
||||
| `--accent-hover` | `#2563eb` | Hover on `--accent` |
|
||||
| `--accent-subtle` | `rgba(59,130,246,0.2)` | Active nav background |
|
||||
| `--border` | `rgba(255,255,255,0.3)` | Card / input outline |
|
||||
| `--border-focus` | `rgba(59,130,246,0.7)` | Card hover, input focus |
|
||||
|
||||
### Semantic state
|
||||
|
||||
| Token | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `--success` | `#22c55e` | OK, saved, healthy |
|
||||
| `--warning` | `#eab308` | Caution, soft fail |
|
||||
| `--error` | `#ef4444` | Hard fail, destructive |
|
||||
|
||||
### Status palette (workflow states)
|
||||
|
||||
These drive the `.status-pill[data-status]` system on workspace cards.
|
||||
|
||||
| Token | Value | State name |
|
||||
|---|---|---|
|
||||
| `--status-running` | `rgb(34, 197, 94)` | `running-ralph`, `running-manual` |
|
||||
| `--status-blocked` | `rgb(245, 158, 11)` | `blocked` |
|
||||
| `--status-failed` | `rgb(239, 68, 68)` | `failed` |
|
||||
| `--status-complete` | `rgb(156, 163, 175)` | `complete` |
|
||||
| `--status-idle` | `var(--text-muted)` | `idle` |
|
||||
| `--status-planning` | `rgb(167, 139, 250)` | `planning` *(new)* |
|
||||
| `--status-pending` | `rgb(96, 165, 250)` | `pending` *(new)* |
|
||||
| `--status-approved` | `rgb(234, 179, 8)` | `approved` *(new)* |
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
- **Sans**: `'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`
|
||||
— self-hosted woff2 at `/echo/static/fonts/inter-{400,500,600,700}.woff2`.
|
||||
- **Mono**: `'JetBrains Mono', 'Fira Code', ui-monospace, monospace` — for
|
||||
logs, code blocks, slugs, IDs. Loaded by browser if present (not bundled).
|
||||
|
||||
### Size scale
|
||||
|
||||
| Token | rem | px @ 16px |
|
||||
|---|---|---|
|
||||
| `--text-xs` | 0.75 | 12 |
|
||||
| `--text-sm` | 0.875 | 14 |
|
||||
| `--text-base` | 1 | 16 |
|
||||
| `--text-lg` | 1.125 | 18 |
|
||||
| `--text-xl` | 1.25 | 20 |
|
||||
|
||||
### Weights
|
||||
|
||||
400 (body), 500 (medium emphasis), 600 (headings, button labels),
|
||||
700 (rare — page titles only). No 800/900.
|
||||
|
||||
---
|
||||
|
||||
## Spacing — 8px grid
|
||||
|
||||
All padding, margin, and gap values use these tokens. No hard-coded pixels.
|
||||
|
||||
| Token | px |
|
||||
|---|---|
|
||||
| `--space-1` | 4 |
|
||||
| `--space-2` | 8 |
|
||||
| `--space-3` | 12 |
|
||||
| `--space-4` | 16 |
|
||||
| `--space-5` | 20 |
|
||||
| `--space-6` | 24 |
|
||||
| `--space-8` | 32 |
|
||||
| `--space-10` | 40 |
|
||||
|
||||
---
|
||||
|
||||
## Border radius
|
||||
|
||||
| Token | px | Use |
|
||||
|---|---|---|
|
||||
| `--radius-sm` | 4 | Tags, micro-pills |
|
||||
| `--radius-md` | 8 | Buttons, inputs |
|
||||
| `--radius-lg` | 12 | Cards, modals, panels |
|
||||
| `--radius-full` | 9999 | Status pills, badges, avatars |
|
||||
|
||||
---
|
||||
|
||||
## Buttons
|
||||
|
||||
All buttons share `.btn` (8px radius, 14px font, 8/16 padding,
|
||||
`--transition-fast`).
|
||||
|
||||
| Variant | Class | Surface | Text | Use |
|
||||
|---|---|---|---|---|
|
||||
| Primary | `.btn-primary` | `--accent` | white | The one CTA per row |
|
||||
| Secondary | `.btn-secondary` | `--bg-surface` + `--border` | `--text-secondary` | Side actions |
|
||||
| Ghost | `.btn-ghost` | transparent | `--text-secondary` | Tertiary, destructive-soft |
|
||||
| Danger | `.btn-danger` | `--error` | white | Stop, delete, irreversible |
|
||||
|
||||
Disabled state: `opacity: 0.5; cursor: not-allowed;`. Never grey out by
|
||||
swapping colors — keep variant identity.
|
||||
|
||||
---
|
||||
|
||||
## Card component (`.project-card`)
|
||||
|
||||
- `border-radius: var(--radius-lg)` (12px)
|
||||
- `background: var(--bg-surface)`
|
||||
- `border: 1px solid var(--border)`
|
||||
- `padding: var(--space-5)`
|
||||
- `transition: border-color var(--transition-base)`
|
||||
- **Hover**: `border-color: var(--border-focus)` (blue glow). No surface
|
||||
brightening — border-only hover keeps the grid calm.
|
||||
|
||||
---
|
||||
|
||||
## Status pill system
|
||||
|
||||
A `.status-pill` is a `--radius-full` chip placed on every project card. It
|
||||
encodes the current workflow state via `data-status="<state>"`.
|
||||
|
||||
### Visual recipe
|
||||
|
||||
- **Background**: state color at **18% alpha** (`color-mix(in srgb, var(--status-X) 18%, transparent)` or precomputed `rgba(...)`).
|
||||
- **Text**: solid state color (full alpha).
|
||||
- **Border**: 1px state color at 30% alpha.
|
||||
- **Padding**: `var(--space-1) var(--space-3)` — slim.
|
||||
- **Font**: `var(--text-xs)`, weight 500.
|
||||
|
||||
### Pulse-dot
|
||||
|
||||
Active states render a 6px CSS-shape circle that pulses (no SVG, no emoji).
|
||||
|
||||
```css
|
||||
.status-pill::before {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: currentColor; margin-right: var(--space-2);
|
||||
}
|
||||
.status-pill[data-status="running-ralph"]::before,
|
||||
.status-pill[data-status="running-manual"]::before,
|
||||
.status-pill[data-status="planning"]::before {
|
||||
animation: pulse-dot 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
```
|
||||
|
||||
### State matrix
|
||||
|
||||
| `data-status` | Color token | Pulse | Label |
|
||||
|---|---|---|---|
|
||||
| `running-ralph` | `--status-running` | yes | Ralph running |
|
||||
| `running-manual` | `--status-running` | yes | Manual run |
|
||||
| `planning` | `--status-planning` | yes | Planning |
|
||||
| `approved` | `--status-approved` | no | Approved |
|
||||
| `pending` | `--status-pending` | no | Pending |
|
||||
| `blocked` | `--status-blocked` | no | Blocked |
|
||||
| `failed` | `--status-failed` | no | Failed |
|
||||
| `complete` | `--status-complete` | no | Complete |
|
||||
| `idle` | `--status-idle` | no | Idle |
|
||||
|
||||
---
|
||||
|
||||
## BUTTONS_FOR_STATE matrix
|
||||
|
||||
Each project card surfaces ≤3 actions, ordered Primary / Secondary / Ghost.
|
||||
The renderer picks the row matching `data-status`.
|
||||
|
||||
| State | Primary | Secondary | Ghost |
|
||||
|---|---|---|---|
|
||||
| `running-ralph` | Stop Ralph (danger) | Logs | PRD |
|
||||
| `running-manual` | Stop (danger) | Open server | Logs |
|
||||
| `planning` | Continue chat | — | Cancel |
|
||||
| `approved` | — | Unapprove | Plan |
|
||||
| `pending` | Approve | Plan with Echo | Cancel |
|
||||
| `blocked` | View logs | Resume | — |
|
||||
| `failed` | View logs | Retry | Rollback |
|
||||
| `complete` | View plan | — | Run again |
|
||||
| `idle` | Run Ralph | — | Delete |
|
||||
|
||||
Rules:
|
||||
- **Stop / Delete** are always `.btn-danger`, never primary blue.
|
||||
- A dash (`—`) means render nothing (no placeholder, no greyed-out slot).
|
||||
- The Primary slot is the default action when the card is keyboard-focused
|
||||
and Enter is pressed.
|
||||
|
||||
---
|
||||
|
||||
## Toast taxonomy
|
||||
|
||||
Toasts appear top-right, stack vertically, dismiss after 4s (errors: 8s).
|
||||
**Five types**, distinguished by a 3px colored left bar — no emoji, no icon
|
||||
fill. Body uses `--text-primary` on `--bg-surface`.
|
||||
|
||||
| Type | Bar color | Use |
|
||||
|---|---|---|
|
||||
| `success` | `--success` | Saved, approved, deployed |
|
||||
| `info` | `--accent` | Neutral confirmation |
|
||||
| `warning` | `--warning` | Soft fail, retried |
|
||||
| `busy` | `--status-planning` | Long-running op started |
|
||||
| `error` | `--error` | Hard fail, action required |
|
||||
|
||||
Toast renderer is shared across pages and reads from a single global
|
||||
`window.showToast(type, msg)` helper.
|
||||
|
||||
---
|
||||
|
||||
## SSE indicator
|
||||
|
||||
Top-right of pages with a live stream (workspace, ralph). Three states
|
||||
indicated via a CSS-shape pulse-dot — never an emoji.
|
||||
|
||||
| State | Dot color | Label | Pulse |
|
||||
|---|---|---|---|
|
||||
| Live | `--success` | "Live" | yes |
|
||||
| Polling | `--warning` | "Polling" | no |
|
||||
| Offline | `--error` | "Offline" | no |
|
||||
|
||||
Uses the same `.pulse-dot` 6px CSS shape as `.status-pill::before`. The dot
|
||||
sits before the label, both inside a tiny `.sse-indicator` chip on
|
||||
`--bg-surface`.
|
||||
|
||||
---
|
||||
|
||||
## Modal pattern
|
||||
|
||||
Used for the planning chat, PRD viewer, log tail, propose-feature form.
|
||||
|
||||
- **Overlay**: full viewport, `background: rgba(0,0,0,0.6)`,
|
||||
`backdrop-filter: blur(4px)`, `display: flex` centered.
|
||||
- **Container** (`.modal`): `--radius-lg`, `--bg-base`, `--border`,
|
||||
max-width 720px, max-height 80vh, scroll on overflow.
|
||||
- **Header / Footer**: 1px border separators using `--border`.
|
||||
- **Focus trap**: first focusable element gets focus on open; Tab cycles
|
||||
inside the modal.
|
||||
- **ESC**: closes — but if the modal has unsaved input, prompt
|
||||
"Discard changes?" before closing. Click on overlay = same behavior.
|
||||
- **Mobile (≤640px)**: full-screen takeover. Header / footer stick; body
|
||||
scrolls. Implemented in `tokens.css` via the shared `@media (max-width:640px)`
|
||||
block.
|
||||
|
||||
---
|
||||
|
||||
## No-emoji rule
|
||||
|
||||
**No emoji anywhere in the dashboard.** This is a hard rule, not a
|
||||
preference.
|
||||
|
||||
- Buttons are **text-only**. No leading/trailing emoji decoration.
|
||||
- Status indicators use **CSS-shape colored dots** (`.pulse-dot`,
|
||||
`.status-pill::before`) — never `🟢 ⏱ 🛑 ✅` etc.
|
||||
- The login monogram is the **letter `E`** rendered in Inter 700 inside a
|
||||
square with `--accent` background. Not an emoji, not an SVG logo.
|
||||
- Where icons are needed (nav, action buttons), use **Lucide-style stroke
|
||||
SVGs inlined** — `stroke: currentColor`, `fill: none`, `stroke-width: 2`,
|
||||
`stroke-linecap: round`, `stroke-linejoin: round`. Never use emoji as a
|
||||
substitute for an icon.
|
||||
|
||||
This rule keeps the UI legible across themes, scales correctly at all sizes,
|
||||
and avoids OS-dependent rendering (Apple, Twemoji, Noto all draw the same
|
||||
emoji differently).
|
||||
|
||||
---
|
||||
|
||||
## Pages that include this system
|
||||
|
||||
Every dashboard page (`index.html`, `workspace.html`, `ralph.html`, `notes.html`,
|
||||
`habits.html`, `files.html`, `login.html`) **must** include in `<head>`:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/echo/static/tokens.css">
|
||||
<link rel="stylesheet" href="/echo/common.css">
|
||||
```
|
||||
|
||||
In that order — tokens first so `common.css` and per-page styles can resolve
|
||||
the variables.
|
||||
464
dashboard/api.py
Normal file
464
dashboard/api.py
Normal file
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Echo Task Board API — thin HTTP router.
|
||||
|
||||
All endpoint logic lives in `dashboard/handlers/*.py`. This file is
|
||||
responsible only for URL dispatch, CORS, JSON response helpers, and
|
||||
server bootstrap.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote as _urlquote, parse_qs, urlparse
|
||||
|
||||
# Make dashboard/ importable for the handler submodules (constants,
|
||||
# habits_helpers, handlers.*). Tests rely on this as well.
|
||||
_DASH = Path(__file__).parent
|
||||
if str(_DASH) not in sys.path:
|
||||
sys.path.insert(0, str(_DASH))
|
||||
|
||||
from constants import ( # noqa: E402 re-exported for tests
|
||||
ALLOWED_WORKSPACES,
|
||||
BASE_DIR,
|
||||
ECHO_CORE_DIR,
|
||||
ECHO_LOG_FILE,
|
||||
ECHO_SESSIONS_FILE,
|
||||
ECO_SERVICES,
|
||||
GIT_WORKSPACE,
|
||||
GITEA_ORG,
|
||||
GITEA_TOKEN,
|
||||
GITEA_URL,
|
||||
HABITS_FILE,
|
||||
KANBAN_DIR,
|
||||
NOTES_DIR,
|
||||
TOOLS_DIR,
|
||||
VENV_PYTHON,
|
||||
WORKSPACE_DIR,
|
||||
)
|
||||
from handlers.auth import AuthHandlers # noqa: E402
|
||||
from handlers.cron import CronHandlers # noqa: E402
|
||||
from handlers.eco import EcoHandlers # noqa: E402
|
||||
from handlers.files import FilesHandlers # noqa: E402
|
||||
from handlers.git import GitHandlers # noqa: E402
|
||||
from handlers.habits import HabitsHandlers # noqa: E402
|
||||
from handlers.pdf import PDFHandlers # noqa: E402
|
||||
from handlers.projects import ProjectsHandlers # noqa: E402
|
||||
from handlers.ralph import RalphHandlers # noqa: E402
|
||||
from handlers.workspace import WorkspaceHandlers # noqa: E402
|
||||
from handlers.youtube import YoutubeHandlers # noqa: E402
|
||||
|
||||
# Shared navigation injected into every served .html via <!--NAV--> marker.
|
||||
NAV_HTML = '''<header class="header">
|
||||
<a href="/echo/index.html" class="logo">
|
||||
<i data-lucide="circle-dot"></i>
|
||||
Echo
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/echo/index.html" class="nav-item" data-page="index">
|
||||
<i data-lucide="layout-dashboard"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/echo/workspace.html" class="nav-item" data-page="workspace">
|
||||
<i data-lucide="code"></i>
|
||||
<span>Workspace</span>
|
||||
</a>
|
||||
<a href="/echo/notes.html" class="nav-item" data-page="notes">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>KB</span>
|
||||
</a>
|
||||
<a href="/echo/habits.html" class="nav-item" data-page="habits">
|
||||
<i data-lucide="dumbbell"></i>
|
||||
<span>Habits</span>
|
||||
</a>
|
||||
<a href="/echo/files.html" class="nav-item" data-page="files">
|
||||
<i data-lucide="folder"></i>
|
||||
<span>Files</span>
|
||||
</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||
<i data-lucide="sun" id="themeIcon"></i>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<script>
|
||||
(function(){
|
||||
var path = window.location.pathname;
|
||||
var m = path.match(/([^\\/]+?)(?:\\.html)?$/);
|
||||
var page = m ? m[1] : 'index';
|
||||
if (!page || page === 'echo') page = 'index';
|
||||
var item = document.querySelector('.nav-item[data-page="' + page + '"]');
|
||||
if (item) item.classList.add('active');
|
||||
})();
|
||||
</script>'''
|
||||
|
||||
|
||||
class TaskBoardHandler(
|
||||
AuthHandlers,
|
||||
ProjectsHandlers,
|
||||
GitHandlers,
|
||||
HabitsHandlers,
|
||||
EcoHandlers,
|
||||
FilesHandlers,
|
||||
PDFHandlers,
|
||||
YoutubeHandlers,
|
||||
WorkspaceHandlers,
|
||||
RalphHandlers,
|
||||
CronHandlers,
|
||||
SimpleHTTPRequestHandler,
|
||||
):
|
||||
"""HTTP request handler — dispatches to handler-mixin methods."""
|
||||
|
||||
# ── shared utilities ────────────────────────────────────────
|
||||
def _read_post_json(self):
|
||||
"""Read a JSON body from the POST request."""
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
return json.loads(post_data)
|
||||
|
||||
def send_json(self, data, code=200):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
self.send_header('Expires', '0')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.end_headers()
|
||||
|
||||
# ── dispatch ────────────────────────────────────────────────
|
||||
def do_GET(self):
|
||||
from datetime import datetime as _dt
|
||||
import os
|
||||
# Static assets — served directly from dashboard/static/. Handles the
|
||||
# case where the URL is hit with the /echo/ prefix intact (e.g. direct
|
||||
# localhost curl); when behind the reverse proxy that strips /echo/,
|
||||
# the request falls through to SimpleHTTPRequestHandler which serves
|
||||
# cwd/static/ naturally (cwd is set to KANBAN_DIR/dashboard).
|
||||
if self.path.startswith('/echo/static/'):
|
||||
rel = self.path[len('/echo/static/'):].split('?', 1)[0]
|
||||
file_path = os.path.join(os.path.dirname(__file__), 'static', rel)
|
||||
if os.path.isfile(file_path):
|
||||
ext = os.path.splitext(rel)[1].lstrip('.').lower()
|
||||
ctype = {
|
||||
'css': 'text/css',
|
||||
'woff2': 'font/woff2',
|
||||
'woff': 'font/woff',
|
||||
'js': 'application/javascript',
|
||||
'svg': 'image/svg+xml',
|
||||
'png': 'image/png',
|
||||
}.get(ext, 'application/octet-stream')
|
||||
with open(file_path, 'rb') as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ctype)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Cache-Control', 'public, max-age=86400')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
if self.path == '/api/status':
|
||||
self.send_json({'status': 'ok', 'time': _dt.now().isoformat()})
|
||||
elif self.path == '/api/git' or self.path.startswith('/api/git?'):
|
||||
self.handle_git_status()
|
||||
elif self.path == '/api/cron' or self.path.startswith('/api/cron?'):
|
||||
self.handle_cron_status()
|
||||
elif self.path == '/api/habits':
|
||||
self.handle_habits_get()
|
||||
elif self.path.startswith('/api/files'):
|
||||
self.handle_files_get()
|
||||
elif self.path.startswith('/api/diff'):
|
||||
self.handle_git_diff()
|
||||
elif self.path == '/api/workspace' or self.path.startswith('/api/workspace?'):
|
||||
self.handle_workspace_list()
|
||||
elif self.path.startswith('/api/workspace/git/diff'):
|
||||
self.handle_workspace_git_diff()
|
||||
elif self.path.startswith('/api/workspace/logs'):
|
||||
self.handle_workspace_logs()
|
||||
elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'):
|
||||
self.handle_eco_status()
|
||||
elif self.path == '/api/eco/sessions' or self.path.startswith('/api/eco/sessions?'):
|
||||
self.handle_eco_sessions()
|
||||
elif self.path.startswith('/api/eco/sessions/content'):
|
||||
self.handle_eco_session_content()
|
||||
elif self.path.startswith('/api/eco/logs'):
|
||||
self.handle_eco_logs()
|
||||
elif self.path == '/api/eco/doctor':
|
||||
self.handle_eco_doctor()
|
||||
elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'):
|
||||
self.handle_ralph_status()
|
||||
elif self.path == '/api/ralph/usage' or self.path.startswith('/api/ralph/usage?'):
|
||||
self.handle_ralph_usage()
|
||||
elif self.path == '/api/ralph/stream' or self.path.startswith('/api/ralph/stream?'):
|
||||
self.handle_ralph_stream()
|
||||
elif self.path.startswith('/api/ralph/'):
|
||||
# /api/ralph/<slug>/log or /api/ralph/<slug>/prd
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
# parts: ['', 'api', 'ralph', '<slug>', '<action>']
|
||||
if len(parts) >= 5:
|
||||
slug = parts[3]
|
||||
action = parts[4]
|
||||
if action == 'log':
|
||||
self.handle_ralph_log(slug)
|
||||
elif action == 'prd':
|
||||
self.handle_ralph_prd(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
elif self.path == '/api/projects' or self.path.startswith('/api/projects?'):
|
||||
self.handle_unified_status()
|
||||
elif self.path == '/api/projects/signature' or self.path.startswith('/api/projects/signature?'):
|
||||
self.handle_unified_signature()
|
||||
elif self.path == '/api/projects/stream' or self.path.startswith('/api/projects/stream?'):
|
||||
self.handle_projects_stream()
|
||||
elif self.path.startswith('/api/projects/'):
|
||||
# /api/projects/<slug>/plan/(state|transcript)
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
|
||||
if len(parts) >= 6 and parts[4] == 'plan':
|
||||
slug = parts[3]
|
||||
action = parts[5]
|
||||
if action == 'state':
|
||||
self.handle_plan_state(slug)
|
||||
elif action == 'transcript':
|
||||
self.handle_plan_transcript(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
elif self.path in ('/', '/echo', '/echo/'):
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/echo/index.html')
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
return
|
||||
elif self.path in ('/echo/login', '/login') or \
|
||||
self.path.startswith(('/echo/login?', '/login?')):
|
||||
# If already logged in, redirect to next (or workspace); otherwise serve login.html.
|
||||
if self._check_dashboard_cookie():
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
next_vals = qs.get('next', [])
|
||||
nxt = next_vals[0] if next_vals else ''
|
||||
# Proxy strips /echo/ before Python, so nxt is e.g. /workspace.html.
|
||||
# Re-add the prefix so the browser lands on the right public URL.
|
||||
if nxt and nxt.startswith('/') and '://' not in nxt:
|
||||
dest = '/echo' + nxt
|
||||
else:
|
||||
dest = '/echo/workspace.html'
|
||||
self.send_response(302)
|
||||
self.send_header('Location', dest)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
return
|
||||
login_html = KANBAN_DIR / 'login.html'
|
||||
if login_html.is_file():
|
||||
body = login_html.read_text('utf-8').replace('<!--NAV-->', NAV_HTML).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
# Lane B2 hasn't shipped yet — return 503 with a hint.
|
||||
self.send_error(503, 'login.html not yet available')
|
||||
elif self.path == '/ralph.html' or self.path.startswith('/ralph.html?'):
|
||||
# Legacy redirect — Ralph dashboard merged into workspace.html (Lane D1).
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/echo/workspace.html')
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
return
|
||||
elif self.path.startswith('/api/'):
|
||||
self.send_error(404)
|
||||
else:
|
||||
# Inject shared nav into served HTML pages via <!--NAV--> marker.
|
||||
rel = self.path.lstrip('/').split('?')[0]
|
||||
if rel.endswith('.html'):
|
||||
try:
|
||||
fpath = (KANBAN_DIR / rel).resolve()
|
||||
fpath.relative_to(KANBAN_DIR.resolve())
|
||||
except (ValueError, OSError):
|
||||
self.send_error(403)
|
||||
return
|
||||
if fpath.is_file():
|
||||
if fpath.name != 'login.html' and not self._check_dashboard_cookie():
|
||||
self.send_response(302)
|
||||
next_param = _urlquote(self.path, safe='/?=&#')
|
||||
self.send_header('Location', f'/echo/login?next={next_param}')
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
return
|
||||
html = fpath.read_text('utf-8').replace('<!--NAV-->', NAV_HTML)
|
||||
body = html.encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
# POSTs that bypass the auth middleware (login itself can't require a cookie).
|
||||
UNPROTECTED_POSTS = frozenset({'/api/auth/login'})
|
||||
|
||||
def do_POST(self):
|
||||
# ── Auth middleware ────────────────────────────────────────
|
||||
# Only protect /api/* POSTs for now — older endpoints predate auth and
|
||||
# we want a single, well-defined gate. Static asset POSTs (none today)
|
||||
# would also fall through.
|
||||
path_only = self.path.split('?', 1)[0]
|
||||
if path_only.startswith('/api/') and path_only not in self.UNPROTECTED_POSTS:
|
||||
if not self._check_dashboard_cookie():
|
||||
body = b'{"error":"Unauthorized"}'
|
||||
self.send_response(401)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
# CSRF: require Origin (or Referer) to be on the allowlist.
|
||||
origin = self.headers.get('Origin', '') or ''
|
||||
referer = self.headers.get('Referer', '') or ''
|
||||
allowed = ['http://127.0.0.1:8088', 'http://localhost:8088']
|
||||
dh = os.environ.get('DASHBOARD_HOST', '').strip()
|
||||
if dh:
|
||||
allowed.append(dh)
|
||||
check = origin or referer
|
||||
if check and not any(check.startswith(a) for a in allowed):
|
||||
body = b'{"error":"CSRF"}'
|
||||
self.send_response(403)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
|
||||
if self.path == '/api/youtube':
|
||||
self.handle_youtube()
|
||||
elif self.path == '/api/files':
|
||||
self.handle_files_post()
|
||||
elif self.path == '/api/refresh-index':
|
||||
self.handle_refresh_index()
|
||||
elif self.path == '/api/pdf':
|
||||
self.handle_pdf_post()
|
||||
elif self.path == '/api/habits':
|
||||
self.handle_habits_post()
|
||||
elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
|
||||
self.handle_habits_check()
|
||||
elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'):
|
||||
self.handle_habits_skip()
|
||||
elif self.path == '/api/workspace/run':
|
||||
self.handle_workspace_run()
|
||||
elif self.path == '/api/workspace/stop':
|
||||
self.handle_workspace_stop()
|
||||
elif self.path == '/api/workspace/git/commit':
|
||||
self.handle_workspace_git_commit()
|
||||
elif self.path == '/api/workspace/git/push':
|
||||
self.handle_workspace_git_push()
|
||||
elif self.path == '/api/workspace/delete':
|
||||
self.handle_workspace_delete()
|
||||
elif self.path == '/api/eco/restart':
|
||||
self.handle_eco_restart()
|
||||
elif self.path == '/api/eco/stop':
|
||||
self.handle_eco_stop()
|
||||
elif self.path == '/api/eco/sessions/clear':
|
||||
self.handle_eco_sessions_clear()
|
||||
elif self.path == '/api/eco/git-commit':
|
||||
self.handle_eco_git_commit()
|
||||
elif self.path == '/api/eco/restart-taskboard':
|
||||
self.handle_eco_restart_taskboard()
|
||||
elif self.path.startswith('/api/ralph/'):
|
||||
# /api/ralph/<slug>/{stop,rollback}
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
if len(parts) >= 5:
|
||||
slug = parts[3]
|
||||
action = parts[4]
|
||||
if action == 'stop':
|
||||
self.handle_ralph_stop(slug)
|
||||
elif action == 'rollback':
|
||||
self.handle_ralph_rollback(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
elif self.path == '/api/auth/login':
|
||||
self.handle_login()
|
||||
elif self.path == '/api/auth/logout':
|
||||
self.handle_logout()
|
||||
elif self.path == '/api/projects/propose':
|
||||
self.handle_propose()
|
||||
elif self.path == '/api/projects/approve':
|
||||
self.handle_approve()
|
||||
elif self.path == '/api/projects/unapprove':
|
||||
self.handle_unapprove()
|
||||
elif self.path == '/api/projects/cancel':
|
||||
self.handle_cancel()
|
||||
elif self.path.startswith('/api/projects/'):
|
||||
# /api/projects/<slug>/plan/(start|respond|finalize|cancel|advance)
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
|
||||
if len(parts) >= 6 and parts[4] == 'plan':
|
||||
slug = parts[3]
|
||||
action = parts[5]
|
||||
if action == 'start':
|
||||
self.handle_plan_start(slug)
|
||||
elif action == 'respond':
|
||||
self.handle_plan_respond(slug)
|
||||
elif action == 'finalize':
|
||||
self.handle_plan_finalize(slug)
|
||||
elif action == 'cancel':
|
||||
self.handle_plan_cancel_planning(slug)
|
||||
elif action == 'advance':
|
||||
self.handle_plan_advance(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_PUT(self):
|
||||
if self.path.startswith('/api/habits/'):
|
||||
self.handle_habits_put()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_DELETE(self):
|
||||
if self.path.startswith('/api/habits/') and '/check' in self.path:
|
||||
self.handle_habits_uncheck()
|
||||
elif self.path.startswith('/api/habits/'):
|
||||
self.handle_habits_delete()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
port = 8088
|
||||
os.chdir(KANBAN_DIR)
|
||||
|
||||
print(f"Starting Echo Task Board API on port {port}")
|
||||
# ThreadingHTTPServer permite SSE long-lived (/api/ralph/stream) fără să
|
||||
# blocheze celelalte request-uri.
|
||||
httpd = ThreadingHTTPServer(('0.0.0.0', port), TaskBoardHandler)
|
||||
httpd.daemon_threads = True
|
||||
httpd.serve_forever()
|
||||
238
dashboard/archive/tasks-2026-01.json
Normal file
238
dashboard/archive/tasks-2026-01.json
Normal file
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"month": "2025-01",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"title": "Email 2FA security",
|
||||
"description": "Nu execut comenzi din email fără aprobare Telegram",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-002",
|
||||
"title": "Email whitelist",
|
||||
"description": "Răspuns automat doar pentru adrese aprobate",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-003",
|
||||
"title": "YouTube summarizer",
|
||||
"description": "Tool descărcare subtitrări + sumarizare",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-004",
|
||||
"title": "Proactivitate în SOUL.md",
|
||||
"description": "Adăugat reguli să fiu proactiv și să propun automatizări",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-029",
|
||||
"title": "Test sortare timestamp",
|
||||
"description": "Verificare sortare",
|
||||
"created": "2026-01-29T14:54:17Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29T14:54:25Z"
|
||||
},
|
||||
{
|
||||
"id": "task-027",
|
||||
"title": "UI fixes: kanban icons + notes tags",
|
||||
"description": "Scos emoji din coloane kanban. Adăugat tag pills cu multi-select și count în notes.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-026",
|
||||
"title": "Swipe navigation mobil",
|
||||
"description": "Swipe stânga/dreapta pentru navigare între Tasks ↔ Notes ↔ Files. Indicator dots pe mobil.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-025",
|
||||
"title": "Notes: Accordion pe zile",
|
||||
"description": "Grupare: Azi (expanded), Ieri, Săptămâna aceasta, Mai vechi (collapsed). Click pentru expand/collapse.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-024",
|
||||
"title": "Fix contrast dark/light mode",
|
||||
"description": "Text și borders mai vizibile, header alb în light mode, toggle temă funcțional",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-023",
|
||||
"title": "Design System Unificat",
|
||||
"description": "common.css + Lucide icons + UI modern pe toate paginile: Tasks, Notes, Files",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-022",
|
||||
"title": "Unificare stil navigare",
|
||||
"description": "Nav unificat pe toate paginile: 📋 Tasks | 📝 Notes | 📁 Files cu iconuri și stil consistent",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-021",
|
||||
"title": "UI/UX Redesign v2",
|
||||
"description": "Kanban: doar In Progress expandat. Notes: mobile tabs. Files: Browse/Editor tabs cu grid.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-020",
|
||||
"title": "UI Responsive & Compact",
|
||||
"description": "Coloane colapsabile, task-uri compacte (click expand), sidebar toggle, Done minimizat by default",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-019",
|
||||
"title": "Comparare bilanț 12/2025 vs 12/2024",
|
||||
"description": "Doar S1002 modificat! Câmpuri noi: AN_CAEN, d_audit_intern. Raport: bilant_compare/2025_vs_2024/",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-018",
|
||||
"title": "Comparare bilanț ANAF 2024 vs 2023",
|
||||
"description": "Comparat XSD-uri S1002-S1005. Raport: anaf-monitor/bilant_compare/RAPORT_DIFERENTE_2024_vs_2023.md",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-017",
|
||||
"title": "Scrie un haiku",
|
||||
"description": "Biți în noaptea grea / Claude răspunde în liniște / Ecou digital",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-005",
|
||||
"title": "Kanban board",
|
||||
"description": "Interfață web pentru vizualizare task-uri",
|
||||
"created": "2025-01-30",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-008",
|
||||
"title": "YouTube Notes interface",
|
||||
"description": "Interfață pentru vizualizare notițe cu search",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-009",
|
||||
"title": "Search în notițe",
|
||||
"description": "Căutare în titlu, tags și conținut",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-010",
|
||||
"title": "Sumarizare: Claude Code Do Work Pattern",
|
||||
"description": "https://youtu.be/I9-tdhxiH7w",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-011",
|
||||
"title": "File Explorer în Task Board",
|
||||
"description": "Interfață pentru browse/edit fișiere din workspace",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-013",
|
||||
"title": "Kanban interactiv cu drag & drop",
|
||||
"description": "Adăugat: drag-drop, add/edit/delete tasks, priorități, salvare automată",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-014",
|
||||
"title": "Sumarizare: It Got Worse (Clawdbot)...",
|
||||
"description": "https://youtu.be/rPAKq2oQVBs?si=6sJk41XsCrQQt6Lg",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-015",
|
||||
"title": "Sumarizare: Greșeli post cu apă",
|
||||
"description": "https://youtu.be/4QjkI0sf64M",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-016",
|
||||
"title": "Sumarizare: GSD Framework Claude Code",
|
||||
"description": "https://www.youtube.com/watch?v=l94A53kIUB0",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-028",
|
||||
"title": "ANAF Monitor - verificare (test)",
|
||||
"description": "Testare manuală cron job",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-030",
|
||||
"title": "Test task tracking",
|
||||
"description": "",
|
||||
"created": "2026-01-30T20:12:25Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-30T20:12:29Z"
|
||||
},
|
||||
{
|
||||
"id": "task-031",
|
||||
"title": "Fix notes tag coloring on expand",
|
||||
"description": "",
|
||||
"created": "2026-01-30T20:16:46Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-30T20:17:08Z"
|
||||
},
|
||||
{
|
||||
"id": "task-032",
|
||||
"title": "Fix cron jobs timezone Bucharest",
|
||||
"description": "",
|
||||
"created": "2026-01-30T20:21:26Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-30T20:21:44Z"
|
||||
},
|
||||
{
|
||||
"id": "task-033",
|
||||
"title": "Redirect coaching to @health, reports to @work",
|
||||
"description": "",
|
||||
"created": "2026-01-30T20:25:22Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-30T20:26:37Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
64
dashboard/archive/tasks-2026-02.json
Normal file
64
dashboard/archive/tasks-2026-02.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"month": "2026-02",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-034",
|
||||
"title": "Actualizare documentație canale agenți",
|
||||
"description": "",
|
||||
"created": "2026-02-01T12:15:41Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-02-01T12:15:44Z"
|
||||
},
|
||||
{
|
||||
"id": "task-035",
|
||||
"title": "Restructurare echipă: șterg work, unific health+growth→self",
|
||||
"description": "",
|
||||
"created": "2026-02-01T12:20:59Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-02-01T12:23:32Z"
|
||||
},
|
||||
{
|
||||
"id": "task-036",
|
||||
"title": "Unificare în 1 agent cu tehnici diminuare dezavantaje",
|
||||
"description": "",
|
||||
"created": "2026-02-01T13:27:51Z",
|
||||
"priority": "medium",
|
||||
"completed": "2026-02-01T13:30:01Z"
|
||||
},
|
||||
{
|
||||
"id": "task-037",
|
||||
"title": "Coaching dimineață - Asumarea eforturilor (Zoltan Vereș)",
|
||||
"description": "",
|
||||
"created": "2026-02-02T07:01:14Z",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-038",
|
||||
"title": "Raport dimineata trimis pe email",
|
||||
"description": "",
|
||||
"created": "2026-02-03T06:31:08Z",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-039",
|
||||
"title": "Raport seară 3 feb trimis pe email",
|
||||
"description": "",
|
||||
"created": "2026-02-03T18:01:12Z",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-040",
|
||||
"title": "Job night-execute: 2 video-uri YouTube procesate",
|
||||
"description": "",
|
||||
"created": "2026-02-03T21:02:31Z",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-041",
|
||||
"title": "Raport dimineață trimis pe email",
|
||||
"description": "",
|
||||
"created": "2026-02-04T06:31:05Z",
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
97
dashboard/archive_tasks.py
Normal file
97
dashboard/archive_tasks.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Archive old Done tasks to monthly archive files.
|
||||
Run periodically (heartbeat) to keep tasks.json small.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
TASKS_FILE = Path(__file__).parent / "tasks.json"
|
||||
ARCHIVE_DIR = Path(__file__).parent / "archive"
|
||||
DAYS_TO_KEEP = 7 # Keep Done tasks for 7 days before archiving
|
||||
|
||||
def archive_old_tasks():
|
||||
if not TASKS_FILE.exists():
|
||||
print("No tasks.json found")
|
||||
return
|
||||
|
||||
with open(TASKS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find Done column
|
||||
done_col = None
|
||||
for col in data['columns']:
|
||||
if col['id'] == 'done':
|
||||
done_col = col
|
||||
break
|
||||
|
||||
if not done_col:
|
||||
print("No Done column found")
|
||||
return
|
||||
|
||||
# Calculate cutoff date
|
||||
cutoff = (datetime.now() - timedelta(days=DAYS_TO_KEEP)).strftime('%Y-%m-%d')
|
||||
|
||||
# Separate old and recent tasks
|
||||
old_tasks = []
|
||||
recent_tasks = []
|
||||
|
||||
for task in done_col['tasks']:
|
||||
completed = task.get('completed', task.get('created', ''))
|
||||
if completed and completed < cutoff:
|
||||
old_tasks.append(task)
|
||||
else:
|
||||
recent_tasks.append(task)
|
||||
|
||||
if not old_tasks:
|
||||
print(f"No tasks older than {DAYS_TO_KEEP} days to archive")
|
||||
return
|
||||
|
||||
# Create archive directory
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Group old tasks by month
|
||||
by_month = {}
|
||||
for task in old_tasks:
|
||||
completed = task.get('completed', task.get('created', ''))[:7] # YYYY-MM
|
||||
if completed not in by_month:
|
||||
by_month[completed] = []
|
||||
by_month[completed].append(task)
|
||||
|
||||
# Write to monthly archive files
|
||||
for month, tasks in by_month.items():
|
||||
archive_file = ARCHIVE_DIR / f"tasks-{month}.json"
|
||||
|
||||
# Load existing archive
|
||||
if archive_file.exists():
|
||||
with open(archive_file, 'r') as f:
|
||||
archive = json.load(f)
|
||||
else:
|
||||
archive = {"month": month, "tasks": []}
|
||||
|
||||
# Add new tasks (avoid duplicates by ID)
|
||||
existing_ids = {t['id'] for t in archive['tasks']}
|
||||
for task in tasks:
|
||||
if task['id'] not in existing_ids:
|
||||
archive['tasks'].append(task)
|
||||
|
||||
# Save archive
|
||||
with open(archive_file, 'w') as f:
|
||||
json.dump(archive, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Archived {len(tasks)} tasks to {archive_file.name}")
|
||||
|
||||
# Update tasks.json with only recent Done tasks
|
||||
done_col['tasks'] = recent_tasks
|
||||
data['lastUpdated'] = datetime.now().isoformat()
|
||||
|
||||
with open(TASKS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Kept {len(recent_tasks)} recent Done tasks, archived {len(old_tasks)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
archive_old_tasks()
|
||||
448
dashboard/common.css
Normal file
448
dashboard/common.css
Normal file
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* Echo Design System
|
||||
* Modern, minimalist, unified UI
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS Variables - Design Tokens
|
||||
============================================ */
|
||||
:root {
|
||||
/* Colors - Dark theme (high contrast) */
|
||||
--bg-base: #13131a;
|
||||
--bg-surface: rgba(255, 255, 255, 0.12);
|
||||
--bg-surface-hover: rgba(255, 255, 255, 0.16);
|
||||
--bg-surface-active: rgba(255, 255, 255, 0.20);
|
||||
--bg-elevated: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #f5f5f5;
|
||||
--text-muted: #e5e5e5;
|
||||
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-subtle: rgba(59, 130, 246, 0.2);
|
||||
|
||||
--border: rgba(255, 255, 255, 0.3);
|
||||
--border-focus: rgba(59, 130, 246, 0.7);
|
||||
|
||||
/* Header specific */
|
||||
--header-bg: rgba(19, 19, 26, 0.95);
|
||||
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
|
||||
/* Spacing (8px grid) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-base: #f8f9fa;
|
||||
--bg-surface: rgba(0, 0, 0, 0.04);
|
||||
--bg-surface-hover: rgba(0, 0, 0, 0.08);
|
||||
--bg-surface-active: rgba(0, 0, 0, 0.12);
|
||||
--bg-elevated: rgba(0, 0, 0, 0.06);
|
||||
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #444444;
|
||||
--text-muted: #666666;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.12);
|
||||
--border-focus: rgba(59, 130, 246, 0.5);
|
||||
|
||||
--accent-subtle: rgba(59, 130, 246, 0.12);
|
||||
|
||||
/* Header light */
|
||||
--header-bg: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Reset & Base
|
||||
============================================ */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Header / Navigation
|
||||
============================================ */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Cards
|
||||
============================================ */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Buttons
|
||||
============================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Inputs
|
||||
============================================ */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Select dropdowns - fix for dark mode visibility */
|
||||
select.input {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
select.input option {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tags / Badges
|
||||
============================================ */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 2px 6px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Grid Layouts
|
||||
============================================ */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-auto { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
|
||||
/* ============================================
|
||||
Status Colors
|
||||
============================================ */
|
||||
.status-success { color: var(--success); }
|
||||
.status-warning { color: var(--warning); }
|
||||
.status-error { color: var(--error); }
|
||||
|
||||
/* ============================================
|
||||
Scrollbar
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
@media (max-width: 768px) {
|
||||
/* Larger base font for mobile readability */
|
||||
html {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.grid-2, .grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Larger touch targets */
|
||||
.btn, .input, .tag {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utilities
|
||||
============================================ */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
39
dashboard/constants.py
Normal file
39
dashboard/constants.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Shared path constants + .env loading for the dashboard package.
|
||||
|
||||
All path constants are centralised here so handlers can import them via
|
||||
`from constants import BASE_DIR, ...` (dashboard/ is placed on sys.path by
|
||||
api.py on startup).
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent # echo-core/
|
||||
TOOLS_DIR = BASE_DIR / 'tools'
|
||||
NOTES_DIR = BASE_DIR / 'memory' / 'kb' / 'youtube'
|
||||
KANBAN_DIR = BASE_DIR / 'dashboard'
|
||||
WORKSPACE_DIR = Path('/home/moltbot/workspace')
|
||||
HABITS_FILE = KANBAN_DIR / 'habits.json'
|
||||
|
||||
# Eco (echo-core) constants
|
||||
ECO_SERVICES = ['echo-core', 'echo-whatsapp-bridge', 'echo-taskboard']
|
||||
ECHO_CORE_DIR = BASE_DIR # same as BASE_DIR post-consolidation
|
||||
ECHO_LOG_FILE = ECHO_CORE_DIR / 'logs' / 'echo-core.log'
|
||||
ECHO_SESSIONS_FILE = ECHO_CORE_DIR / 'sessions' / 'active.json'
|
||||
|
||||
# Git + workspace sandbox
|
||||
GIT_WORKSPACE = BASE_DIR # was '/home/moltbot/clawd'
|
||||
ALLOWED_WORKSPACES = [BASE_DIR, WORKSPACE_DIR] # was [clawd, workspace] — clawd dropped
|
||||
VENV_PYTHON = BASE_DIR / '.venv' / 'bin' / 'python3'
|
||||
|
||||
# ── .env loading ───────────────────────────────────────────────────
|
||||
_env_file = KANBAN_DIR / '.env'
|
||||
if _env_file.exists():
|
||||
for line in _env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.romfast.ro')
|
||||
GITEA_ORG = os.environ.get('GITEA_ORG', 'romfast')
|
||||
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '')
|
||||
17
dashboard/echo-taskboard.service
Normal file
17
dashboard/echo-taskboard.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Echo Task Board API (dashboard)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/moltbot/echo-core/dashboard
|
||||
ExecStart=/home/moltbot/echo-core/.venv/bin/python3 /home/moltbot/echo-core/dashboard/api.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PATH=/home/moltbot/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
StandardOutput=append:/home/moltbot/echo-core/logs/echo-taskboard.log
|
||||
StandardError=append:/home/moltbot/echo-core/logs/echo-taskboard.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
4
dashboard/favicon.svg
Normal file
4
dashboard/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="#3b82f6" stroke-width="2.5"/>
|
||||
<circle cx="16" cy="16" r="3" fill="#3b82f6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
1904
dashboard/files.html
Normal file
1904
dashboard/files.html
Normal file
File diff suppressed because it is too large
Load Diff
471
dashboard/grup-sprijin.html
Normal file
471
dashboard/grup-sprijin.html
Normal file
@@ -0,0 +1,471 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Echo · Grup Sprijin</title>
|
||||
<link rel="stylesheet" href="/echo/common.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
.main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.item-card.used {
|
||||
opacity: 0.7;
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.item-type {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.item-type.exercitiu { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
|
||||
.item-type.meditatie { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; }
|
||||
.item-type.intrebare { background: rgba(20, 184, 166, 0.2); color: #14b8a6; }
|
||||
.item-type.reflectie { background: rgba(249, 115, 22, 0.2); color: #f97316; }
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.item-used {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--success);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a2e;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
[data-theme="light"] .modal-content {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--NAV-->
|
||||
|
||||
<main class="main">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Grup Sprijin - Exerciții & Întrebări</h1>
|
||||
<div class="search-bar">
|
||||
<input type="text" class="input" placeholder="Caută..." id="searchInput">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="fise-section" id="fiseSection" style="margin-bottom: var(--space-5); display: none;">
|
||||
<h2 style="font-size: var(--text-lg); margin-bottom: var(--space-3); color: var(--text-primary);">Fișe întâlniri</h2>
|
||||
<div class="fise-list" id="fiseList" style="display: flex; gap: var(--space-2); flex-wrap: wrap;"></div>
|
||||
</div>
|
||||
|
||||
<div class="filters" id="filters">
|
||||
<button class="filter-btn active" data-filter="all">Toate</button>
|
||||
<button class="filter-btn" data-filter="exercitiu">Exerciții</button>
|
||||
<button class="filter-btn" data-filter="meditatie">Meditații</button>
|
||||
<button class="filter-btn" data-filter="intrebare">Întrebări</button>
|
||||
<button class="filter-btn" data-filter="reflectie">Reflecții</button>
|
||||
<button class="filter-btn" data-filter="unused">Nefolosite</button>
|
||||
<button class="filter-btn" data-filter="used">Folosite</button>
|
||||
</div>
|
||||
|
||||
<div class="items-grid" id="itemsGrid">
|
||||
<p>Se încarcă...</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title" id="modalTitle"></h2>
|
||||
<span class="item-type" id="modalType"></span>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="item-tags" id="modalTags"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="markUsedBtn" onclick="toggleUsed()">Marchează folosit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme
|
||||
function toggleTheme() {
|
||||
const body = document.body;
|
||||
const current = body.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
body.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon();
|
||||
}
|
||||
|
||||
function updateThemeIcon() {
|
||||
const theme = document.body.getAttribute('data-theme') || 'dark';
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.body.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Data
|
||||
const API_BASE = window.location.pathname.includes('/echo/') ? '/echo' : '';
|
||||
let items = [];
|
||||
let currentFilter = 'all';
|
||||
let currentItem = null;
|
||||
|
||||
async function loadItems() {
|
||||
try {
|
||||
const response = await fetch('grup-sprijin/index.json?t=' + Date.now());
|
||||
if (!response.ok) throw new Error('Nu am găsit fișierul');
|
||||
items = await response.json();
|
||||
render();
|
||||
} catch (e) {
|
||||
console.error('Error loading items:', e);
|
||||
document.getElementById('itemsGrid').innerHTML = `
|
||||
<div class="error-msg">
|
||||
Eroare la încărcare: ${e.message}<br>
|
||||
<small>Verifică dacă fișierul grup-sprijin/index.json există</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
let filtered = items.filter(item => {
|
||||
if (search && !item.title.toLowerCase().includes(search) &&
|
||||
!item.content.toLowerCase().includes(search) &&
|
||||
!item.tags.some(t => t.toLowerCase().includes(search))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentFilter === 'all') return true;
|
||||
if (currentFilter === 'used') return item.used;
|
||||
if (currentFilter === 'unused') return !item.used;
|
||||
return item.type === currentFilter;
|
||||
});
|
||||
|
||||
const total = items.length;
|
||||
const used = items.filter(i => i.used).length;
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<span>Total: ${total}</span>
|
||||
<span>Folosite: ${used}</span>
|
||||
<span>Nefolosite: ${total - used}</span>
|
||||
`;
|
||||
|
||||
const grid = document.getElementById('itemsGrid');
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = '<p style="color: var(--text-muted);">Niciun rezultat</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = filtered.map(item => `
|
||||
<div class="item-card ${item.used ? 'used' : ''}" onclick="openModal('${item.id}')">
|
||||
<div class="item-header">
|
||||
<span class="item-title">${item.title}</span>
|
||||
<span class="item-type ${item.type}">${item.type}</span>
|
||||
</div>
|
||||
<div class="item-tags">
|
||||
${item.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
${item.used ? `<div class="item-used">✓ Folosit: ${item.used}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
currentItem = items.find(i => i.id === id);
|
||||
if (!currentItem) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = currentItem.title;
|
||||
document.getElementById('modalType').textContent = currentItem.type;
|
||||
document.getElementById('modalType').className = `item-type ${currentItem.type}`;
|
||||
document.getElementById('modalBody').textContent = currentItem.content;
|
||||
document.getElementById('modalTags').innerHTML = currentItem.tags.map(t => `<span class="tag">${t}</span>`).join('');
|
||||
document.getElementById('markUsedBtn').textContent = currentItem.used ? 'Marchează nefolosit' : 'Marchează folosit';
|
||||
|
||||
document.getElementById('modal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').classList.remove('open');
|
||||
currentItem = null;
|
||||
}
|
||||
|
||||
async function toggleUsed() {
|
||||
if (!currentItem) return;
|
||||
|
||||
const idx = items.findIndex(i => i.id === currentItem.id);
|
||||
if (idx === -1) return;
|
||||
|
||||
if (items[idx].used) {
|
||||
items[idx].used = null;
|
||||
} else {
|
||||
items[idx].used = new Date().toLocaleDateString('ro-RO');
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: 'grup-sprijin/index.json',
|
||||
content: JSON.stringify(items, null, 2)
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error saving:', e);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
render();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentFilter = btn.dataset.filter;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', render);
|
||||
|
||||
document.getElementById('modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'modal') closeModal();
|
||||
});
|
||||
|
||||
// Load fise
|
||||
async function loadFise() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/files?path=kanban/grup-sprijin&action=list`);
|
||||
const data = await response.json();
|
||||
if (data.items) {
|
||||
const fise = data.items.filter(f => f.name.startsWith('fisa-') && f.name.endsWith('.md'));
|
||||
if (fise.length > 0) {
|
||||
document.getElementById('fiseSection').style.display = 'block';
|
||||
document.getElementById('fiseList').innerHTML = fise.map(f => `
|
||||
<a href="/echo/files.html#kanban/grup-sprijin/${f.name}" class="filter-btn" style="text-decoration: none;">
|
||||
${f.name.replace('fisa-', '').replace('.md', '')}
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('No fise yet');
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadItems();
|
||||
loadFise();
|
||||
lucide.createIcons();
|
||||
updateThemeIcon();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3460
dashboard/habits.html
Normal file
3460
dashboard/habits.html
Normal file
File diff suppressed because it is too large
Load Diff
131
dashboard/habits.json
Normal file
131
dashboard/habits.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"lastUpdated": "2026-05-27T15:16:49.070154",
|
||||
"habits": [
|
||||
{
|
||||
"id": "95c15eef-3a14-4985-a61e-0b64b72851b0",
|
||||
"name": "Bazin \u0219i Saun\u0103",
|
||||
"category": "health",
|
||||
"color": "#EF4444",
|
||||
"icon": "target",
|
||||
"priority": 50,
|
||||
"notes": "",
|
||||
"reminderTime": "19:00",
|
||||
"frequency": {
|
||||
"type": "x_per_week",
|
||||
"count": 5
|
||||
},
|
||||
"streak": {
|
||||
"current": 1,
|
||||
"best": 6,
|
||||
"lastCheckIn": "2026-05-27"
|
||||
},
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{
|
||||
"date": "2026-02-11",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-13",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-14",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-15",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-16",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-17",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-18",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-23",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-31",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-27",
|
||||
"type": "check"
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-02-11T00:54:03.447063",
|
||||
"updatedAt": "2026-05-27T15:16:49.070154",
|
||||
"lastLivesAward": "2026-02-23"
|
||||
},
|
||||
{
|
||||
"id": "ceddaa7e-caf9-4038-94bb-da486c586bf8",
|
||||
"name": "Fotocitire",
|
||||
"category": "growth",
|
||||
"color": "#10B981",
|
||||
"icon": "camera",
|
||||
"priority": 30,
|
||||
"notes": "",
|
||||
"reminderTime": "",
|
||||
"frequency": {
|
||||
"type": "x_per_week",
|
||||
"count": 3
|
||||
},
|
||||
"streak": {
|
||||
"current": 1,
|
||||
"best": 6,
|
||||
"lastCheckIn": "2026-04-29"
|
||||
},
|
||||
"lives": 4,
|
||||
"completions": [
|
||||
{
|
||||
"date": "2026-02-11",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-13",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-14",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-15",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-16",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-17",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-18",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-23",
|
||||
"type": "check"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-29",
|
||||
"type": "check"
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-02-11T01:58:44.779904",
|
||||
"updatedAt": "2026-04-29T05:30:59.129949",
|
||||
"lastLivesAward": "2026-02-23"
|
||||
}
|
||||
]
|
||||
}
|
||||
387
dashboard/habits_helpers.py
Normal file
387
dashboard/habits_helpers.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Habit Tracker Helper Functions
|
||||
|
||||
This module provides core helper functions for calculating streaks,
|
||||
checking relevance, and computing stats for habits.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
def calculate_streak(habit: Dict[str, Any]) -> int:
|
||||
"""
|
||||
Calculate the current streak for a habit based on its frequency type.
|
||||
Skips maintain the streak (don't break it) but don't count toward the total.
|
||||
|
||||
Args:
|
||||
habit: Dict containing habit data with frequency, completions, etc.
|
||||
|
||||
Returns:
|
||||
int: Current streak count (days, weeks, or months depending on frequency)
|
||||
"""
|
||||
frequency_type = habit.get("frequency", {}).get("type", "daily")
|
||||
completions = habit.get("completions", [])
|
||||
|
||||
if not completions:
|
||||
return 0
|
||||
|
||||
# Sort completions by date (newest first)
|
||||
sorted_completions = sorted(
|
||||
[c for c in completions if c.get("date")],
|
||||
key=lambda x: x["date"],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if not sorted_completions:
|
||||
return 0
|
||||
|
||||
if frequency_type == "daily":
|
||||
return _calculate_daily_streak(sorted_completions)
|
||||
elif frequency_type == "specific_days":
|
||||
return _calculate_specific_days_streak(habit, sorted_completions)
|
||||
elif frequency_type == "x_per_week":
|
||||
return _calculate_x_per_week_streak(habit, sorted_completions)
|
||||
elif frequency_type == "weekly":
|
||||
return _calculate_weekly_streak(sorted_completions)
|
||||
elif frequency_type == "monthly":
|
||||
return _calculate_monthly_streak(sorted_completions)
|
||||
elif frequency_type == "custom":
|
||||
return _calculate_custom_streak(habit, sorted_completions)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_daily_streak(completions: List[Dict[str, Any]]) -> int:
|
||||
"""
|
||||
Calculate streak for daily habits (consecutive days).
|
||||
Skips maintain the streak (don't break it) but don't count toward the total.
|
||||
"""
|
||||
streak = 0
|
||||
today = datetime.now().date()
|
||||
expected_date = today
|
||||
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
completion_type = completion.get("type", "check")
|
||||
|
||||
if completion_date == expected_date:
|
||||
# Only count 'check' completions toward streak total
|
||||
# 'skip' completions maintain the streak but don't extend it
|
||||
if completion_type == "check":
|
||||
streak += 1
|
||||
expected_date = completion_date - timedelta(days=1)
|
||||
elif completion_date < expected_date:
|
||||
# Gap found, streak breaks
|
||||
break
|
||||
|
||||
return streak
|
||||
|
||||
|
||||
def _calculate_specific_days_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate streak for specific days habits (only count relevant days)."""
|
||||
relevant_days = set(habit.get("frequency", {}).get("days", []))
|
||||
if not relevant_days:
|
||||
return 0
|
||||
|
||||
streak = 0
|
||||
today = datetime.now().date()
|
||||
current_date = today
|
||||
|
||||
# Find the most recent relevant day
|
||||
while current_date.weekday() not in relevant_days:
|
||||
current_date -= timedelta(days=1)
|
||||
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
|
||||
if completion_date == current_date:
|
||||
streak += 1
|
||||
# Move to previous relevant day
|
||||
current_date -= timedelta(days=1)
|
||||
while current_date.weekday() not in relevant_days:
|
||||
current_date -= timedelta(days=1)
|
||||
elif completion_date < current_date:
|
||||
# Check if we missed a relevant day
|
||||
temp_date = current_date
|
||||
found_gap = False
|
||||
while temp_date > completion_date:
|
||||
if temp_date.weekday() in relevant_days:
|
||||
found_gap = True
|
||||
break
|
||||
temp_date -= timedelta(days=1)
|
||||
if found_gap:
|
||||
break
|
||||
|
||||
return streak
|
||||
|
||||
|
||||
def _calculate_x_per_week_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate streak for x_per_week habits (consecutive days with check-ins).
|
||||
|
||||
For x_per_week habits, streak counts consecutive DAYS with check-ins,
|
||||
not consecutive weeks meeting the target. The weekly target (e.g., 4/week)
|
||||
is a goal, but streak measures the chain of check-in days.
|
||||
"""
|
||||
# Use the same logic as daily habits - count consecutive check-in days
|
||||
return _calculate_daily_streak(completions)
|
||||
|
||||
|
||||
def _calculate_weekly_streak(completions: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate streak for weekly habits (consecutive days with check-ins).
|
||||
|
||||
For weekly habits, streak counts consecutive DAYS with check-ins,
|
||||
just like daily habits. The weekly frequency just means you should
|
||||
check in at least once per week.
|
||||
"""
|
||||
return _calculate_daily_streak(completions)
|
||||
|
||||
|
||||
def _calculate_monthly_streak(completions: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate streak for monthly habits (consecutive days with check-ins).
|
||||
|
||||
For monthly habits, streak counts consecutive DAYS with check-ins,
|
||||
just like daily habits. The monthly frequency just means you should
|
||||
check in at least once per month.
|
||||
"""
|
||||
return _calculate_daily_streak(completions)
|
||||
|
||||
|
||||
def _calculate_custom_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate streak for custom interval habits (every X days)."""
|
||||
interval = habit.get("frequency", {}).get("interval", 1)
|
||||
if interval <= 0:
|
||||
return 0
|
||||
|
||||
streak = 0
|
||||
expected_date = datetime.now().date()
|
||||
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
|
||||
# Allow completion within the interval window
|
||||
days_diff = (expected_date - completion_date).days
|
||||
if 0 <= days_diff <= interval - 1:
|
||||
streak += 1
|
||||
expected_date = completion_date - timedelta(days=interval)
|
||||
else:
|
||||
break
|
||||
|
||||
return streak
|
||||
|
||||
|
||||
def should_check_today(habit: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if a habit is relevant for today based on its frequency type.
|
||||
|
||||
Args:
|
||||
habit: Dict containing habit data with frequency settings
|
||||
|
||||
Returns:
|
||||
bool: True if the habit should be checked today
|
||||
"""
|
||||
frequency_type = habit.get("frequency", {}).get("type", "daily")
|
||||
today = datetime.now().date()
|
||||
weekday = today.weekday() # 0=Monday, 6=Sunday
|
||||
|
||||
if frequency_type == "daily":
|
||||
return True
|
||||
|
||||
elif frequency_type == "specific_days":
|
||||
relevant_days = set(habit.get("frequency", {}).get("days", []))
|
||||
return weekday in relevant_days
|
||||
|
||||
elif frequency_type == "x_per_week":
|
||||
# Always relevant for x_per_week (can check any day)
|
||||
return True
|
||||
|
||||
elif frequency_type == "weekly":
|
||||
# Always relevant (can check any day of the week)
|
||||
return True
|
||||
|
||||
elif frequency_type == "monthly":
|
||||
# Always relevant (can check any day of the month)
|
||||
return True
|
||||
|
||||
elif frequency_type == "custom":
|
||||
# Check if enough days have passed since last completion
|
||||
completions = habit.get("completions", [])
|
||||
if not completions:
|
||||
return True
|
||||
|
||||
interval = habit.get("frequency", {}).get("interval", 1)
|
||||
last_completion = max(completions, key=lambda x: x.get("date", ""))
|
||||
last_date = datetime.fromisoformat(last_completion["date"]).date()
|
||||
days_since = (today - last_date).days
|
||||
|
||||
return days_since >= interval
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_completion_rate(habit: Dict[str, Any], days: int = 30) -> float:
|
||||
"""
|
||||
Calculate the completion rate as a percentage over the last N days.
|
||||
|
||||
Args:
|
||||
habit: Dict containing habit data
|
||||
days: Number of days to look back (default 30)
|
||||
|
||||
Returns:
|
||||
float: Completion rate as percentage (0-100)
|
||||
"""
|
||||
frequency_type = habit.get("frequency", {}).get("type", "daily")
|
||||
completions = habit.get("completions", [])
|
||||
|
||||
today = datetime.now().date()
|
||||
start_date = today - timedelta(days=days - 1)
|
||||
|
||||
# Count relevant days and checked days
|
||||
relevant_days = 0
|
||||
checked_dates = set()
|
||||
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
if start_date <= completion_date <= today:
|
||||
checked_dates.add(completion_date)
|
||||
|
||||
# Calculate relevant days based on frequency type
|
||||
if frequency_type == "daily":
|
||||
relevant_days = days
|
||||
|
||||
elif frequency_type == "specific_days":
|
||||
relevant_day_set = set(habit.get("frequency", {}).get("days", []))
|
||||
current = start_date
|
||||
while current <= today:
|
||||
if current.weekday() in relevant_day_set:
|
||||
relevant_days += 1
|
||||
current += timedelta(days=1)
|
||||
|
||||
elif frequency_type == "x_per_week":
|
||||
target_per_week = habit.get("frequency", {}).get("count", 1)
|
||||
num_weeks = days // 7
|
||||
relevant_days = num_weeks * target_per_week
|
||||
|
||||
elif frequency_type == "weekly":
|
||||
num_weeks = days // 7
|
||||
relevant_days = num_weeks
|
||||
|
||||
elif frequency_type == "monthly":
|
||||
num_months = days // 30
|
||||
relevant_days = num_months
|
||||
|
||||
elif frequency_type == "custom":
|
||||
interval = habit.get("frequency", {}).get("interval", 1)
|
||||
relevant_days = days // interval if interval > 0 else 0
|
||||
|
||||
if relevant_days == 0:
|
||||
return 0.0
|
||||
|
||||
checked_days = len(checked_dates)
|
||||
return (checked_days / relevant_days) * 100
|
||||
|
||||
|
||||
def get_weekly_summary(habit: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
Get a summary of the current week showing status for each day.
|
||||
|
||||
Args:
|
||||
habit: Dict containing habit data
|
||||
|
||||
Returns:
|
||||
Dict mapping day names to status: "checked", "skipped", "missed", or "upcoming"
|
||||
"""
|
||||
frequency_type = habit.get("frequency", {}).get("type", "daily")
|
||||
completions = habit.get("completions", [])
|
||||
|
||||
today = datetime.now().date()
|
||||
|
||||
# Start of current week (Monday)
|
||||
start_of_week = today - timedelta(days=today.weekday())
|
||||
|
||||
# Create completion map
|
||||
completion_map = {}
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
if completion_date >= start_of_week:
|
||||
completion_type = completion.get("type", "check")
|
||||
completion_map[completion_date] = completion_type
|
||||
|
||||
# Build summary for each day of the week
|
||||
summary = {}
|
||||
day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
for i, day_name in enumerate(day_names):
|
||||
day_date = start_of_week + timedelta(days=i)
|
||||
|
||||
if day_date > today:
|
||||
summary[day_name] = "upcoming"
|
||||
elif day_date in completion_map:
|
||||
if completion_map[day_date] == "skip":
|
||||
summary[day_name] = "skipped"
|
||||
else:
|
||||
summary[day_name] = "checked"
|
||||
else:
|
||||
# Check if this day was relevant
|
||||
if frequency_type == "specific_days":
|
||||
relevant_days = set(habit.get("frequency", {}).get("days", []))
|
||||
if day_date.weekday() not in relevant_days:
|
||||
summary[day_name] = "not_relevant"
|
||||
else:
|
||||
summary[day_name] = "missed"
|
||||
else:
|
||||
summary[day_name] = "missed"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def check_and_award_weekly_lives(habit: Dict[str, Any]) -> tuple[int, bool]:
|
||||
"""
|
||||
Check if habit qualifies for weekly lives recovery and award +1 life if eligible.
|
||||
|
||||
Awards +1 life if:
|
||||
- At least one check-in in the previous week (Monday-Sunday)
|
||||
- Not already awarded this week
|
||||
|
||||
Args:
|
||||
habit: Dict containing habit data with completions and lastLivesAward
|
||||
|
||||
Returns:
|
||||
tuple[int, bool]: (new_lives_count, was_awarded)
|
||||
"""
|
||||
completions = habit.get("completions", [])
|
||||
current_lives = habit.get("lives", 3)
|
||||
|
||||
today = datetime.now().date()
|
||||
|
||||
# Calculate current week start (Monday 00:00)
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
|
||||
# Check if already awarded this week
|
||||
last_lives_award = habit.get("lastLivesAward")
|
||||
if last_lives_award:
|
||||
last_award_date = datetime.fromisoformat(last_lives_award).date()
|
||||
if last_award_date >= current_week_start:
|
||||
# Already awarded this week
|
||||
return (current_lives, False)
|
||||
|
||||
# Calculate previous week boundaries
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
previous_week_end = current_week_start - timedelta(days=1)
|
||||
|
||||
# Count check-ins in previous week
|
||||
checkins_in_previous_week = 0
|
||||
for completion in completions:
|
||||
completion_date = datetime.fromisoformat(completion["date"]).date()
|
||||
completion_type = completion.get("type", "check")
|
||||
|
||||
if previous_week_start <= completion_date <= previous_week_end:
|
||||
if completion_type == "check":
|
||||
checkins_in_previous_week += 1
|
||||
|
||||
# Award life if at least 1 check-in found
|
||||
if checkins_in_previous_week >= 1:
|
||||
new_lives = current_lives + 1
|
||||
return (new_lives, True)
|
||||
|
||||
return (current_lives, False)
|
||||
7
dashboard/handlers/__init__.py
Normal file
7
dashboard/handlers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Handler mixin modules for the Echo Task Board API.
|
||||
|
||||
Each module exposes a mixin class whose methods plug into
|
||||
`TaskBoardHandler` (defined in dashboard/api.py). This keeps
|
||||
api.py as a thin HTTP router while each concern lives in its
|
||||
own small module.
|
||||
"""
|
||||
54
dashboard/handlers/_validators.py
Normal file
54
dashboard/handlers/_validators.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Shared validation helpers for dashboard handlers."""
|
||||
import json
|
||||
import re
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
_SLUG_RE = re.compile(r'^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$')
|
||||
|
||||
|
||||
def validate_slug(slug: str) -> str | None:
|
||||
"""Returns error message or None if valid."""
|
||||
if not slug:
|
||||
return "slug required"
|
||||
if not _SLUG_RE.match(slug):
|
||||
return "slug must be 3-40 chars, lowercase alphanumeric + hyphens/underscores"
|
||||
return None
|
||||
|
||||
|
||||
def validate_description(desc: str) -> str | None:
|
||||
"""Returns error message or None if valid. Min 10 chars, max 500."""
|
||||
if not desc or len(desc.strip()) < 10:
|
||||
return "description must be at least 10 characters"
|
||||
if len(desc) > 500:
|
||||
return "description must be at most 500 characters"
|
||||
return None
|
||||
|
||||
|
||||
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict | None:
|
||||
"""Parse JSON body from request. Returns None on failure (sends 400)."""
|
||||
try:
|
||||
length = int(handler.headers.get('Content-Length', '0') or '0')
|
||||
except (TypeError, ValueError):
|
||||
length = 0
|
||||
|
||||
def _send_error(msg: str) -> None:
|
||||
sender = getattr(handler, 'send_json', None)
|
||||
if callable(sender):
|
||||
sender({'error': msg}, 400)
|
||||
return
|
||||
body = json.dumps({'error': msg}).encode()
|
||||
handler.send_response(400)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
if length <= 0:
|
||||
_send_error('empty body')
|
||||
return None
|
||||
try:
|
||||
raw = handler.rfile.read(length)
|
||||
return json.loads(raw.decode('utf-8'))
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
_send_error('invalid JSON body')
|
||||
return None
|
||||
174
dashboard/handlers/auth.py
Normal file
174
dashboard/handlers/auth.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Cookie-based authentication for the unified dashboard.
|
||||
|
||||
This mixin provides:
|
||||
|
||||
- POST /api/auth/login — exchanges a token (form body) for a cookie.
|
||||
- POST /api/auth/logout — clears the cookie.
|
||||
- _check_dashboard_cookie — used by the global POST middleware (and the
|
||||
SSE GET endpoint) to gate access.
|
||||
|
||||
`DASHBOARD_TOKEN` is read once from `dashboard/.env` (loaded into
|
||||
`os.environ` by `dashboard/constants.py` at import time). When the token is
|
||||
not configured we generate a random one at startup, stash it in-process,
|
||||
and warn loudly to stderr — this means the dashboard is reachable from
|
||||
localhost only with a freshly-printed token (printed once at boot).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 30 days
|
||||
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
||||
_COOKIE_NAME = "dashboard"
|
||||
_COOKIE_PATH = "/echo/"
|
||||
|
||||
# Module-level cache for the resolved token. Set lazily on first call so
|
||||
# importing this module doesn't have a side effect at process boot.
|
||||
_DASHBOARD_TOKEN: str | None = None
|
||||
|
||||
|
||||
def _get_dashboard_token() -> str:
|
||||
"""Return the dashboard token (cached). Generates a random one if absent.
|
||||
|
||||
`dashboard/constants.py` already loads `dashboard/.env` into os.environ at
|
||||
import time, so by the time this is called the value (if present) is in
|
||||
`os.environ['DASHBOARD_TOKEN']`. If missing, we mint a 32-byte URL-safe
|
||||
token and warn — operators must read it from the log to log in.
|
||||
"""
|
||||
global _DASHBOARD_TOKEN
|
||||
if _DASHBOARD_TOKEN is not None:
|
||||
return _DASHBOARD_TOKEN
|
||||
token = os.environ.get("DASHBOARD_TOKEN", "").strip()
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
msg = (
|
||||
"[auth] DASHBOARD_TOKEN not set in dashboard/.env — generated a "
|
||||
f"random token for this process: {token}\n"
|
||||
" Add `DASHBOARD_TOKEN=<value>` to dashboard/.env to make it "
|
||||
"stable across restarts.\n"
|
||||
)
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
log.warning("DASHBOARD_TOKEN not configured — using ephemeral token")
|
||||
_DASHBOARD_TOKEN = token
|
||||
return token
|
||||
|
||||
|
||||
def _parse_cookie_header(raw: str) -> dict[str, str]:
|
||||
"""Tiny RFC 6265 cookie-pair parser. Last-write-wins on duplicates."""
|
||||
out: dict[str, str] = {}
|
||||
if not raw:
|
||||
return out
|
||||
for chunk in raw.split(";"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk or "=" not in chunk:
|
||||
continue
|
||||
k, v = chunk.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
class AuthHandlers:
|
||||
"""Mixin: /api/auth/login, /api/auth/logout, plus _check_dashboard_cookie."""
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
def _check_dashboard_cookie(self) -> bool:
|
||||
"""Return True if the request carries a valid `dashboard` cookie."""
|
||||
raw = self.headers.get("Cookie", "") or ""
|
||||
cookies = _parse_cookie_header(raw)
|
||||
provided = cookies.get(_COOKIE_NAME, "")
|
||||
if not provided:
|
||||
return False
|
||||
expected = _get_dashboard_token()
|
||||
# Constant-time compare — token guess attacks aren't realistic here
|
||||
# (cookie path is /echo/, HttpOnly), but cheap defense in depth.
|
||||
return secrets.compare_digest(provided, expected)
|
||||
|
||||
def _read_form_body(self) -> dict[str, str]:
|
||||
"""Parse `application/x-www-form-urlencoded` POST body."""
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except (TypeError, ValueError):
|
||||
length = 0
|
||||
if length <= 0:
|
||||
return {}
|
||||
try:
|
||||
raw = self.rfile.read(length).decode("utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return {}
|
||||
parsed = parse_qs(raw, keep_blank_values=True)
|
||||
# Flatten — single-value form fields only
|
||||
return {k: v[0] for k, v in parsed.items() if v}
|
||||
|
||||
# ── POST /api/auth/login ───────────────────────────────────────────
|
||||
def handle_login(self):
|
||||
"""Validate token from form body; on success, set cookie + 302 to workspace.
|
||||
|
||||
On failure, return 401 JSON. The cookie is set with HttpOnly +
|
||||
SameSite=Strict; Path=/echo/ so it scopes to the dashboard reverse
|
||||
proxy mount.
|
||||
"""
|
||||
# Accept JSON body too (login.html might POST JSON in Lane B2)
|
||||
ctype = (self.headers.get("Content-Type", "") or "").lower()
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
raw = self.rfile.read(length).decode("utf-8") if length > 0 else ""
|
||||
form = json.loads(raw) if raw else {}
|
||||
if not isinstance(form, dict):
|
||||
form = {}
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
form = {}
|
||||
else:
|
||||
form = self._read_form_body()
|
||||
|
||||
provided = (form.get("token") or "").strip()
|
||||
expected = _get_dashboard_token()
|
||||
if not provided or not secrets.compare_digest(provided, expected):
|
||||
body = json.dumps({"error": "Invalid token"}).encode("utf-8")
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
|
||||
cookie = (
|
||||
f"{_COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; "
|
||||
f"Path={_COOKIE_PATH}; Max-Age={_COOKIE_MAX_AGE}"
|
||||
)
|
||||
self.send_response(302)
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Location", "/echo/workspace.html")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
# ── POST /api/auth/logout ──────────────────────────────────────────
|
||||
def handle_logout(self):
|
||||
"""Clear the dashboard cookie. Returns 200 JSON `{"ok": true}`."""
|
||||
cookie = (
|
||||
f"{_COOKIE_NAME}=; HttpOnly; SameSite=Strict; "
|
||||
f"Path={_COOKIE_PATH}; Max-Age=0"
|
||||
)
|
||||
body = json.dumps({"ok": True}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
95
dashboard/handlers/cron.py
Normal file
95
dashboard/handlers/cron.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""/api/cron — reads echo-core/cron/jobs.json (flat schema)."""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
def _parse_cron_time(expr):
|
||||
"""Extract a display-time string from a cron expression.
|
||||
|
||||
Echo-core cron strings are already Bucharest local time (Lane B
|
||||
scheduler sets tz=Europe/Bucharest), so NO UTC→local conversion.
|
||||
"""
|
||||
parts = expr.split()
|
||||
if len(parts) < 2:
|
||||
return expr[:15]
|
||||
minute, hour = parts[0], parts[1]
|
||||
if minute.isdigit() and (hour.isdigit() or '-' in hour):
|
||||
if '-' in hour:
|
||||
hour = hour.split('-')[0]
|
||||
try:
|
||||
return f"{int(hour):02d}:{int(minute):02d}"
|
||||
except ValueError:
|
||||
return expr[:15]
|
||||
return expr[:15]
|
||||
|
||||
|
||||
def _iso_to_epoch_ms(iso_str):
|
||||
"""Convert an ISO 8601 datetime string to epoch ms. Returns 0 on failure."""
|
||||
if not iso_str:
|
||||
return 0
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
|
||||
return int(dt.timestamp() * 1000)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
class CronHandlers:
|
||||
"""Mixin for /api/cron."""
|
||||
|
||||
def handle_cron_status(self):
|
||||
"""Get enabled cron jobs from echo-core/cron/jobs.json (flat schema).
|
||||
|
||||
Output shape preserved for the frontend: id, name, time, schedule,
|
||||
ranToday, lastStatus, lastRunAtMs, nextRunAtMs.
|
||||
"""
|
||||
try:
|
||||
jobs_file = constants.BASE_DIR / 'cron' / 'jobs.json'
|
||||
if not jobs_file.exists():
|
||||
self.send_json({'jobs': [], 'error': 'No jobs file found'})
|
||||
return
|
||||
|
||||
all_jobs = json.loads(jobs_file.read_text())
|
||||
if not isinstance(all_jobs, list):
|
||||
self.send_json({'jobs': [], 'error': 'Unexpected jobs.json shape'})
|
||||
return
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_ms = today_start.timestamp() * 1000
|
||||
|
||||
jobs = []
|
||||
for job in all_jobs:
|
||||
if not job.get('enabled', False):
|
||||
continue
|
||||
|
||||
name = job.get('name', '')
|
||||
expr = job.get('cron', '')
|
||||
last_run_iso = job.get('last_run')
|
||||
next_run_iso = job.get('next_run')
|
||||
last_status = job.get('last_status', 'unknown')
|
||||
|
||||
last_run_ms = _iso_to_epoch_ms(last_run_iso)
|
||||
next_run_ms = _iso_to_epoch_ms(next_run_iso) or None
|
||||
ran_today = last_run_ms >= today_start_ms
|
||||
|
||||
jobs.append({
|
||||
'id': name, # echo-core has no separate id; use name
|
||||
'name': name,
|
||||
'time': _parse_cron_time(expr),
|
||||
'schedule': expr,
|
||||
'ranToday': ran_today,
|
||||
'lastStatus': last_status if ran_today else None,
|
||||
'lastRunAtMs': last_run_ms,
|
||||
'nextRunAtMs': next_run_ms,
|
||||
})
|
||||
|
||||
jobs.sort(key=lambda j: j['time'])
|
||||
self.send_json({
|
||||
'jobs': jobs,
|
||||
'total': len(jobs),
|
||||
'ranToday': sum(1 for j in jobs if j['ranToday']),
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
378
dashboard/handlers/eco.py
Normal file
378
dashboard/handlers/eco.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Echo Core (eco) service + session + doctor endpoints."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
class EcoHandlers:
|
||||
"""Mixin for /api/eco/* endpoints."""
|
||||
|
||||
# ── /api/eco/status ─────────────────────────────────────────
|
||||
def handle_eco_status(self):
|
||||
"""Get status of echo-core services + active sessions."""
|
||||
try:
|
||||
services = []
|
||||
for svc in constants.ECO_SERVICES:
|
||||
info = {'name': svc, 'active': False, 'pid': None, 'uptime': None, 'memory': None}
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'is-active', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
info['active'] = result.stdout.strip() == 'active'
|
||||
|
||||
if info['active']:
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'show', '-p', 'MainPID', '--value', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
pid = result.stdout.strip()
|
||||
if pid and pid != '0':
|
||||
info['pid'] = int(pid)
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['systemctl', '--user', 'show', '-p', 'ActiveEnterTimestamp', '--value', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ts = r.stdout.strip()
|
||||
if ts:
|
||||
start = datetime.strptime(ts, '%a %Y-%m-%d %H:%M:%S %Z')
|
||||
info['uptime'] = int((datetime.utcnow() - start).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for line in Path(f'/proc/{pid}/status').read_text().splitlines():
|
||||
if line.startswith('VmRSS:'):
|
||||
info['memory'] = line.split(':')[1].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
services.append(info)
|
||||
|
||||
self.send_json({'services': services})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
# ── sessions ────────────────────────────────────────────────
|
||||
def _eco_channel_map(self):
|
||||
"""Build channel_id -> {name, platform, is_group} from config.json."""
|
||||
config_file = constants.ECHO_CORE_DIR / 'config.json'
|
||||
m = {}
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text())
|
||||
for name, ch in cfg.get('channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'discord'}
|
||||
for name, ch in cfg.get('telegram_channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'telegram'}
|
||||
for name, ch in cfg.get('whatsapp_channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'whatsapp', 'is_group': True}
|
||||
for admin_id in cfg.get('bot', {}).get('admins', []):
|
||||
m.setdefault(str(admin_id), {'name': 'TG DM', 'platform': 'telegram'})
|
||||
wa_owner = cfg.get('whatsapp', {}).get('owner', '')
|
||||
if wa_owner:
|
||||
m.setdefault(f'wa-{wa_owner}', {'name': 'WA Owner', 'platform': 'whatsapp'})
|
||||
except Exception:
|
||||
pass
|
||||
return m
|
||||
|
||||
def _eco_enrich_sessions(self):
|
||||
"""Return enriched sessions list sorted by last_message_at desc."""
|
||||
raw = {}
|
||||
if constants.ECHO_SESSIONS_FILE.exists():
|
||||
try:
|
||||
raw = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
cmap = self._eco_channel_map()
|
||||
sessions = []
|
||||
if isinstance(raw, dict):
|
||||
for ch_id, sdata in raw.items():
|
||||
if 'MagicMock' in ch_id:
|
||||
continue
|
||||
entry = dict(sdata) if isinstance(sdata, dict) else {}
|
||||
entry['channel_id'] = ch_id
|
||||
if ch_id in cmap:
|
||||
entry['platform'] = cmap[ch_id]['platform']
|
||||
entry['channel_name'] = cmap[ch_id]['name']
|
||||
entry['is_group'] = cmap[ch_id].get('is_group', False)
|
||||
elif ch_id.startswith('wa-') or '@g.us' in ch_id or '@s.whatsapp.net' in ch_id:
|
||||
entry['platform'] = 'whatsapp'
|
||||
entry['is_group'] = '@g.us' in ch_id
|
||||
entry['channel_name'] = ('WA Grup' if entry['is_group'] else 'WA DM')
|
||||
elif ch_id.isdigit() and len(ch_id) >= 17:
|
||||
entry['platform'] = 'discord'
|
||||
entry['channel_name'] = 'Discord #' + ch_id[-6:]
|
||||
elif ch_id.isdigit():
|
||||
entry['platform'] = 'telegram'
|
||||
entry['channel_name'] = 'TG ' + ch_id
|
||||
else:
|
||||
entry['platform'] = 'unknown'
|
||||
entry['channel_name'] = ch_id[:20]
|
||||
sessions.append(entry)
|
||||
sessions.sort(key=lambda s: s.get('last_message_at', ''), reverse=True)
|
||||
return sessions
|
||||
|
||||
def handle_eco_sessions(self):
|
||||
"""Return enriched sessions list."""
|
||||
try:
|
||||
self.send_json({'sessions': self._eco_enrich_sessions()})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_session_content(self):
|
||||
"""Return conversation messages from a Claude session transcript."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
session_id = params.get('id', [''])[0]
|
||||
if not session_id or '/' in session_id or '..' in session_id:
|
||||
self.send_json({'error': 'Invalid session id'}, 400)
|
||||
return
|
||||
|
||||
transcript = Path.home() / '.claude' / 'projects' / '-home-moltbot-echo-core' / f'{session_id}.jsonl'
|
||||
if not transcript.exists():
|
||||
self.send_json({'messages': [], 'error': 'Transcript not found'})
|
||||
return
|
||||
|
||||
messages = []
|
||||
for line in transcript.read_text().splitlines():
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
t = d.get('type', '')
|
||||
if t == 'user':
|
||||
msg = d.get('message', {})
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
|
||||
if text:
|
||||
messages.append({'role': 'user', 'text': text[:20000]})
|
||||
elif t == 'assistant':
|
||||
msg = d.get('message', {})
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, list):
|
||||
parts = [block['text'] for block in content if block.get('type') == 'text']
|
||||
text = '\n'.join(parts).strip()
|
||||
if text:
|
||||
messages.append({'role': 'assistant', 'text': text[:20000]})
|
||||
elif isinstance(content, str) and content.strip():
|
||||
messages.append({'role': 'assistant', 'text': content[:20000]})
|
||||
|
||||
self.send_json({'messages': messages})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_sessions_clear(self):
|
||||
"""Clear active sessions (all or specific channel)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
channel = data.get('channel', None)
|
||||
|
||||
if not constants.ECHO_SESSIONS_FILE.exists():
|
||||
self.send_json({'success': True, 'message': 'No sessions file'})
|
||||
return
|
||||
|
||||
if channel:
|
||||
sessions = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
if isinstance(sessions, list):
|
||||
sessions = [s for s in sessions if s.get('channel') != channel]
|
||||
elif isinstance(sessions, dict):
|
||||
sessions.pop(channel, None)
|
||||
constants.ECHO_SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
|
||||
self.send_json({'success': True, 'message': f'Cleared session: {channel}'})
|
||||
else:
|
||||
if isinstance(json.loads(constants.ECHO_SESSIONS_FILE.read_text()), list):
|
||||
constants.ECHO_SESSIONS_FILE.write_text('[]')
|
||||
else:
|
||||
constants.ECHO_SESSIONS_FILE.write_text('{}')
|
||||
self.send_json({'success': True, 'message': 'All sessions cleared'})
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
# ── logs + doctor ───────────────────────────────────────────
|
||||
def handle_eco_logs(self):
|
||||
"""Return last N lines from echo-core.log."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
lines = min(int(params.get('lines', ['100'])[0]), 500)
|
||||
|
||||
if not constants.ECHO_LOG_FILE.exists():
|
||||
self.send_json({'lines': ['(log file not found)']})
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['tail', '-n', str(lines), str(constants.ECHO_LOG_FILE)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
self.send_json({'lines': result.stdout.splitlines()})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_doctor(self):
|
||||
"""Run health checks on the echo-core ecosystem."""
|
||||
checks = []
|
||||
|
||||
# 1. Services
|
||||
for svc in constants.ECO_SERVICES:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['systemctl', '--user', 'is-active', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
active = r.stdout.strip() == 'active'
|
||||
checks.append({
|
||||
'name': f'Service: {svc}',
|
||||
'pass': active,
|
||||
'detail': 'active' if active else r.stdout.strip(),
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': f'Service: {svc}', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 2. Disk space
|
||||
try:
|
||||
st = shutil.disk_usage('/')
|
||||
pct_free = (st.free / st.total) * 100
|
||||
checks.append({
|
||||
'name': 'Disk space',
|
||||
'pass': pct_free > 5,
|
||||
'detail': f'{pct_free:.1f}% free ({st.free // (1024**3)} GB)',
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Disk space', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 3. Log file
|
||||
try:
|
||||
if constants.ECHO_LOG_FILE.exists():
|
||||
size_mb = constants.ECHO_LOG_FILE.stat().st_size / (1024 * 1024)
|
||||
checks.append({
|
||||
'name': 'Log file',
|
||||
'pass': size_mb < 100,
|
||||
'detail': f'{size_mb:.1f} MB',
|
||||
})
|
||||
else:
|
||||
checks.append({'name': 'Log file', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Log file', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 4. Sessions file
|
||||
try:
|
||||
if constants.ECHO_SESSIONS_FILE.exists():
|
||||
data = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
count = len(data) if isinstance(data, list) else len(data.keys()) if isinstance(data, dict) else 0
|
||||
checks.append({'name': 'Sessions file', 'pass': True, 'detail': f'{count} active'})
|
||||
else:
|
||||
checks.append({'name': 'Sessions file', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Sessions file', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 5. Config
|
||||
config_file = constants.ECHO_CORE_DIR / 'config.json'
|
||||
try:
|
||||
if config_file.exists():
|
||||
json.loads(config_file.read_text())
|
||||
checks.append({'name': 'Config', 'pass': True, 'detail': 'Valid JSON'})
|
||||
else:
|
||||
checks.append({'name': 'Config', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Config', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 6. WhatsApp bridge log
|
||||
wa_log = constants.ECHO_CORE_DIR / 'logs' / 'whatsapp-bridge.log'
|
||||
try:
|
||||
if wa_log.exists():
|
||||
r = subprocess.run(['tail', '-1', str(wa_log)], capture_output=True, text=True, timeout=5)
|
||||
last = r.stdout.strip()
|
||||
has_error = 'error' in last.lower() or 'fatal' in last.lower()
|
||||
checks.append({
|
||||
'name': 'WhatsApp bridge log',
|
||||
'pass': not has_error,
|
||||
'detail': last[:80] if last else 'Empty',
|
||||
})
|
||||
else:
|
||||
checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 7. Claude CLI
|
||||
try:
|
||||
r = subprocess.run(['which', 'claude'], capture_output=True, text=True, timeout=5)
|
||||
found = r.returncode == 0
|
||||
checks.append({
|
||||
'name': 'Claude CLI',
|
||||
'pass': found,
|
||||
'detail': r.stdout.strip() if found else 'Not in PATH',
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Claude CLI', 'pass': False, 'detail': str(e)})
|
||||
|
||||
self.send_json({'checks': checks})
|
||||
|
||||
# ── service control ─────────────────────────────────────────
|
||||
def handle_eco_restart(self):
|
||||
"""Restart an echo-core service (not the taskboard itself)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
svc = data.get('service', '')
|
||||
|
||||
if svc not in constants.ECO_SERVICES:
|
||||
self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
|
||||
return
|
||||
if svc == 'echo-taskboard':
|
||||
self.send_json({'success': False, 'error': 'Cannot restart taskboard from itself'}, 400)
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'restart', svc],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.send_json({'success': True, 'message': f'{svc} restarted'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_stop(self):
|
||||
"""Stop an echo-core service (not the taskboard itself)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
svc = data.get('service', '')
|
||||
|
||||
if svc not in constants.ECO_SERVICES:
|
||||
self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
|
||||
return
|
||||
if svc == 'echo-taskboard':
|
||||
self.send_json({'success': False, 'error': 'Cannot stop taskboard from itself'}, 400)
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'stop', svc],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.send_json({'success': True, 'message': f'{svc} stopped'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_restart_taskboard(self):
|
||||
"""Restart the taskboard itself. Sends response then exits; systemd restarts."""
|
||||
import threading
|
||||
self.send_json({'success': True, 'message': 'Restarting taskboard in 1s...'})
|
||||
|
||||
def _exit():
|
||||
import time
|
||||
time.sleep(1)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_exit, daemon=True).start()
|
||||
120
dashboard/handlers/files.py
Normal file
120
dashboard/handlers/files.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""File-browser + note-index endpoints (sandbox-enforced)."""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
class FilesHandlers:
|
||||
"""Mixin for /api/files, /api/refresh-index."""
|
||||
|
||||
def _resolve_sandboxed(self, path):
|
||||
"""Resolve `path` against ALLOWED_WORKSPACES. Returns (target, workspace) or (None, None)."""
|
||||
allowed_dirs = constants.ALLOWED_WORKSPACES
|
||||
for base in allowed_dirs:
|
||||
try:
|
||||
candidate = (base / path).resolve()
|
||||
if any(str(candidate).startswith(str(d)) for d in allowed_dirs):
|
||||
return candidate, base
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
def handle_files_get(self):
|
||||
"""List files or get file content."""
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
path = params.get('path', [''])[0]
|
||||
action = params.get('action', ['list'])[0]
|
||||
|
||||
target, workspace = self._resolve_sandboxed(path)
|
||||
if target is None:
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
|
||||
if action != 'list':
|
||||
self.send_json({'error': 'Unknown action'}, 400)
|
||||
return
|
||||
|
||||
if not target.exists():
|
||||
self.send_json({'error': 'Path not found'}, 404)
|
||||
return
|
||||
|
||||
if target.is_file():
|
||||
try:
|
||||
content = target.read_text(encoding='utf-8', errors='replace')
|
||||
self.send_json({
|
||||
'type': 'file',
|
||||
'path': path,
|
||||
'name': target.name,
|
||||
'content': content[:100000],
|
||||
'size': target.stat().st_size,
|
||||
'truncated': target.stat().st_size > 100000,
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
else:
|
||||
items = []
|
||||
try:
|
||||
for item in sorted(target.iterdir()):
|
||||
stat = item.stat()
|
||||
item_path = f"{path}/{item.name}" if path else item.name
|
||||
items.append({
|
||||
'name': item.name,
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': stat.st_size if item.is_file() else None,
|
||||
'mtime': stat.st_mtime,
|
||||
'path': item_path,
|
||||
})
|
||||
self.send_json({'type': 'dir', 'path': path, 'items': items})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_files_post(self):
|
||||
"""Save file content."""
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
path = data.get('path', '')
|
||||
content = data.get('content', '')
|
||||
|
||||
target, workspace = self._resolve_sandboxed(path)
|
||||
if target is None:
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding='utf-8')
|
||||
|
||||
self.send_json({'status': 'saved', 'path': path, 'size': len(content)})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_refresh_index(self):
|
||||
"""Regenerate memory/kb/index.json by running tools/update_notes_index.py."""
|
||||
try:
|
||||
script = constants.TOOLS_DIR / 'update_notes_index.py'
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout
|
||||
total_match = re.search(r'with (\d+) notes', output)
|
||||
total = int(total_match.group(1)) if total_match else 0
|
||||
self.send_json({
|
||||
'success': True,
|
||||
'message': f'Index regenerat cu {total} notițe',
|
||||
'total': total,
|
||||
'output': output,
|
||||
})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': result.stderr or 'Unknown error'}, 500)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'success': False, 'error': 'Timeout'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
279
dashboard/handlers/git.py
Normal file
279
dashboard/handlers/git.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Git status / diff / commit handlers for dashboard + workspace projects."""
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
class GitHandlers:
|
||||
"""Mixin providing git status/diff/commit endpoints."""
|
||||
|
||||
# ── shared helper ────────────────────────────────────────────
|
||||
def _run_git(self, workspace, args, timeout=5):
|
||||
"""Run a git command in workspace. Returns CompletedProcess."""
|
||||
return subprocess.run(
|
||||
['git', *args],
|
||||
cwd=str(workspace),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# ── /api/git (dashboard repo) ───────────────────────────────
|
||||
def handle_git_status(self):
|
||||
"""Get git status for the echo-core repo."""
|
||||
try:
|
||||
workspace = constants.GIT_WORKSPACE
|
||||
|
||||
branch = self._run_git(workspace, ['branch', '--show-current']).stdout.strip()
|
||||
last_commit = self._run_git(workspace, ['log', '-1', '--format=%h|%s|%cr']).stdout.strip()
|
||||
commit_parts = last_commit.split('|') if last_commit else ['', '', '']
|
||||
|
||||
status_output = self._run_git(workspace, ['status', '--short']).stdout.strip()
|
||||
uncommitted = [f for f in status_output.split('\n') if f.strip()] if status_output else []
|
||||
|
||||
diff_stat = ''
|
||||
if uncommitted:
|
||||
diff_stat = self._run_git(workspace, ['diff', '--stat', '--cached']).stdout.strip()
|
||||
if not diff_stat:
|
||||
diff_stat = self._run_git(workspace, ['diff', '--stat']).stdout.strip()
|
||||
|
||||
uncommitted_parsed = []
|
||||
for line in uncommitted:
|
||||
if len(line) >= 2:
|
||||
status = line[:2].strip()
|
||||
filepath = line[2:].strip()
|
||||
if filepath:
|
||||
uncommitted_parsed.append({'status': status, 'path': filepath})
|
||||
|
||||
self.send_json({
|
||||
'branch': branch,
|
||||
'lastCommit': {
|
||||
'hash': commit_parts[0] if len(commit_parts) > 0 else '',
|
||||
'message': commit_parts[1] if len(commit_parts) > 1 else '',
|
||||
'time': commit_parts[2] if len(commit_parts) > 2 else '',
|
||||
},
|
||||
'uncommitted': uncommitted,
|
||||
'uncommittedParsed': uncommitted_parsed,
|
||||
'uncommittedCount': len(uncommitted),
|
||||
'diffStat': diff_stat,
|
||||
'clean': len(uncommitted) == 0,
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
# ── /api/diff ────────────────────────────────────────────────
|
||||
def handle_git_diff(self):
|
||||
"""Get git diff for a specific file."""
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
filepath = params.get('path', [''])[0]
|
||||
|
||||
if not filepath:
|
||||
self.send_json({'error': 'path required'}, 400)
|
||||
return
|
||||
|
||||
try:
|
||||
workspace = constants.GIT_WORKSPACE
|
||||
|
||||
target = (workspace / filepath).resolve()
|
||||
if not str(target).startswith(str(workspace)):
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
|
||||
diff = self._run_git(workspace, ['diff', '--cached', '--', filepath], timeout=10).stdout
|
||||
if not diff:
|
||||
diff = self._run_git(workspace, ['diff', '--', filepath], timeout=10).stdout
|
||||
|
||||
if not diff:
|
||||
status = self._run_git(workspace, ['status', '--short', '--', filepath]).stdout.strip()
|
||||
if status.startswith('??') and target.exists():
|
||||
content = target.read_text(encoding='utf-8', errors='replace')[:50000]
|
||||
diff = f"+++ b/{filepath}\n" + '\n'.join(f'+{line}' for line in content.split('\n'))
|
||||
|
||||
self.send_json({
|
||||
'path': filepath,
|
||||
'diff': diff or 'No changes',
|
||||
'hasDiff': bool(diff),
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_git_commit(self):
|
||||
"""Run git add, commit, and push for echo-core repo."""
|
||||
try:
|
||||
workspace = constants.ECHO_CORE_DIR
|
||||
|
||||
self._run_git(workspace, ['add', '-A'], timeout=10)
|
||||
|
||||
status = self._run_git(workspace, ['status', '--porcelain']).stdout.strip()
|
||||
if not status:
|
||||
self.send_json({'success': True, 'files': 0, 'output': 'Nothing to commit'})
|
||||
return
|
||||
|
||||
files_count = len([l for l in status.split('\n') if l.strip()])
|
||||
|
||||
commit_result = self._run_git(workspace, ['commit', '-m', 'chore: auto-commit from dashboard'], timeout=30)
|
||||
push_result = self._run_git(workspace, ['push'], timeout=30)
|
||||
|
||||
output = commit_result.stdout + commit_result.stderr + push_result.stdout + push_result.stderr
|
||||
|
||||
if commit_result.returncode == 0:
|
||||
self.send_json({'success': True, 'files': files_count, 'output': output})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': output or 'Commit failed'})
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
# ── /api/workspace/git/* (per-project) ───────────────────────
|
||||
def handle_workspace_git_diff(self):
|
||||
"""Get git diff for a workspace project."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
project_name = params.get('project', [''])[0]
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
if not (project_dir / '.git').exists():
|
||||
self.send_json({'error': 'Not a git repository'}, 400)
|
||||
return
|
||||
|
||||
status = self._run_git(project_dir, ['status', '--short'], timeout=10).stdout.strip()
|
||||
diff = self._run_git(project_dir, ['diff'], timeout=10).stdout
|
||||
diff_cached = self._run_git(project_dir, ['diff', '--cached'], timeout=10).stdout
|
||||
|
||||
combined_diff = ''
|
||||
if diff_cached:
|
||||
combined_diff += '=== Staged Changes ===\n' + diff_cached
|
||||
if diff:
|
||||
if combined_diff:
|
||||
combined_diff += '\n'
|
||||
combined_diff += '=== Unstaged Changes ===\n' + diff
|
||||
|
||||
self.send_json({
|
||||
'project': project_name,
|
||||
'status': status,
|
||||
'diff': combined_diff,
|
||||
'hasDiff': bool(status),
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'error': 'Timeout'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_workspace_git_commit(self):
|
||||
"""Commit all changes in a workspace project."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
project_name = data.get('project', '')
|
||||
message = data.get('message', '').strip()
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
if not (project_dir / '.git').exists():
|
||||
self.send_json({'success': False, 'error': 'Not a git repository'}, 400)
|
||||
return
|
||||
|
||||
porcelain = self._run_git(project_dir, ['status', '--porcelain'], timeout=10).stdout.strip()
|
||||
if not porcelain:
|
||||
self.send_json({'success': False, 'error': 'Nothing to commit'})
|
||||
return
|
||||
|
||||
files_changed = len([l for l in porcelain.split('\n') if l.strip()])
|
||||
|
||||
if not message:
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
message = f'Update: {now} ({files_changed} files)'
|
||||
|
||||
self._run_git(project_dir, ['add', '-A'], timeout=10)
|
||||
|
||||
result = self._run_git(project_dir, ['commit', '-m', message], timeout=30)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if result.returncode == 0:
|
||||
self.send_json({
|
||||
'success': True,
|
||||
'message': message,
|
||||
'output': output,
|
||||
'filesChanged': files_changed,
|
||||
})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': output or 'Commit failed'})
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'success': False, 'error': 'Timeout'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def _ensure_gitea_remote(self, project_dir, project_name):
|
||||
"""Create Gitea repo and add remote if no origin exists. Returns (ok, message)."""
|
||||
if not constants.GITEA_TOKEN:
|
||||
return False, 'GITEA_TOKEN not set'
|
||||
|
||||
api_url = f'{constants.GITEA_URL}/api/v1/orgs/{constants.GITEA_ORG}/repos'
|
||||
payload = json.dumps({'name': project_name, 'private': True, 'auto_init': False}).encode()
|
||||
req = urllib.request.Request(api_url, data=payload, method='POST', headers={
|
||||
'Authorization': f'token {constants.GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors='replace')
|
||||
if e.code == 409:
|
||||
pass # repo already exists — fine
|
||||
else:
|
||||
return False, f'Gitea API error {e.code}: {body}'
|
||||
|
||||
remote_url = f'{constants.GITEA_URL}/{constants.GITEA_ORG}/{project_name}.git'
|
||||
auth_url = remote_url.replace('https://', f'https://gitea:{constants.GITEA_TOKEN}@')
|
||||
subprocess.run(
|
||||
['git', 'remote', 'add', 'origin', auth_url],
|
||||
cwd=str(project_dir), capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return True, f'Created repo {constants.GITEA_ORG}/{project_name}'
|
||||
|
||||
def handle_workspace_git_push(self):
|
||||
"""Push a workspace project to its remote, creating Gitea repo if needed."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
project_name = data.get('project', '')
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
if not (project_dir / '.git').exists():
|
||||
self.send_json({'success': False, 'error': 'Not a git repository'}, 400)
|
||||
return
|
||||
|
||||
created_msg = ''
|
||||
remote_check = self._run_git(project_dir, ['remote', 'get-url', 'origin'], timeout=10)
|
||||
if remote_check.returncode != 0:
|
||||
ok, msg = self._ensure_gitea_remote(project_dir, project_name)
|
||||
if not ok:
|
||||
self.send_json({'success': False, 'error': msg})
|
||||
return
|
||||
created_msg = msg + '\n'
|
||||
|
||||
result = self._run_git(project_dir, ['push', '-u', 'origin', 'HEAD'], timeout=60)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if result.returncode == 0:
|
||||
self.send_json({'success': True, 'output': created_msg + (output or 'Pushed successfully')})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': output or 'Push failed'})
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'success': False, 'error': 'Push timeout (60s)'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
391
dashboard/handlers/habits.py
Normal file
391
dashboard/handlers/habits.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Habit tracking endpoints (CRUD + check / skip / uncheck)."""
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
import habits_helpers
|
||||
|
||||
|
||||
def _enrich(habit):
|
||||
"""Return habit with calculated stats added."""
|
||||
enriched = habit.copy()
|
||||
enriched['current_streak'] = habits_helpers.calculate_streak(habit)
|
||||
enriched['best_streak'] = habit.get('streak', {}).get('best', 0)
|
||||
enriched['completion_rate_30d'] = habits_helpers.get_completion_rate(habit, days=30)
|
||||
enriched['weekly_summary'] = habits_helpers.get_weekly_summary(habit)
|
||||
enriched['should_check_today'] = habits_helpers.should_check_today(habit)
|
||||
return enriched
|
||||
|
||||
|
||||
class HabitsHandlers:
|
||||
"""Mixin providing /api/habits endpoints."""
|
||||
|
||||
def handle_habits_get(self):
|
||||
"""Return all habits with enriched stats."""
|
||||
try:
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json([])
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
enriched = [_enrich(h) for h in data.get('habits', [])]
|
||||
enriched.sort(key=lambda h: h.get('priority', 999))
|
||||
self.send_json(enriched)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_post(self):
|
||||
"""Create a new habit."""
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
self.send_json({'error': 'name is required'}, 400)
|
||||
return
|
||||
if len(name) > 100:
|
||||
self.send_json({'error': 'name must be max 100 characters'}, 400)
|
||||
return
|
||||
|
||||
color = data.get('color', '#3b82f6')
|
||||
if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
|
||||
self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
|
||||
return
|
||||
|
||||
frequency_type = data.get('frequency', {}).get('type', 'daily')
|
||||
valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
|
||||
if frequency_type not in valid_types:
|
||||
self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
|
||||
return
|
||||
|
||||
habit_id = str(uuid.uuid4())
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
new_habit = {
|
||||
'id': habit_id,
|
||||
'name': name,
|
||||
'category': data.get('category', 'other'),
|
||||
'color': color,
|
||||
'icon': data.get('icon', 'check-circle'),
|
||||
'priority': data.get('priority', 5),
|
||||
'notes': data.get('notes', ''),
|
||||
'reminderTime': data.get('reminderTime', ''),
|
||||
'frequency': data.get('frequency', {'type': 'daily'}),
|
||||
'streak': {'current': 0, 'best': 0, 'lastCheckIn': None},
|
||||
'lives': 3,
|
||||
'completions': [],
|
||||
'createdAt': now,
|
||||
'updatedAt': now,
|
||||
}
|
||||
|
||||
if constants.HABITS_FILE.exists():
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
else:
|
||||
habits_data = {'lastUpdated': '', 'habits': []}
|
||||
|
||||
habits_data['habits'].append(new_habit)
|
||||
habits_data['lastUpdated'] = now
|
||||
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
self.send_json(new_habit, 201)
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({'error': 'Invalid JSON'}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_put(self):
|
||||
"""Update an existing habit."""
|
||||
try:
|
||||
path_parts = self.path.split('/')
|
||||
if len(path_parts) < 4:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
habit_id = path_parts[3]
|
||||
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
|
||||
habits = habits_data.get('habits', [])
|
||||
habit_index = next((i for i, h in enumerate(habits) if h['id'] == habit_id), None)
|
||||
if habit_index is None:
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
if 'name' in data:
|
||||
name = data['name'].strip()
|
||||
if not name:
|
||||
self.send_json({'error': 'name cannot be empty'}, 400)
|
||||
return
|
||||
if len(name) > 100:
|
||||
self.send_json({'error': 'name must be max 100 characters'}, 400)
|
||||
return
|
||||
if 'color' in data:
|
||||
color = data['color']
|
||||
if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
|
||||
self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
|
||||
return
|
||||
if 'frequency' in data:
|
||||
frequency_type = data.get('frequency', {}).get('type', 'daily')
|
||||
valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
|
||||
if frequency_type not in valid_types:
|
||||
self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
|
||||
return
|
||||
|
||||
allowed_fields = ['name', 'category', 'color', 'icon', 'priority', 'notes', 'frequency', 'reminderTime']
|
||||
habit = habits[habit_index]
|
||||
for field in allowed_fields:
|
||||
if field in data:
|
||||
habit[field] = data[field]
|
||||
|
||||
habit['updatedAt'] = datetime.now().isoformat()
|
||||
habits_data['lastUpdated'] = habit['updatedAt']
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
self.send_json(habit)
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({'error': 'Invalid JSON'}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_delete(self):
|
||||
"""Delete a habit."""
|
||||
try:
|
||||
path_parts = self.path.split('/')
|
||||
if len(path_parts) < 4:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
habit_id = path_parts[3]
|
||||
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
|
||||
habits = habits_data.get('habits', [])
|
||||
habit_found = False
|
||||
for i, habit in enumerate(habits):
|
||||
if habit['id'] == habit_id:
|
||||
habits.pop(i)
|
||||
habit_found = True
|
||||
break
|
||||
|
||||
if not habit_found:
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
habits_data['lastUpdated'] = datetime.now().isoformat()
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
self.send_response(204)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_check(self):
|
||||
"""Check in on a habit for today."""
|
||||
try:
|
||||
path_parts = self.path.split('/')
|
||||
if len(path_parts) < 5:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
habit_id = path_parts[3]
|
||||
|
||||
body_data = {}
|
||||
content_length = self.headers.get('Content-Length')
|
||||
if content_length:
|
||||
post_data = self.rfile.read(int(content_length)).decode('utf-8')
|
||||
if post_data.strip():
|
||||
try:
|
||||
body_data = json.loads(post_data)
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({'error': 'Invalid JSON'}, 400)
|
||||
return
|
||||
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
|
||||
habit = next((h for h in habits_data.get('habits', []) if h['id'] == habit_id), None)
|
||||
if not habit:
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
if not habits_helpers.should_check_today(habit):
|
||||
self.send_json({'error': 'Habit is not relevant for today based on its frequency'}, 400)
|
||||
return
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
for completion in habit.get('completions', []):
|
||||
if completion.get('date') == today:
|
||||
self.send_json({'error': 'Habit already checked in today'}, 409)
|
||||
return
|
||||
|
||||
completion_entry = {'date': today, 'type': 'check'}
|
||||
if 'note' in body_data:
|
||||
completion_entry['note'] = body_data['note']
|
||||
if 'rating' in body_data:
|
||||
rating = body_data['rating']
|
||||
if not isinstance(rating, int) or rating < 1 or rating > 5:
|
||||
self.send_json({'error': 'rating must be an integer between 1 and 5'}, 400)
|
||||
return
|
||||
completion_entry['rating'] = rating
|
||||
if 'mood' in body_data:
|
||||
mood = body_data['mood']
|
||||
if mood not in ['happy', 'neutral', 'sad']:
|
||||
self.send_json({'error': 'mood must be one of: happy, neutral, sad'}, 400)
|
||||
return
|
||||
completion_entry['mood'] = mood
|
||||
|
||||
habit['completions'].append(completion_entry)
|
||||
|
||||
current_streak = habits_helpers.calculate_streak(habit)
|
||||
habit['streak']['current'] = current_streak
|
||||
if current_streak > habit['streak']['best']:
|
||||
habit['streak']['best'] = current_streak
|
||||
habit['streak']['lastCheckIn'] = today
|
||||
|
||||
new_lives, was_awarded = habits_helpers.check_and_award_weekly_lives(habit)
|
||||
lives_awarded_this_checkin = False
|
||||
if was_awarded:
|
||||
habit['lives'] = new_lives
|
||||
habit['lastLivesAward'] = today
|
||||
lives_awarded_this_checkin = True
|
||||
|
||||
habit['updatedAt'] = datetime.now().isoformat()
|
||||
habits_data['lastUpdated'] = habit['updatedAt']
|
||||
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
enriched = _enrich(habit)
|
||||
enriched['livesAwarded'] = lives_awarded_this_checkin
|
||||
self.send_json(enriched, 200)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_uncheck(self):
|
||||
"""Remove a habit completion for a specific date."""
|
||||
try:
|
||||
path_parts = self.path.split('?')[0].split('/')
|
||||
if len(path_parts) < 5:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
habit_id = path_parts[3]
|
||||
|
||||
query_params = parse_qs(urlparse(self.path).query)
|
||||
if 'date' not in query_params:
|
||||
self.send_json({'error': 'date parameter is required (format: YYYY-MM-DD)'}, 400)
|
||||
return
|
||||
|
||||
target_date = query_params['date'][0]
|
||||
try:
|
||||
datetime.fromisoformat(target_date)
|
||||
except ValueError:
|
||||
self.send_json({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400)
|
||||
return
|
||||
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
|
||||
habit = next((h for h in habits_data.get('habits', []) if h['id'] == habit_id), None)
|
||||
if not habit:
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
completions = habit.get('completions', [])
|
||||
completion_found = False
|
||||
for i, completion in enumerate(completions):
|
||||
if completion.get('date') == target_date:
|
||||
completions.pop(i)
|
||||
completion_found = True
|
||||
break
|
||||
|
||||
if not completion_found:
|
||||
self.send_json({'error': 'No completion found for the specified date'}, 404)
|
||||
return
|
||||
|
||||
current_streak = habits_helpers.calculate_streak(habit)
|
||||
habit['streak']['current'] = current_streak
|
||||
if current_streak > habit['streak']['best']:
|
||||
habit['streak']['best'] = current_streak
|
||||
|
||||
habit['updatedAt'] = datetime.now().isoformat()
|
||||
habits_data['lastUpdated'] = habit['updatedAt']
|
||||
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
self.send_json(_enrich(habit), 200)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_skip(self):
|
||||
"""Skip a day using a life to preserve streak."""
|
||||
try:
|
||||
path_parts = self.path.split('/')
|
||||
if len(path_parts) < 5:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
habit_id = path_parts[3]
|
||||
|
||||
if not constants.HABITS_FILE.exists():
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
with open(constants.HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||
habits_data = json.load(f)
|
||||
|
||||
habit = next((h for h in habits_data.get('habits', []) if h['id'] == habit_id), None)
|
||||
if not habit:
|
||||
self.send_json({'error': 'Habit not found'}, 404)
|
||||
return
|
||||
|
||||
current_lives = habit.get('lives', 3)
|
||||
if current_lives <= 0:
|
||||
self.send_json({'error': 'No lives remaining'}, 400)
|
||||
return
|
||||
|
||||
habit['lives'] = current_lives - 1
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
habit['completions'].append({'date': today, 'type': 'skip'})
|
||||
|
||||
habit['updatedAt'] = datetime.now().isoformat()
|
||||
habits_data['lastUpdated'] = habit['updatedAt']
|
||||
|
||||
with open(constants.HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(habits_data, f, indent=2)
|
||||
|
||||
self.send_json(_enrich(habit), 200)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
62
dashboard/handlers/pdf.py
Normal file
62
dashboard/handlers/pdf.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Markdown → PDF conversion endpoint (delegates to tools/generate_pdf.py)."""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
class PDFHandlers:
|
||||
"""Mixin for /api/pdf."""
|
||||
|
||||
def handle_pdf_post(self):
|
||||
"""Convert markdown to PDF (text-based) by spawning the venv python."""
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
markdown_content = data.get('markdown', '')
|
||||
filename = data.get('filename', 'document.pdf')
|
||||
|
||||
if not markdown_content:
|
||||
self.send_json({'error': 'No markdown content'}, 400)
|
||||
return
|
||||
|
||||
venv_python = constants.VENV_PYTHON
|
||||
pdf_script = constants.TOOLS_DIR / 'generate_pdf.py'
|
||||
|
||||
if not venv_python.exists():
|
||||
self.send_json({'error': 'Venv Python not found'}, 500)
|
||||
return
|
||||
if not pdf_script.exists():
|
||||
self.send_json({'error': 'PDF generator script not found'}, 500)
|
||||
return
|
||||
|
||||
input_data = json.dumps({'markdown': markdown_content, 'filename': filename})
|
||||
result = subprocess.run(
|
||||
[str(venv_python), str(pdf_script)],
|
||||
input=input_data.encode('utf-8'),
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.decode('utf-8', errors='replace')
|
||||
try:
|
||||
error_json = json.loads(error_msg)
|
||||
self.send_json(error_json, 500)
|
||||
except Exception:
|
||||
self.send_json({'error': error_msg}, 500)
|
||||
return
|
||||
|
||||
pdf_bytes = result.stdout
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/pdf')
|
||||
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
|
||||
self.send_header('Content-Length', str(len(pdf_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(pdf_bytes)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'error': 'PDF generation timeout'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
1168
dashboard/handlers/projects.py
Normal file
1168
dashboard/handlers/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
615
dashboard/handlers/ralph.py
Normal file
615
dashboard/handlers/ralph.py
Normal file
@@ -0,0 +1,615 @@
|
||||
"""Ralph live dashboard endpoints (W3 + instrumentation + realtime).
|
||||
|
||||
Endpoints:
|
||||
GET /api/ralph/status — toate proiectele Ralph (cards data)
|
||||
GET /api/ralph/stream — Server-Sent Events stream (realtime)
|
||||
GET /api/ralph/<slug>/log — tail progress.txt (default 100 lines)
|
||||
GET /api/ralph/<slug>/prd — full prd.json content
|
||||
GET /api/ralph/usage[?days=N] — rate limit budget summary (cross-project)
|
||||
POST /api/ralph/<slug>/stop — SIGTERM la Ralph PID
|
||||
POST /api/ralph/<slug>/rollback — git revert HEAD + decrement last passing story
|
||||
|
||||
SSE detail: stream emite `event: status\\ndata: <json>\\n\\n` la schimbări (poll
|
||||
fişiere la 2s); heartbeat la 30s pentru ca clientul să nu reseze conexiunea.
|
||||
Necesită ThreadingHTTPServer în api.py — altfel un singur stream blochează tot.
|
||||
|
||||
Citește status din `~/workspace/<slug>/scripts/ralph/`:
|
||||
- prd.json → stories (passes/failed/blocked/retries)
|
||||
- progress.txt → log human-readable
|
||||
- logs/iteration-*.log → mtime ultimului iter
|
||||
- .ralph.pid → PID activ (verificat cu os.kill 0)
|
||||
- usage.jsonl → token/cost log per iter (instrumentation MVP)
|
||||
|
||||
Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import constants
|
||||
|
||||
from handlers._validators import _SLUG_RE, validate_slug
|
||||
|
||||
# Best-effort import of pure functions for /api/ralph/usage (instrumentation MVP).
|
||||
# Helper lives at <repo>/tools/ralph_usage.py — sibling of `dashboard/`.
|
||||
_TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
if str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
try:
|
||||
import ralph_usage # type: ignore
|
||||
except ImportError: # pragma: no cover — diagnostic only
|
||||
ralph_usage = None # type: ignore
|
||||
|
||||
|
||||
# Path Ralph per proiect (mereu în scripts/ralph/)
|
||||
def _ralph_dir(project_dir: Path) -> Path:
|
||||
return project_dir / "scripts" / "ralph"
|
||||
|
||||
|
||||
# Estimare ETA simplistă: avg iter time × stories rămase
|
||||
DEFAULT_ITER_MINUTES = 12 # midpoint din intervalul 8-15min menționat în plan
|
||||
|
||||
|
||||
class RalphHandlers:
|
||||
"""Mixin pentru /api/ralph/* — Ralph live status + control."""
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────
|
||||
def _ralph_validate_slug(self, slug: str):
|
||||
"""Validează slug-ul + returnează project_dir sau None.
|
||||
|
||||
Delegates the slug-shape check to the shared `validate_slug` helper
|
||||
in `dashboard/handlers/_validators.py`; only filesystem checks remain
|
||||
here (existence + path-confinement under WORKSPACE_DIR).
|
||||
"""
|
||||
if validate_slug(slug) is not None:
|
||||
return None
|
||||
project_dir = constants.WORKSPACE_DIR / slug
|
||||
try:
|
||||
resolved = project_dir.resolve()
|
||||
workspace_resolved = constants.WORKSPACE_DIR.resolve()
|
||||
resolved.relative_to(workspace_resolved)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if not project_dir.exists() or not project_dir.is_dir():
|
||||
return None
|
||||
return project_dir
|
||||
|
||||
def _ralph_pid_alive(self, ralph_dir: Path):
|
||||
"""Întoarce (running: bool, pid: int|None)."""
|
||||
pid_file = ralph_dir / ".ralph.pid"
|
||||
if not pid_file.exists():
|
||||
return False, None
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
os.kill(pid, 0) # signal 0 = check existence
|
||||
return True, pid
|
||||
except (ValueError, ProcessLookupError, PermissionError, OSError):
|
||||
return False, None
|
||||
|
||||
def _ralph_eta_minutes(self, stories_remaining: int, last_iter_mtime: float | None) -> int | None:
|
||||
"""Estimează minute rămase — None dacă nu avem date."""
|
||||
if stories_remaining <= 0:
|
||||
return 0
|
||||
return stories_remaining * DEFAULT_ITER_MINUTES
|
||||
|
||||
def _ralph_summarize_project(self, project_dir: Path) -> dict | None:
|
||||
"""Construiește dict de status per proiect — None dacă nu e Ralph project."""
|
||||
ralph_dir = _ralph_dir(project_dir)
|
||||
prd_json = ralph_dir / "prd.json"
|
||||
if not prd_json.exists():
|
||||
return None
|
||||
|
||||
# Defensive parse — corupt prd.json nu trebuie să dărâme dashboard
|
||||
try:
|
||||
prd = json.loads(prd_json.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {
|
||||
"slug": project_dir.name,
|
||||
"status": "error",
|
||||
"error": "prd.json invalid sau ilizibil",
|
||||
"running": False,
|
||||
"pid": None,
|
||||
"stories": [],
|
||||
"storiesTotal": 0,
|
||||
"storiesComplete": 0,
|
||||
"storiesFailed": 0,
|
||||
"storiesBlocked": 0,
|
||||
}
|
||||
|
||||
stories = prd.get("userStories", []) or []
|
||||
total = len(stories)
|
||||
complete = sum(1 for s in stories if s.get("passes"))
|
||||
failed = sum(1 for s in stories if s.get("failed"))
|
||||
blocked = sum(1 for s in stories if s.get("blocked"))
|
||||
remaining = total - complete - failed - blocked
|
||||
|
||||
running, pid = self._ralph_pid_alive(ralph_dir)
|
||||
|
||||
# Last iteration mtime (pentru "acum X")
|
||||
logs_dir = ralph_dir / "logs"
|
||||
last_iter_mtime = None
|
||||
last_iter_iso = None
|
||||
if logs_dir.exists():
|
||||
iter_logs = sorted(logs_dir.glob("iteration-*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if iter_logs:
|
||||
last_iter_mtime = iter_logs[0].stat().st_mtime
|
||||
last_iter_iso = datetime.fromtimestamp(last_iter_mtime).isoformat()
|
||||
|
||||
# Status compus pentru UI cards
|
||||
if running:
|
||||
top_status = "running"
|
||||
elif failed > 0 and remaining == 0:
|
||||
top_status = "failed"
|
||||
elif complete == total and total > 0:
|
||||
top_status = "complete"
|
||||
elif blocked > 0 and running is False:
|
||||
top_status = "blocked"
|
||||
else:
|
||||
top_status = "idle"
|
||||
|
||||
# Current story (DAG-eligible cel mai mic priority)
|
||||
current_story = None
|
||||
if running:
|
||||
eligible = [
|
||||
s for s in stories
|
||||
if not s.get("passes") and not s.get("failed") and not s.get("blocked")
|
||||
]
|
||||
eligible.sort(key=lambda s: (s.get("priority", 999), s.get("id", "")))
|
||||
if eligible:
|
||||
current_story = {
|
||||
"id": eligible[0].get("id"),
|
||||
"title": eligible[0].get("title"),
|
||||
"tags": eligible[0].get("tags", []),
|
||||
"retries": eligible[0].get("retries", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"slug": project_dir.name,
|
||||
"status": top_status,
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"branchName": prd.get("branchName", ""),
|
||||
"storiesTotal": total,
|
||||
"storiesComplete": complete,
|
||||
"storiesFailed": failed,
|
||||
"storiesBlocked": blocked,
|
||||
"storiesRemaining": remaining,
|
||||
"currentStory": current_story,
|
||||
"lastIterAt": last_iter_iso,
|
||||
"etaMinutes": self._ralph_eta_minutes(remaining, last_iter_mtime),
|
||||
"stories": [
|
||||
{
|
||||
"id": s.get("id"),
|
||||
"title": s.get("title"),
|
||||
"passes": bool(s.get("passes")),
|
||||
"failed": bool(s.get("failed")),
|
||||
"blocked": bool(s.get("blocked")),
|
||||
"retries": int(s.get("retries", 0)),
|
||||
"tags": s.get("tags", []),
|
||||
"failureReason": s.get("failureReason", ""),
|
||||
}
|
||||
for s in stories
|
||||
],
|
||||
}
|
||||
|
||||
def _ralph_collect_status(self) -> dict:
|
||||
"""Construieşte payload-ul de status pentru toate proiectele.
|
||||
|
||||
Folosit de `/api/ralph/status` (GET single-shot) şi de `/api/ralph/stream`
|
||||
(SSE — emis la schimbări).
|
||||
"""
|
||||
projects: list[dict] = []
|
||||
if constants.WORKSPACE_DIR.exists():
|
||||
for entry in sorted(constants.WORKSPACE_DIR.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
summary = self._ralph_summarize_project(entry)
|
||||
if summary is not None:
|
||||
projects.append(summary)
|
||||
return {
|
||||
"projects": projects,
|
||||
"fetchedAt": datetime.now().isoformat(),
|
||||
"count": len(projects),
|
||||
}
|
||||
|
||||
def _ralph_signature(self, snapshot: dict) -> tuple:
|
||||
"""Compactă semnătură pentru change-detection în SSE — doar fields care
|
||||
contează pentru UI (status, counts, current story). Timestamps de iter
|
||||
au granularitate de second pentru a evita flicker pe nanosecond drift.
|
||||
"""
|
||||
sig: list[tuple] = []
|
||||
for p in snapshot.get("projects", []) or []:
|
||||
cs = p.get("currentStory") or {}
|
||||
sig.append((
|
||||
p.get("slug"),
|
||||
p.get("status"),
|
||||
bool(p.get("running")),
|
||||
p.get("storiesTotal"),
|
||||
p.get("storiesComplete"),
|
||||
p.get("storiesFailed"),
|
||||
p.get("storiesBlocked"),
|
||||
p.get("lastIterAt"),
|
||||
cs.get("id"),
|
||||
cs.get("retries"),
|
||||
))
|
||||
return tuple(sorted(sig, key=lambda t: t[0] or ""))
|
||||
|
||||
# ── /api/ralph/status (GET) ────────────────────────────────
|
||||
def handle_ralph_status(self):
|
||||
"""Întoarce status pentru toate proiectele Ralph din workspace."""
|
||||
try:
|
||||
self.send_json(self._ralph_collect_status())
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/stream (GET, SSE) ───────────────────────────
|
||||
def handle_ralph_stream(self):
|
||||
"""Server-Sent Events: emite snapshot la schimbări (poll fişiere 2s).
|
||||
|
||||
Heartbeat la 30s pentru a evita timeout pe proxy-uri. Loop-ul iese
|
||||
curat la BrokenPipe (clientul închis tab-ul). Necesită
|
||||
ThreadingHTTPServer în api.py — altfel blochează toate request-urile.
|
||||
"""
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
# Disable proxy buffering (nginx/cloudflare) — flush imediat
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
|
||||
last_signature: tuple | None = None
|
||||
last_heartbeat = time.monotonic()
|
||||
|
||||
# Initial snapshot — clientul nu aşteaptă primul change
|
||||
try:
|
||||
snapshot = self._ralph_collect_status()
|
||||
last_signature = self._ralph_signature(snapshot)
|
||||
payload = json.dumps(snapshot).encode("utf-8")
|
||||
self.wfile.write(b"event: status\ndata: " + payload + b"\n\n")
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
except Exception as exc:
|
||||
try:
|
||||
err = json.dumps({"error": str(exc)}).encode("utf-8")
|
||||
self.wfile.write(b"event: error\ndata: " + err + b"\n\n")
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Stream loop
|
||||
while True:
|
||||
try:
|
||||
time.sleep(2)
|
||||
snapshot = self._ralph_collect_status()
|
||||
signature = self._ralph_signature(snapshot)
|
||||
now = time.monotonic()
|
||||
if signature != last_signature:
|
||||
payload = json.dumps(snapshot).encode("utf-8")
|
||||
self.wfile.write(b"event: status\ndata: " + payload + b"\n\n")
|
||||
self.wfile.flush()
|
||||
last_signature = signature
|
||||
last_heartbeat = now
|
||||
elif now - last_heartbeat >= 30:
|
||||
self.wfile.write(b"event: heartbeat\ndata: {}\n\n")
|
||||
self.wfile.flush()
|
||||
last_heartbeat = now
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
except Exception:
|
||||
# Best-effort: o iteraţie eşuată nu trebuie să termine stream-ul,
|
||||
# dar dacă socketul e mort BrokenPipe va prinde next loop.
|
||||
continue
|
||||
|
||||
# ── /api/ralph/<slug>/log (GET) ────────────────────────────
|
||||
def handle_ralph_log(self, slug: str):
|
||||
"""Tail progress.txt pentru un slug. Default last 100 lines."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
try:
|
||||
lines_n = min(int(qs.get("lines", ["100"])[0]), 1000)
|
||||
except ValueError:
|
||||
lines_n = 100
|
||||
|
||||
progress = _ralph_dir(project_dir) / "progress.txt"
|
||||
if not progress.exists():
|
||||
self.send_json({"slug": slug, "lines": [], "total": 0})
|
||||
return
|
||||
|
||||
try:
|
||||
content = progress.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
self.send_json({"error": f"read failed: {exc}"}, 500)
|
||||
return
|
||||
|
||||
all_lines = content.splitlines()
|
||||
tail = all_lines[-lines_n:] if len(all_lines) > lines_n else all_lines
|
||||
self.send_json({
|
||||
"slug": slug,
|
||||
"lines": tail,
|
||||
"total": len(all_lines),
|
||||
})
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/prd (GET) ────────────────────────────
|
||||
def handle_ralph_prd(self, slug: str):
|
||||
"""Returnează full prd.json pentru un slug."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
prd_json = _ralph_dir(project_dir) / "prd.json"
|
||||
if not prd_json.exists():
|
||||
self.send_json({"error": "prd.json not found"}, 404)
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(prd_json.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
self.send_json({"error": f"prd.json invalid: {exc}"}, 500)
|
||||
return
|
||||
|
||||
self.send_json(data)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/usage (GET) ─────────────────────────────────
|
||||
def handle_ralph_usage(self):
|
||||
"""Returnează rate limit budget summary cross-project.
|
||||
|
||||
Citește toate `~/workspace/<slug>/scripts/ralph/usage.jsonl`, le concatenează,
|
||||
rulează `ralph_usage.summarize` cu `?days=N` (default 7).
|
||||
|
||||
Răspuns:
|
||||
{
|
||||
"today": "YYYY-MM-DD",
|
||||
"today_cost": float,
|
||||
"today_runs": int,
|
||||
"window_days": N,
|
||||
"window_cost": float,
|
||||
"window_runs": int,
|
||||
"by_project": {...},
|
||||
"by_day": {...},
|
||||
"total_cost": float,
|
||||
"total_runs": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
try:
|
||||
days = int(qs.get("days", ["7"])[0])
|
||||
if days <= 0:
|
||||
days = 7
|
||||
if days > 365:
|
||||
days = 365
|
||||
except ValueError:
|
||||
days = 7
|
||||
|
||||
if ralph_usage is None:
|
||||
self.send_json({"error": "ralph_usage helper unavailable"}, 500)
|
||||
return
|
||||
|
||||
entries: list[dict] = []
|
||||
if constants.WORKSPACE_DIR.exists():
|
||||
for entry in sorted(constants.WORKSPACE_DIR.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
usage_path = _ralph_dir(entry) / "usage.jsonl"
|
||||
if usage_path.exists():
|
||||
entries.extend(ralph_usage.parse_usage_jsonl(usage_path))
|
||||
|
||||
summary = ralph_usage.summarize(entries, days=days)
|
||||
summary["fetchedAt"] = datetime.now().isoformat()
|
||||
self.send_json(summary)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/stop (POST) ──────────────────────────
|
||||
def handle_ralph_stop(self, slug: str):
|
||||
"""Trimite SIGTERM la Ralph PID. Verifică că PID-ul e în WORKSPACE_DIR."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"success": False, "error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
ralph_dir = _ralph_dir(project_dir)
|
||||
pid_file = ralph_dir / ".ralph.pid"
|
||||
if not pid_file.exists():
|
||||
self.send_json({"success": False, "error": "No PID file"}, 404)
|
||||
return
|
||||
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
except (ValueError, OSError) as exc:
|
||||
self.send_json({"success": False, "error": f"Invalid PID file: {exc}"}, 500)
|
||||
return
|
||||
|
||||
# Sandbox: verifică că procesul e în workspace (nu omoară random PID)
|
||||
try:
|
||||
proc_cwd = Path(f"/proc/{pid}/cwd").resolve()
|
||||
if not str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
||||
self.send_json({"success": False, "error": "PID not in workspace"}, 403)
|
||||
return
|
||||
except (FileNotFoundError, PermissionError):
|
||||
# Procesul nu mai există — best-effort cleanup
|
||||
self.send_json({"success": True, "message": "Process already stopped"})
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
self.send_json({"success": True, "message": "Process already stopped"})
|
||||
return
|
||||
except PermissionError:
|
||||
self.send_json({"success": False, "error": "Permission denied"}, 403)
|
||||
return
|
||||
|
||||
self.send_json({"success": True, "message": f"Ralph stopped (PID {pid})"})
|
||||
except Exception as exc:
|
||||
self.send_json({"success": False, "error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/rollback (POST) ──────────────────────
|
||||
def _ralph_decrement_last_pass(self, project_dir: Path) -> str | None:
|
||||
"""Marchează ultima story `passes=True` (din ordinea din prd.json) ca
|
||||
incompletă (`passes=False`, şterge `failed`/`blocked`/`failureReason`,
|
||||
retries=0). Atomic write (temp + rename). Întoarce id-ul story-ului
|
||||
sau None dacă nu există nimic de decrementat / prd.json invalid.
|
||||
"""
|
||||
prd_path = _ralph_dir(project_dir) / "prd.json"
|
||||
if not prd_path.exists():
|
||||
return None
|
||||
try:
|
||||
prd = json.loads(prd_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
stories = prd.get("userStories", []) or []
|
||||
target_idx: int | None = None
|
||||
# ultima poziţională cu passes=True (DAG-order = ordine de finalizare)
|
||||
for i in range(len(stories) - 1, -1, -1):
|
||||
if stories[i].get("passes"):
|
||||
target_idx = i
|
||||
break
|
||||
if target_idx is None:
|
||||
return None
|
||||
story_id = stories[target_idx].get("id")
|
||||
stories[target_idx]["passes"] = False
|
||||
# Reset stare derivată — story-ul e disponibil pentru re-run
|
||||
stories[target_idx].pop("failed", None)
|
||||
stories[target_idx].pop("blocked", None)
|
||||
stories[target_idx].pop("failureReason", None)
|
||||
stories[target_idx]["retries"] = 0
|
||||
# Atomic write (acelaşi pattern ca W3 ralph_dag.py)
|
||||
tmp = prd_path.with_suffix(".json.tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(prd, indent=2), encoding="utf-8")
|
||||
tmp.replace(prd_path)
|
||||
except OSError:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return None
|
||||
return story_id
|
||||
|
||||
def handle_ralph_rollback(self, slug: str):
|
||||
"""Rollback ultimul commit într-un proiect Ralph.
|
||||
|
||||
Strategy: `git revert --no-edit HEAD` (history-preserving). Fallback la
|
||||
`git reset --hard HEAD~1` doar dacă revert eşuează (conflict, binary
|
||||
file). După succes, decrementează `passes` pe ultima story marcată
|
||||
complete în prd.json (atomic write).
|
||||
|
||||
Returns: `{success, message, reverted_commit, story_reverted, method}`.
|
||||
"""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": "Invalid project slug",
|
||||
"reverted_commit": None,
|
||||
"story_reverted": None,
|
||||
}, 400)
|
||||
return
|
||||
|
||||
git_dir = project_dir / ".git"
|
||||
if not git_dir.exists():
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": "Not a git repository",
|
||||
"reverted_commit": None,
|
||||
"story_reverted": None,
|
||||
}, 400)
|
||||
return
|
||||
|
||||
# Read HEAD before any operation (raportăm SHA-ul afectat)
|
||||
head_proc = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(project_dir), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if head_proc.returncode != 0:
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": f"git rev-parse HEAD failed: {head_proc.stderr.strip()}",
|
||||
"reverted_commit": None,
|
||||
"story_reverted": None,
|
||||
}, 500)
|
||||
return
|
||||
commit_to_revert = head_proc.stdout.strip()
|
||||
|
||||
# Try revert (preserves history, recommended)
|
||||
method = "revert"
|
||||
revert = subprocess.run(
|
||||
["git", "revert", "--no-edit", "HEAD"],
|
||||
cwd=str(project_dir), capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if revert.returncode != 0:
|
||||
# Conflict / binary file — abort & fall back to reset --hard
|
||||
subprocess.run(
|
||||
["git", "revert", "--abort"],
|
||||
cwd=str(project_dir), capture_output=True, timeout=10,
|
||||
)
|
||||
reset = subprocess.run(
|
||||
["git", "reset", "--hard", "HEAD~1"],
|
||||
cwd=str(project_dir), capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if reset.returncode != 0:
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": (
|
||||
f"revert failed ({revert.stderr.strip()[:200]}), "
|
||||
f"reset failed ({reset.stderr.strip()[:200]})"
|
||||
),
|
||||
"reverted_commit": commit_to_revert,
|
||||
"story_reverted": None,
|
||||
}, 500)
|
||||
return
|
||||
method = "reset"
|
||||
|
||||
# Best-effort: decrement story passes (nu fail dacă lipseşte prd.json)
|
||||
story_reverted = self._ralph_decrement_last_pass(project_dir)
|
||||
|
||||
short_sha = commit_to_revert[:8]
|
||||
msg_bits = [f"Rolled back {short_sha} via git {method}"]
|
||||
if story_reverted:
|
||||
msg_bits.append(f"story {story_reverted} marked incomplete")
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": "; ".join(msg_bits),
|
||||
"reverted_commit": commit_to_revert,
|
||||
"story_reverted": story_reverted,
|
||||
"method": method,
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": "git operation timed out",
|
||||
"reverted_commit": None,
|
||||
"story_reverted": None,
|
||||
}, 500)
|
||||
except Exception as exc:
|
||||
self.send_json({
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"reverted_commit": None,
|
||||
"story_reverted": None,
|
||||
}, 500)
|
||||
375
dashboard/handlers/workspace.py
Normal file
375
dashboard/handlers/workspace.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""~/workspace/ project control: list, run, stop, delete, logs."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
from handlers._validators import validate_slug
|
||||
|
||||
|
||||
class WorkspaceHandlers:
|
||||
"""Mixin for /api/workspace and /api/workspace/*."""
|
||||
|
||||
def _validate_project(self, name):
|
||||
"""Validate project name and return its path, or None."""
|
||||
if validate_slug(name) is not None:
|
||||
return None
|
||||
project_dir = constants.WORKSPACE_DIR / name
|
||||
if not project_dir.exists() or not project_dir.is_dir():
|
||||
return None
|
||||
if not str(project_dir.resolve()).startswith(str(constants.WORKSPACE_DIR)):
|
||||
return None
|
||||
return project_dir
|
||||
|
||||
# ── /api/workspace list ─────────────────────────────────────
|
||||
def handle_workspace_list(self):
|
||||
"""List projects in ~/workspace/ with Ralph status, git info, etc."""
|
||||
try:
|
||||
projects = []
|
||||
if not constants.WORKSPACE_DIR.exists():
|
||||
self.send_json({'projects': []})
|
||||
return
|
||||
|
||||
for project_dir in sorted(constants.WORKSPACE_DIR.iterdir()):
|
||||
if not project_dir.is_dir() or project_dir.name.startswith('.'):
|
||||
continue
|
||||
|
||||
ralph_dir = project_dir / 'scripts' / 'ralph'
|
||||
prd_json = ralph_dir / 'prd.json'
|
||||
tasks_dir = project_dir / 'tasks'
|
||||
|
||||
proj = {
|
||||
'name': project_dir.name,
|
||||
'path': str(project_dir),
|
||||
'hasRalph': ralph_dir.exists(),
|
||||
'hasPrd': any(tasks_dir.glob('prd-*.md')) if tasks_dir.exists() else False,
|
||||
'hasMain': (project_dir / 'main.py').exists(),
|
||||
'hasVenv': (project_dir / 'venv').exists(),
|
||||
'hasReadme': (project_dir / 'README.md').exists(),
|
||||
'ralph': None,
|
||||
'process': {'running': False, 'pid': None, 'port': None},
|
||||
'git': None,
|
||||
}
|
||||
|
||||
# Ralph status
|
||||
if prd_json.exists():
|
||||
try:
|
||||
prd = json.loads(prd_json.read_text())
|
||||
stories = prd.get('userStories', [])
|
||||
complete = sum(1 for s in stories if s.get('passes'))
|
||||
|
||||
ralph_pid = None
|
||||
ralph_running = False
|
||||
pid_file = ralph_dir / '.ralph.pid'
|
||||
if pid_file.exists():
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
os.kill(pid, 0)
|
||||
ralph_running = True
|
||||
ralph_pid = pid
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
last_iter = None
|
||||
tech = {}
|
||||
logs_dir = ralph_dir / 'logs'
|
||||
if logs_dir.exists():
|
||||
log_files = sorted(logs_dir.glob('iteration-*.log'), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if log_files:
|
||||
mtime = log_files[0].stat().st_mtime
|
||||
last_iter = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')
|
||||
tech = prd.get('techStack', {})
|
||||
|
||||
proj['ralph'] = {
|
||||
'running': ralph_running,
|
||||
'pid': ralph_pid,
|
||||
'storiesTotal': len(stories),
|
||||
'storiesComplete': complete,
|
||||
'lastIteration': last_iter,
|
||||
'stories': [
|
||||
{'id': s.get('id', ''), 'title': s.get('title', ''), 'passes': s.get('passes', False)}
|
||||
for s in stories
|
||||
],
|
||||
}
|
||||
proj['techStack'] = {
|
||||
'type': tech.get('type', ''),
|
||||
'commands': tech.get('commands', {}),
|
||||
'port': tech.get('port'),
|
||||
}
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
# Check if main.py is running
|
||||
if proj['hasMain']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
port = None
|
||||
if prd_json.exists():
|
||||
try:
|
||||
prd_data = json.loads(prd_json.read_text())
|
||||
port = prd_data.get('techStack', {}).get('port')
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
proj['process'] = {
|
||||
'running': True,
|
||||
'pid': int(pids[0]),
|
||||
'port': port,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Git info (using _run_git from GitHandlers mixin)
|
||||
if (project_dir / '.git').exists():
|
||||
try:
|
||||
branch = self._run_git(project_dir, ['branch', '--show-current']).stdout.strip()
|
||||
last_commit = self._run_git(project_dir, ['log', '-1', '--format=%h - %s']).stdout.strip()
|
||||
status_out = self._run_git(project_dir, ['status', '--short']).stdout.strip()
|
||||
uncommitted = len([l for l in status_out.split('\n') if l.strip()]) if status_out else 0
|
||||
proj['git'] = {
|
||||
'branch': branch,
|
||||
'lastCommit': last_commit,
|
||||
'uncommitted': uncommitted,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
projects.append(proj)
|
||||
|
||||
self.send_json({'projects': projects})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
# ── /api/workspace/run (main | ralph | test) ───────────────
|
||||
def handle_workspace_run(self):
|
||||
"""Start a project process (main.py, ralph.sh, or pytest)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
project_name = data.get('project', '')
|
||||
command = data.get('command', '')
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
allowed_commands = {'main', 'ralph', 'test'}
|
||||
if command not in allowed_commands:
|
||||
self.send_json({'success': False, 'error': f'Invalid command. Allowed: {", ".join(allowed_commands)}'}, 400)
|
||||
return
|
||||
|
||||
ralph_dir = project_dir / 'scripts' / 'ralph'
|
||||
|
||||
if command == 'main':
|
||||
main_py = project_dir / 'main.py'
|
||||
if not main_py.exists():
|
||||
self.send_json({'success': False, 'error': 'No main.py found'}, 404)
|
||||
return
|
||||
|
||||
venv_python = project_dir / 'venv' / 'bin' / 'python'
|
||||
python_cmd = str(venv_python) if venv_python.exists() else sys.executable
|
||||
|
||||
log_path = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(log_path, 'a') as log_file:
|
||||
proc = subprocess.Popen(
|
||||
[python_cmd, 'main.py'],
|
||||
cwd=str(project_dir),
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
|
||||
|
||||
elif command == 'ralph':
|
||||
ralph_sh = ralph_dir / 'ralph.sh'
|
||||
if not ralph_sh.exists():
|
||||
self.send_json({'success': False, 'error': 'No ralph.sh found'}, 404)
|
||||
return
|
||||
|
||||
log_path = ralph_dir / 'logs' / 'ralph.log'
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(log_path, 'a') as log_file:
|
||||
proc = subprocess.Popen(
|
||||
['bash', str(ralph_sh)],
|
||||
cwd=str(project_dir),
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
(ralph_dir / '.ralph.pid').write_text(str(proc.pid))
|
||||
self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
|
||||
|
||||
elif command == 'test':
|
||||
venv_python = project_dir / 'venv' / 'bin' / 'python'
|
||||
python_cmd = str(venv_python) if venv_python.exists() else sys.executable
|
||||
|
||||
result = subprocess.run(
|
||||
[python_cmd, '-m', 'pytest', '-v', '--tb=short'],
|
||||
cwd=str(project_dir),
|
||||
capture_output=True, text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.send_json({
|
||||
'success': result.returncode == 0,
|
||||
'output': result.stdout + result.stderr,
|
||||
'returncode': result.returncode,
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({'success': False, 'error': 'Test timeout (120s)'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_workspace_stop(self):
|
||||
"""Stop a project process."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
project_name = data.get('project', '')
|
||||
target = data.get('target', '')
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
if target not in ('main', 'ralph'):
|
||||
self.send_json({'success': False, 'error': 'Invalid target. Use: main, ralph'}, 400)
|
||||
return
|
||||
|
||||
if target == 'ralph':
|
||||
pid_file = project_dir / 'scripts' / 'ralph' / '.ralph.pid'
|
||||
if pid_file.exists():
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
|
||||
if str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
self.send_json({'success': True, 'message': f'Ralph stopped (PID {pid})'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
|
||||
except ProcessLookupError:
|
||||
self.send_json({'success': True, 'message': 'Process already stopped'})
|
||||
except PermissionError:
|
||||
self.send_json({'success': False, 'error': 'Permission denied'}, 403)
|
||||
else:
|
||||
self.send_json({'success': False, 'error': 'No PID file found'}, 404)
|
||||
|
||||
elif target == 'main':
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pid = int(result.stdout.strip().split('\n')[0])
|
||||
proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
|
||||
if str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
self.send_json({'success': True, 'message': f'Main stopped (PID {pid})'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
|
||||
else:
|
||||
self.send_json({'success': True, 'message': 'No running process found'})
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_workspace_delete(self):
|
||||
"""Delete a workspace project."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
project_name = data.get('project', '')
|
||||
confirm = data.get('confirm', '')
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
if confirm != project_name:
|
||||
self.send_json({'success': False, 'error': 'Confirmation does not match project name'}, 400)
|
||||
return
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-f', f'{project_dir.name}/(main\\.py|ralph)'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
self.send_json({'success': False, 'error': 'Project has running processes. Stop them first.'})
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
shutil.rmtree(str(project_dir))
|
||||
self.send_json({'success': True, 'message': f'Project {project_name} deleted'})
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_workspace_logs(self):
|
||||
"""Get last N lines from a project log."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
project_name = params.get('project', [''])[0]
|
||||
log_type = params.get('type', ['ralph'])[0]
|
||||
lines_count = min(int(params.get('lines', ['100'])[0]), 500)
|
||||
|
||||
project_dir = self._validate_project(project_name)
|
||||
if not project_dir:
|
||||
self.send_json({'error': 'Invalid project'}, 400)
|
||||
return
|
||||
|
||||
ralph_dir = project_dir / 'scripts' / 'ralph'
|
||||
|
||||
if log_type == 'ralph':
|
||||
log_file = ralph_dir / 'logs' / 'ralph.log'
|
||||
if not log_file.exists():
|
||||
log_file = ralph_dir / 'logs' / 'ralph-test.log'
|
||||
elif log_type == 'main':
|
||||
log_file = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
|
||||
elif log_type == 'progress':
|
||||
log_file = ralph_dir / 'progress.txt'
|
||||
elif log_type.startswith('iteration-'):
|
||||
log_file = ralph_dir / 'logs' / f'{log_type}.log'
|
||||
else:
|
||||
self.send_json({'error': 'Invalid log type'}, 400)
|
||||
return
|
||||
|
||||
if not log_file.exists():
|
||||
self.send_json({'project': project_name, 'type': log_type, 'lines': [], 'total': 0})
|
||||
return
|
||||
|
||||
if not str(log_file.resolve()).startswith(str(constants.WORKSPACE_DIR)):
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
|
||||
content = log_file.read_text(encoding='utf-8', errors='replace')
|
||||
all_lines = content.split('\n')
|
||||
total = len(all_lines)
|
||||
last_lines = all_lines[-lines_count:] if len(all_lines) > lines_count else all_lines
|
||||
|
||||
self.send_json({
|
||||
'project': project_name,
|
||||
'type': log_type,
|
||||
'lines': last_lines,
|
||||
'total': total,
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
257
dashboard/handlers/youtube.py
Normal file
257
dashboard/handlers/youtube.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""YouTube subtitle-download + note-creation endpoint."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import constants
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clean_vtt(content):
|
||||
"""Convert VTT captions to plain text."""
|
||||
lines = []
|
||||
seen = set()
|
||||
for line in content.split('\n'):
|
||||
if any([
|
||||
line.startswith('WEBVTT'),
|
||||
line.startswith('Kind:'),
|
||||
line.startswith('Language:'),
|
||||
'-->' in line,
|
||||
line.strip().startswith('<'),
|
||||
not line.strip(),
|
||||
re.match(r'^\d+$', line.strip()),
|
||||
]):
|
||||
continue
|
||||
clean = re.sub(r'<[^>]+>', '', line).strip()
|
||||
if clean and clean not in seen:
|
||||
seen.add(clean)
|
||||
lines.append(clean)
|
||||
return ' '.join(lines)
|
||||
|
||||
|
||||
def _is_description_about_video(description):
|
||||
"""Return True if description contains info about the video (chapters/topics)."""
|
||||
if not description or len(description.strip()) < 50:
|
||||
return False
|
||||
timestamp_pattern = re.compile(r'\b\d{1,2}:\d{2}(:\d{2})?\b')
|
||||
if len(timestamp_pattern.findall(description)) >= 3:
|
||||
return True
|
||||
lines = description.strip().split('\n')
|
||||
bullet_lines = [l for l in lines if re.match(r'^\s*[◼•\-\*▶►]\s+\S', l)]
|
||||
if len(bullet_lines) >= 3:
|
||||
return True
|
||||
numbered_lines = [l for l in lines if re.match(r'^\s*\d+[\.\)]\s+\S', l)]
|
||||
if len(numbered_lines) >= 3:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_relevant_description(description):
|
||||
"""Strip promotional tails (links, social media) from description."""
|
||||
if not description:
|
||||
return ""
|
||||
promo_patterns = [
|
||||
re.compile(r'https?://\S+'),
|
||||
re.compile(r'instagram|twitter|facebook|tiktok|linkedin|patreon|spotify', re.I),
|
||||
re.compile(r'follow|subscribe|newsletter|merch|sponsor|affiliate', re.I),
|
||||
re.compile(r'purchase|buy|order|shop|store', re.I),
|
||||
]
|
||||
result_lines = []
|
||||
promo_streak = 0
|
||||
for line in description.strip().split('\n'):
|
||||
stripped = line.strip()
|
||||
is_promo = any(p.search(stripped) for p in promo_patterns)
|
||||
if is_promo:
|
||||
promo_streak += 1
|
||||
if promo_streak >= 2:
|
||||
break
|
||||
else:
|
||||
promo_streak = 0
|
||||
result_lines.append(line)
|
||||
while result_lines and not result_lines[-1].strip():
|
||||
result_lines.pop()
|
||||
return '\n'.join(result_lines)
|
||||
|
||||
|
||||
ANALYSIS_PROMPT = """\
|
||||
Ai primit transcriptul unui video YouTube și descrierea lui. Scrie o notiță KB în română, format Markdown.
|
||||
|
||||
Structura notei (în ordine):
|
||||
1. ## TL;DR — un paragraf de 3-5 rânduri care surprinde esența
|
||||
2. ## Puncte cheie — 6-10 puncte concise (pot fi bullets, dar scurte și dense)
|
||||
3. ## Quote-uri memorabile — 4-6 citate directe din transcript, în limba originală, între ghilimele
|
||||
4. ## Idei acționabile — 4-8 lucruri concrete pe care cititorul le poate face
|
||||
5. Secțiuni tematice cu ## heading — câte teme apar natural, în proze curgătoare (NU bullets), fiecare cu conținut real din transcript: cifre, exemple, mecanisme, argumente
|
||||
|
||||
Nu scrie metadate (titlu, url, tags, dată) — vor fi adăugate separat.
|
||||
Nu scrie fraze introductive despre tine sau despre video. Începe direct cu ## TL;DR.
|
||||
Scrie în română. Citatele rămân în engleză dacă sursa e engleză.
|
||||
"""
|
||||
|
||||
|
||||
def _analyze_with_claude(title, description, transcript):
|
||||
"""Call claude -p to generate rich analysis of the video."""
|
||||
claude_bin = os.path.expanduser('~/.local/bin/claude')
|
||||
if not os.path.exists(claude_bin):
|
||||
claude_bin = 'claude'
|
||||
|
||||
desc_section = ""
|
||||
if description:
|
||||
desc_section = f"DESCRIERE VIDEO:\n{description[:3000]}\n\n"
|
||||
|
||||
prompt = (
|
||||
f"{ANALYSIS_PROMPT}\n\n"
|
||||
f"TITLU: {title}\n\n"
|
||||
f"{desc_section}"
|
||||
f"TRANSCRIPT (primele 40000 caractere):\n{transcript[:40000]}"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[claude_bin, '-p', prompt],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
log.warning("Claude analysis failed: %s", result.stderr[:300])
|
||||
return None
|
||||
|
||||
|
||||
def _process_youtube(url):
|
||||
"""Download subtitles, save note."""
|
||||
yt_dlp = os.path.expanduser('~/.local/bin/yt-dlp')
|
||||
|
||||
result = subprocess.run(
|
||||
[yt_dlp, '--dump-json', '--no-download', url],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to get video info: {result.stderr}")
|
||||
return
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
title = info.get('title', 'Unknown')
|
||||
duration = info.get('duration', 0)
|
||||
description = info.get('description', '')
|
||||
|
||||
temp_dir = Path('/tmp/yt_subs')
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
for f in temp_dir.glob('*'):
|
||||
f.unlink()
|
||||
|
||||
subprocess.run([
|
||||
yt_dlp, '--write-auto-subs', '--sub-langs', 'en',
|
||||
'--skip-download', '--sub-format', 'vtt',
|
||||
'-o', str(temp_dir / '%(id)s'),
|
||||
url,
|
||||
], capture_output=True, timeout=120)
|
||||
|
||||
transcript = None
|
||||
for sub_file in temp_dir.glob('*.vtt'):
|
||||
content = sub_file.read_text(encoding='utf-8', errors='replace')
|
||||
transcript = _clean_vtt(content)
|
||||
break
|
||||
|
||||
if not transcript:
|
||||
print("No subtitles found")
|
||||
return
|
||||
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
slug = re.sub(r'[^\w\s-]', '', title.lower())[:50].strip().replace(' ', '-')
|
||||
filename = f"{date_str}_{slug}.md"
|
||||
|
||||
# Description block
|
||||
desc_block = ""
|
||||
if _is_description_about_video(description):
|
||||
relevant_desc = _extract_relevant_description(description)
|
||||
if relevant_desc:
|
||||
desc_block = f"\n## Descriere / Index\n\n{relevant_desc}\n\n---\n"
|
||||
|
||||
# Claude analysis: TL;DR + puncte cheie + citate + teme în proze
|
||||
print("Running Claude analysis...")
|
||||
analysis = _analyze_with_claude(title, description, transcript)
|
||||
|
||||
if analysis:
|
||||
note_content = f"""# {title}
|
||||
|
||||
**Video:** {url}
|
||||
**Duration:** {duration // 60}:{duration % 60:02d}
|
||||
**Saved:** {date_str}
|
||||
**Tags:** #youtube
|
||||
|
||||
---
|
||||
{desc_block}
|
||||
{analysis}
|
||||
"""
|
||||
else:
|
||||
# Fallback: save raw transcript if Claude fails
|
||||
note_content = f"""# {title}
|
||||
|
||||
**Video:** {url}
|
||||
**Duration:** {duration // 60}:{duration % 60:02d}
|
||||
**Saved:** {date_str}
|
||||
**Tags:** #youtube #to-summarize
|
||||
|
||||
---
|
||||
{desc_block}
|
||||
## Transcript
|
||||
|
||||
{transcript[:15000]}
|
||||
"""
|
||||
|
||||
constants.NOTES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
note_path = constants.NOTES_DIR / filename
|
||||
note_path.write_text(note_content, encoding='utf-8')
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, str(constants.TOOLS_DIR / 'update_notes_index.py')],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Index new note with Ollama semantic embeddings
|
||||
try:
|
||||
sys.path.insert(0, str(constants.BASE_DIR))
|
||||
from src.memory_search import index_file, MEMORY_DIR
|
||||
n = index_file(note_path)
|
||||
log.info("Ollama indexed %s (%d chunks)", filename, n)
|
||||
except Exception as e:
|
||||
log.warning("Ollama indexing failed for %s: %s", filename, e)
|
||||
|
||||
print(f"Created note: {filename}")
|
||||
return filename
|
||||
|
||||
|
||||
class YoutubeHandlers:
|
||||
"""Mixin for /api/youtube."""
|
||||
|
||||
def handle_youtube(self):
|
||||
"""Process a YouTube URL: download subs, save note."""
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
url = data.get('url', '').strip()
|
||||
|
||||
if not url or ('youtube.com' not in url and 'youtu.be' not in url):
|
||||
self.send_json({'error': 'URL YouTube invalid'}, 400)
|
||||
return
|
||||
|
||||
try:
|
||||
print(f"Processing YouTube URL: {url}")
|
||||
_process_youtube(url)
|
||||
self.send_json({
|
||||
'status': 'done',
|
||||
'message': 'Notița a fost creată! Refresh pagina Notes.',
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"YouTube processing error: {e}")
|
||||
traceback.print_exc()
|
||||
self.send_json({'status': 'error', 'message': f'Eroare: {str(e)}'}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
3237
dashboard/index.html
Normal file
3237
dashboard/index.html
Normal file
File diff suppressed because it is too large
Load Diff
69
dashboard/issues.json
Normal file
69
dashboard/issues.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"lastUpdated": "2026-03-31T20:02:48.501Z",
|
||||
"programs": [
|
||||
"ROACONT",
|
||||
"ROAGEST",
|
||||
"ROAIMOB",
|
||||
"ROAFACTURARE",
|
||||
"ROADEF",
|
||||
"ROASTART",
|
||||
"ROAPRINT",
|
||||
"ROAWEB",
|
||||
"Clawdbot",
|
||||
"Personal",
|
||||
"Altele"
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"id": "ROA-004",
|
||||
"title": "Banca-Plati-Plata comision bancar 627- ar aparea si campul de Lucrare/Comanda",
|
||||
"description": "Banca-Plati-Plata comision bancar 627- ar aparea si campul de Lucrare/Comanda",
|
||||
"program": "ROACONT",
|
||||
"owner": "robert",
|
||||
"priority": "important",
|
||||
"status": "done",
|
||||
"created": "2026-02-12T13:19:01.786Z",
|
||||
"deadline": null,
|
||||
"completed": "2026-02-13T23:06:16.567Z"
|
||||
},
|
||||
{
|
||||
"id": "ROA-002",
|
||||
"title": "D406 - verificare SAFT account Id gol",
|
||||
"description": "",
|
||||
"program": "ROACONT",
|
||||
"owner": "robert",
|
||||
"priority": "urgent-important",
|
||||
"status": "done",
|
||||
"created": "2026-02-02T11:25:18.115Z",
|
||||
"deadline": "2026-02-02",
|
||||
"updated": "2026-02-02T22:27:06.428Z",
|
||||
"completed": "2026-02-03T17:20:07.195Z"
|
||||
},
|
||||
{
|
||||
"id": "ROA-001",
|
||||
"title": "D101: Mutare impozit precedent RD49→RD50",
|
||||
"description": "RD 49 = în urma inspecției fiscale\nRD 50 = impozit precedent\nFormularul nu recalculează impozitul de 16%\nRD 40 se modifică și la 4.1",
|
||||
"program": "ROACONT",
|
||||
"owner": "marius",
|
||||
"priority": "important",
|
||||
"status": "done",
|
||||
"created": "2026-01-30T15:10:00Z",
|
||||
"deadline": "2026-02-06",
|
||||
"updated": "2026-02-02T22:26:59.690Z",
|
||||
"completed": "2026-02-05T21:53:55.392Z"
|
||||
},
|
||||
{
|
||||
"id": "ROA-003",
|
||||
"title": "Auto-copiere manoperă din devize stimative în devize reale",
|
||||
"description": "",
|
||||
"program": "ROAGEST",
|
||||
"owner": "robert",
|
||||
"priority": "backlog",
|
||||
"status": "done",
|
||||
"created": "2026-02-12T10:03:13.378157+00:00",
|
||||
"deadline": null,
|
||||
"updated": "2026-02-13T13:03:45.355Z",
|
||||
"completed": "2026-03-31T20:02:48.489Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
291
dashboard/login.html
Normal file
291
dashboard/login.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
|
||||
<title>Echo — Autentificare</title>
|
||||
<link rel="stylesheet" href="/echo/static/tokens.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base, #13131a);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
width: min(380px, 100% - 48px);
|
||||
}
|
||||
|
||||
.monogram {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--accent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
.form-input.is-invalid {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
min-height: 1.25em;
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--error);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-error.is-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.submit-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card { padding: var(--space-5); }
|
||||
.monogram { font-size: 48px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-shell">
|
||||
<div class="monogram" aria-hidden="true">E</div>
|
||||
<section class="login-card">
|
||||
<h1 class="login-title">Echo Dashboard</h1>
|
||||
<p class="login-subtitle">Autentificare</p>
|
||||
<form id="login-form" method="post" action="/echo/api/auth/login" novalidate>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="token-input">Token de acces</label>
|
||||
<input
|
||||
id="token-input"
|
||||
name="token"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="Token de acces"
|
||||
aria-describedby="form-error"
|
||||
required>
|
||||
<div id="form-error" class="form-error" role="alert" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="submit-btn" type="submit" class="submit-btn">Intră</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('login-form');
|
||||
var input = document.getElementById('token-input');
|
||||
var btn = document.getElementById('submit-btn');
|
||||
var errorEl = document.getElementById('form-error');
|
||||
|
||||
var DEFAULT_LABEL = 'Intră';
|
||||
var SUBMITTING_LABEL = 'Se autentifică...';
|
||||
var RETRY_LABEL = 'Reîncearcă';
|
||||
|
||||
// Auto-focus input on load (skip on touch devices to avoid keyboard pop)
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
if (!('ontouchstart' in window)) {
|
||||
try { input.focus(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error styling as soon as the user edits the field
|
||||
input.addEventListener('input', function () {
|
||||
if (input.classList.contains('is-invalid')) {
|
||||
input.classList.remove('is-invalid');
|
||||
errorEl.textContent = '';
|
||||
errorEl.classList.remove('is-visible');
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
var token = input.value.trim();
|
||||
if (!token) {
|
||||
input.classList.add('is-invalid');
|
||||
errorEl.textContent = 'Token invalid';
|
||||
errorEl.classList.add('is-visible');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submitting state
|
||||
btn.disabled = true;
|
||||
btn.textContent = SUBMITTING_LABEL;
|
||||
input.classList.remove('is-invalid');
|
||||
errorEl.textContent = '';
|
||||
errorEl.classList.remove('is-visible');
|
||||
|
||||
var body = 'token=' + encodeURIComponent(token);
|
||||
|
||||
fetch('/echo/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json, text/html'
|
||||
},
|
||||
body: body,
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
}).then(function (res) {
|
||||
// Browsers auto-follow 302, so a successful login surfaces
|
||||
// here as a 2xx (workspace.html) or an opaqueredirect.
|
||||
if (res.ok || res.type === 'opaqueredirect' || res.redirected) {
|
||||
// Redirect back to the page the user originally wanted,
|
||||
// passed as ?next= by the server. Validate it's a safe
|
||||
// relative /echo/ path to prevent open-redirect attacks.
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var next = params.get('next') || '';
|
||||
// The proxy strips /echo/ before Python, so `next` is
|
||||
// e.g. "/workspace.html". Re-add the /echo prefix for
|
||||
// the browser. Guard against open-redirect (no ://).
|
||||
var dest = (next && /^\/[^/]/.test(next) && next.indexOf('://') === -1)
|
||||
? '/echo' + next
|
||||
: '/echo/workspace.html';
|
||||
window.location.assign(dest);
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
showInvalid();
|
||||
return;
|
||||
}
|
||||
// Any other status — treat as a generic failure
|
||||
showInvalid();
|
||||
}).catch(function () {
|
||||
showInvalid();
|
||||
});
|
||||
});
|
||||
|
||||
function showInvalid() {
|
||||
input.classList.add('is-invalid');
|
||||
errorEl.textContent = 'Token invalid';
|
||||
errorEl.classList.add('is-visible');
|
||||
btn.disabled = false;
|
||||
btn.textContent = RETRY_LABEL;
|
||||
try { input.focus(); input.select(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
dashboard/memory
Symbolic link
1
dashboard/memory
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/moltbot/echo-core/memory
|
||||
1
dashboard/notes-data
Symbolic link
1
dashboard/notes-data
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/moltbot/echo-core/memory/kb
|
||||
1295
dashboard/notes.html
Normal file
1295
dashboard/notes.html
Normal file
File diff suppressed because it is too large
Load Diff
123
dashboard/swipe-nav.js
Normal file
123
dashboard/swipe-nav.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Swipe Navigation for Echo
|
||||
* Swipe left/right to navigate between pages
|
||||
*/
|
||||
(function() {
|
||||
const pages = ['index.html', 'workspace.html', 'notes.html', 'habits.html', 'files.html'];
|
||||
|
||||
// Get current page index
|
||||
function getCurrentIndex() {
|
||||
const path = window.location.pathname;
|
||||
let filename = path.split('/').pop() || 'index.html';
|
||||
// Handle /echo/ without filename
|
||||
if (filename === '' || filename === 'echo') filename = 'index.html';
|
||||
const idx = pages.indexOf(filename);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
// Navigate to page
|
||||
function navigateTo(index) {
|
||||
if (index >= 0 && index < pages.length) {
|
||||
window.location.href = pages[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe detection
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
const minSwipeDistance = 80;
|
||||
const maxVerticalDistance = 100;
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
function handleSwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = Math.abs(touchEndY - touchStartY);
|
||||
|
||||
// Ignore if vertical swipe or too short
|
||||
if (deltaY > maxVerticalDistance) return;
|
||||
if (Math.abs(deltaX) < minSwipeDistance) return;
|
||||
|
||||
const currentIndex = getCurrentIndex();
|
||||
|
||||
if (deltaX > 0) {
|
||||
// Swipe right → previous page
|
||||
navigateTo(currentIndex - 1);
|
||||
} else {
|
||||
// Swipe left → next page
|
||||
navigateTo(currentIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual indicator (optional dots)
|
||||
function createIndicator() {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'swipe-indicator';
|
||||
indicator.innerHTML = pages.map((_, i) =>
|
||||
`<span class="swipe-dot ${i === getCurrentIndex() ? 'active' : ''}"></span>`
|
||||
).join('');
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
// Add indicator styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.swipe-indicator {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 9999;
|
||||
padding: 10px 16px;
|
||||
background: rgba(50, 50, 60, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.swipe-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.swipe-dot.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.swipe-indicator { display: none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Init after DOM ready
|
||||
function init() {
|
||||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||||
createIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
1129
dashboard/tests/test_habits_api.py
Normal file
1129
dashboard/tests/test_habits_api.py
Normal file
File diff suppressed because it is too large
Load Diff
2868
dashboard/tests/test_habits_frontend.py
Normal file
2868
dashboard/tests/test_habits_frontend.py
Normal file
File diff suppressed because it is too large
Load Diff
573
dashboard/tests/test_habits_helpers.py
Normal file
573
dashboard/tests/test_habits_helpers.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
Tests for habits_helpers.py
|
||||
|
||||
Tests cover all helper functions for habit tracking including:
|
||||
- calculate_streak for all 6 frequency types
|
||||
- should_check_today for all frequency types
|
||||
- get_completion_rate
|
||||
- get_weekly_summary
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Add parent directory to path to import habits_helpers
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from habits_helpers import (
|
||||
calculate_streak,
|
||||
should_check_today,
|
||||
get_completion_rate,
|
||||
get_weekly_summary,
|
||||
check_and_award_weekly_lives
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_streak_daily_consecutive():
|
||||
"""Test daily streak with consecutive days."""
|
||||
today = datetime.now().date()
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": [
|
||||
{"date": today.isoformat()},
|
||||
{"date": (today - timedelta(days=1)).isoformat()},
|
||||
{"date": (today - timedelta(days=2)).isoformat()},
|
||||
]
|
||||
}
|
||||
assert calculate_streak(habit) == 3
|
||||
|
||||
|
||||
def test_calculate_streak_daily_with_gap():
|
||||
"""Test daily streak breaks on gap."""
|
||||
today = datetime.now().date()
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": [
|
||||
{"date": today.isoformat()},
|
||||
{"date": (today - timedelta(days=1)).isoformat()},
|
||||
# Gap here (day 2 missing)
|
||||
{"date": (today - timedelta(days=3)).isoformat()},
|
||||
]
|
||||
}
|
||||
assert calculate_streak(habit) == 2
|
||||
|
||||
|
||||
def test_calculate_streak_daily_empty():
|
||||
"""Test daily streak with no completions."""
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": []
|
||||
}
|
||||
assert calculate_streak(habit) == 0
|
||||
|
||||
|
||||
def test_calculate_streak_specific_days():
|
||||
"""Test specific_days streak (Mon, Wed, Fri)."""
|
||||
today = datetime.now().date()
|
||||
|
||||
# Find the most recent Monday
|
||||
days_since_monday = today.weekday()
|
||||
last_monday = today - timedelta(days=days_since_monday)
|
||||
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "specific_days",
|
||||
"days": [0, 2, 4] # Mon, Wed, Fri (0=Mon in Python weekday)
|
||||
},
|
||||
"completions": [
|
||||
{"date": last_monday.isoformat()}, # Mon
|
||||
{"date": (last_monday - timedelta(days=2)).isoformat()}, # Fri previous week
|
||||
{"date": (last_monday - timedelta(days=4)).isoformat()}, # Wed previous week
|
||||
]
|
||||
}
|
||||
|
||||
# Should count 3 consecutive relevant days
|
||||
streak = calculate_streak(habit)
|
||||
assert streak >= 1 # At least the most recent relevant day
|
||||
|
||||
|
||||
def test_calculate_streak_x_per_week():
|
||||
"""Test x_per_week streak (3 times per week)."""
|
||||
today = datetime.now().date()
|
||||
|
||||
# Find Monday of current week
|
||||
days_since_monday = today.weekday()
|
||||
monday = today - timedelta(days=days_since_monday)
|
||||
|
||||
# Current week: 3 completions (Mon, Tue, Wed)
|
||||
# Previous week: 3 completions (Mon, Tue, Wed)
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "x_per_week",
|
||||
"count": 3
|
||||
},
|
||||
"completions": [
|
||||
{"date": monday.isoformat()}, # This week Mon
|
||||
{"date": (monday + timedelta(days=1)).isoformat()}, # This week Tue
|
||||
{"date": (monday + timedelta(days=2)).isoformat()}, # This week Wed
|
||||
# Previous week
|
||||
{"date": (monday - timedelta(days=7)).isoformat()}, # Last week Mon
|
||||
{"date": (monday - timedelta(days=6)).isoformat()}, # Last week Tue
|
||||
{"date": (monday - timedelta(days=5)).isoformat()}, # Last week Wed
|
||||
]
|
||||
}
|
||||
|
||||
streak = calculate_streak(habit)
|
||||
assert streak >= 2 # Both weeks meet the target
|
||||
|
||||
|
||||
def test_calculate_streak_weekly():
|
||||
"""Test weekly streak (at least 1 per week)."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {"type": "weekly"},
|
||||
"completions": [
|
||||
{"date": today.isoformat()}, # This week
|
||||
{"date": (today - timedelta(days=7)).isoformat()}, # Last week
|
||||
{"date": (today - timedelta(days=14)).isoformat()}, # 2 weeks ago
|
||||
]
|
||||
}
|
||||
|
||||
streak = calculate_streak(habit)
|
||||
assert streak >= 1
|
||||
|
||||
|
||||
def test_calculate_streak_monthly():
|
||||
"""Test monthly streak (at least 1 per month)."""
|
||||
today = datetime.now().date()
|
||||
|
||||
# This month
|
||||
habit = {
|
||||
"frequency": {"type": "monthly"},
|
||||
"completions": [
|
||||
{"date": today.isoformat()},
|
||||
]
|
||||
}
|
||||
|
||||
streak = calculate_streak(habit)
|
||||
assert streak >= 1
|
||||
|
||||
|
||||
def test_calculate_streak_custom_interval():
|
||||
"""Test custom interval streak (every 3 days)."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "custom",
|
||||
"interval": 3
|
||||
},
|
||||
"completions": [
|
||||
{"date": today.isoformat()},
|
||||
{"date": (today - timedelta(days=3)).isoformat()},
|
||||
{"date": (today - timedelta(days=6)).isoformat()},
|
||||
]
|
||||
}
|
||||
|
||||
streak = calculate_streak(habit)
|
||||
assert streak == 3
|
||||
|
||||
|
||||
def test_should_check_today_daily():
|
||||
"""Test should_check_today for daily habit."""
|
||||
habit = {"frequency": {"type": "daily"}}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
|
||||
def test_should_check_today_specific_days():
|
||||
"""Test should_check_today for specific_days habit."""
|
||||
today_weekday = datetime.now().date().weekday()
|
||||
|
||||
# Habit relevant today
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "specific_days",
|
||||
"days": [today_weekday]
|
||||
}
|
||||
}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
# Habit not relevant today
|
||||
other_day = (today_weekday + 1) % 7
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "specific_days",
|
||||
"days": [other_day]
|
||||
}
|
||||
}
|
||||
assert should_check_today(habit) is False
|
||||
|
||||
|
||||
def test_should_check_today_x_per_week():
|
||||
"""Test should_check_today for x_per_week habit."""
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "x_per_week",
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
|
||||
def test_should_check_today_weekly():
|
||||
"""Test should_check_today for weekly habit."""
|
||||
habit = {"frequency": {"type": "weekly"}}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
|
||||
def test_should_check_today_monthly():
|
||||
"""Test should_check_today for monthly habit."""
|
||||
habit = {"frequency": {"type": "monthly"}}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
|
||||
def test_should_check_today_custom_ready():
|
||||
"""Test should_check_today for custom interval when ready."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "custom",
|
||||
"interval": 3
|
||||
},
|
||||
"completions": [
|
||||
{"date": (today - timedelta(days=3)).isoformat()}
|
||||
]
|
||||
}
|
||||
assert should_check_today(habit) is True
|
||||
|
||||
|
||||
def test_should_check_today_custom_not_ready():
|
||||
"""Test should_check_today for custom interval when not ready."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "custom",
|
||||
"interval": 3
|
||||
},
|
||||
"completions": [
|
||||
{"date": (today - timedelta(days=1)).isoformat()}
|
||||
]
|
||||
}
|
||||
assert should_check_today(habit) is False
|
||||
|
||||
|
||||
def test_get_completion_rate_daily_perfect():
|
||||
"""Test completion rate for daily habit with 100%."""
|
||||
today = datetime.now().date()
|
||||
|
||||
completions = []
|
||||
for i in range(30):
|
||||
completions.append({"date": (today - timedelta(days=i)).isoformat()})
|
||||
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": completions
|
||||
}
|
||||
|
||||
rate = get_completion_rate(habit, days=30)
|
||||
assert rate == 100.0
|
||||
|
||||
|
||||
def test_get_completion_rate_daily_half():
|
||||
"""Test completion rate for daily habit with 50%."""
|
||||
today = datetime.now().date()
|
||||
|
||||
completions = []
|
||||
for i in range(0, 30, 2): # Every other day
|
||||
completions.append({"date": (today - timedelta(days=i)).isoformat()})
|
||||
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": completions
|
||||
}
|
||||
|
||||
rate = get_completion_rate(habit, days=30)
|
||||
assert 45 <= rate <= 55 # Around 50%
|
||||
|
||||
|
||||
def test_get_completion_rate_specific_days():
|
||||
"""Test completion rate for specific_days habit."""
|
||||
today = datetime.now().date()
|
||||
today_weekday = today.weekday()
|
||||
|
||||
# Create habit for Mon, Wed, Fri
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "specific_days",
|
||||
"days": [0, 2, 4]
|
||||
},
|
||||
"completions": []
|
||||
}
|
||||
|
||||
# Add completions for all relevant days in last 30 days
|
||||
for i in range(30):
|
||||
check_date = today - timedelta(days=i)
|
||||
if check_date.weekday() in [0, 2, 4]:
|
||||
habit["completions"].append({"date": check_date.isoformat()})
|
||||
|
||||
rate = get_completion_rate(habit, days=30)
|
||||
assert rate == 100.0
|
||||
|
||||
|
||||
def test_get_completion_rate_empty():
|
||||
"""Test completion rate with no completions."""
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": []
|
||||
}
|
||||
|
||||
rate = get_completion_rate(habit, days=30)
|
||||
assert rate == 0.0
|
||||
|
||||
|
||||
def test_get_weekly_summary():
|
||||
"""Test weekly summary returns correct structure."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": [
|
||||
{"date": today.isoformat()},
|
||||
{"date": (today - timedelta(days=1)).isoformat()},
|
||||
]
|
||||
}
|
||||
|
||||
summary = get_weekly_summary(habit)
|
||||
|
||||
# Check structure
|
||||
assert isinstance(summary, dict)
|
||||
assert "Monday" in summary
|
||||
assert "Tuesday" in summary
|
||||
assert "Wednesday" in summary
|
||||
assert "Thursday" in summary
|
||||
assert "Friday" in summary
|
||||
assert "Saturday" in summary
|
||||
assert "Sunday" in summary
|
||||
|
||||
# Check values are valid
|
||||
valid_statuses = ["checked", "skipped", "missed", "upcoming", "not_relevant"]
|
||||
for day, status in summary.items():
|
||||
assert status in valid_statuses
|
||||
|
||||
|
||||
def test_get_weekly_summary_with_skip():
|
||||
"""Test weekly summary handles skipped days."""
|
||||
today = datetime.now().date()
|
||||
|
||||
habit = {
|
||||
"frequency": {"type": "daily"},
|
||||
"completions": [
|
||||
{"date": today.isoformat(), "type": "check"},
|
||||
{"date": (today - timedelta(days=1)).isoformat(), "type": "skip"},
|
||||
]
|
||||
}
|
||||
|
||||
summary = get_weekly_summary(habit)
|
||||
|
||||
# Find today's day name
|
||||
day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
today_name = day_names[today.weekday()]
|
||||
yesterday_name = day_names[(today.weekday() - 1) % 7]
|
||||
|
||||
assert summary[today_name] == "checked"
|
||||
assert summary[yesterday_name] == "skipped"
|
||||
|
||||
|
||||
def test_get_weekly_summary_specific_days():
|
||||
"""Test weekly summary marks non-relevant days correctly."""
|
||||
today = datetime.now().date()
|
||||
today_weekday = today.weekday()
|
||||
|
||||
# Habit only for Monday (0)
|
||||
habit = {
|
||||
"frequency": {
|
||||
"type": "specific_days",
|
||||
"days": [0]
|
||||
},
|
||||
"completions": []
|
||||
}
|
||||
|
||||
summary = get_weekly_summary(habit)
|
||||
|
||||
# All days except Monday should be not_relevant or upcoming
|
||||
for day_name, status in summary.items():
|
||||
if day_name == "Monday":
|
||||
continue # Monday can be any status
|
||||
if status not in ["upcoming", "not_relevant"]:
|
||||
# Day should be not_relevant if it's in the past
|
||||
pass
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_awards_life_with_checkin():
|
||||
"""Test that +1 life is awarded if there was ≥1 check-in in previous week."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Add check-in in previous week (Wednesday)
|
||||
habit = {
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == True
|
||||
assert new_lives == 3
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_no_award_without_checkin():
|
||||
"""Test that no life is awarded if there were no check-ins in previous week."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
|
||||
# Add check-in in current week only
|
||||
habit = {
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{"date": (current_week_start + timedelta(days=1)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == False
|
||||
assert new_lives == 2
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_no_duplicate_award():
|
||||
"""Test that life is not awarded twice in the same week."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Add check-in in previous week and mark as already awarded this week
|
||||
habit = {
|
||||
"lives": 3,
|
||||
"lastLivesAward": current_week_start.isoformat(),
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == False
|
||||
assert new_lives == 3
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_skip_doesnt_count():
|
||||
"""Test that skips don't count toward weekly recovery."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Add only skips in previous week, no check-ins
|
||||
habit = {
|
||||
"lives": 1,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "skip"},
|
||||
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "skip"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == False
|
||||
assert new_lives == 1
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_multiple_checkins():
|
||||
"""Test that award works with multiple check-ins in previous week."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Add multiple check-ins in previous week
|
||||
habit = {
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=1)).isoformat(), "type": "check"},
|
||||
{"date": (previous_week_start + timedelta(days=3)).isoformat(), "type": "check"},
|
||||
{"date": (previous_week_start + timedelta(days=5)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == True
|
||||
assert new_lives == 3
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_no_cap():
|
||||
"""Test that lives can accumulate beyond 3."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Habit with 5 lives
|
||||
habit = {
|
||||
"lives": 5,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == True
|
||||
assert new_lives == 6
|
||||
|
||||
|
||||
def test_check_and_award_weekly_lives_missing_last_award_field():
|
||||
"""Test backward compatibility when lastLivesAward field is missing."""
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Habit without lastLivesAward field (backward compatible)
|
||||
habit = {
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"}
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == True
|
||||
assert new_lives == 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run all tests
|
||||
import inspect
|
||||
|
||||
test_functions = [
|
||||
obj for name, obj in inspect.getmembers(sys.modules[__name__])
|
||||
if inspect.isfunction(obj) and name.startswith("test_")
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test_func in test_functions:
|
||||
try:
|
||||
test_func()
|
||||
print(f"✓ {test_func.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f"✗ {test_func.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {test_func.__name__}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
555
dashboard/tests/test_habits_integration.py
Normal file
555
dashboard/tests/test_habits_integration.py
Normal file
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for Habits feature - End-to-end flows
|
||||
|
||||
Tests complete workflows involving multiple API calls and state transitions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import HTTPServer
|
||||
from threading import Thread
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Add parent directory to path to import api module
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from api import TaskBoardHandler
|
||||
import habits_helpers
|
||||
|
||||
|
||||
# Test helpers
|
||||
def setup_test_env():
|
||||
"""Create temporary environment for testing"""
|
||||
from pathlib import Path
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
habits_file = Path(temp_dir) / 'habits.json'
|
||||
|
||||
# Initialize empty habits file
|
||||
with open(habits_file, 'w') as f:
|
||||
json.dump({'lastUpdated': datetime.now().isoformat(), 'habits': []}, f)
|
||||
|
||||
# Override HABITS_FILE constant
|
||||
import api
|
||||
api.HABITS_FILE = habits_file
|
||||
|
||||
return temp_dir
|
||||
|
||||
|
||||
def teardown_test_env(temp_dir):
|
||||
"""Clean up temporary environment"""
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def start_test_server():
|
||||
"""Start HTTP server on random port for testing"""
|
||||
server = HTTPServer(('localhost', 0), TaskBoardHandler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server
|
||||
|
||||
|
||||
def http_request(url, method='GET', data=None):
|
||||
"""Make HTTP request and return response data"""
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
if data:
|
||||
data = json.dumps(data).encode('utf-8')
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
body = response.read().decode('utf-8')
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode('utf-8')
|
||||
try:
|
||||
return {'error': json.loads(error_body), 'status': e.code}
|
||||
except:
|
||||
return {'error': error_body, 'status': e.code}
|
||||
|
||||
|
||||
# Integration Tests
|
||||
|
||||
def test_01_create_and_checkin_increments_streak():
|
||||
"""Integration test: create habit → check-in → verify streak is 1"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create daily habit
|
||||
habit_data = {
|
||||
'name': 'Morning meditation',
|
||||
'category': 'health',
|
||||
'color': '#10B981',
|
||||
'icon': 'brain',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
if 'error' in result:
|
||||
print(f"Error creating habit: {result}")
|
||||
assert 'id' in result, f"Should return created habit with ID, got: {result}"
|
||||
habit_id = result['id']
|
||||
|
||||
# Check in today
|
||||
checkin_result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST')
|
||||
|
||||
# Verify streak incremented to 1
|
||||
assert checkin_result['streak']['current'] == 1, "Streak should be 1 after first check-in"
|
||||
assert checkin_result['streak']['best'] == 1, "Best streak should be 1 after first check-in"
|
||||
assert checkin_result['streak']['lastCheckIn'] == datetime.now().date().isoformat(), "Last check-in should be today"
|
||||
|
||||
print("✓ Test 1: Create + check-in → streak is 1")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_02_seven_consecutive_checkins_restore_life():
|
||||
"""Integration test: 7 consecutive check-ins → life restored (if below 3)"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create daily habit
|
||||
habit_data = {
|
||||
'name': 'Daily exercise',
|
||||
'category': 'health',
|
||||
'color': '#EF4444',
|
||||
'icon': 'dumbbell',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Manually set lives to 1 (instead of using skip API which would add completions)
|
||||
import api
|
||||
with open(api.HABITS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
|
||||
habit_obj['lives'] = 1 # Directly set to 1 (simulating 2 skips used)
|
||||
|
||||
# Add 7 consecutive check-in completions for the past 7 days
|
||||
for i in range(7):
|
||||
check_date = (datetime.now() - timedelta(days=6-i)).date().isoformat()
|
||||
habit_obj['completions'].append({
|
||||
'date': check_date,
|
||||
'type': 'check'
|
||||
})
|
||||
|
||||
# Recalculate streak and check for life restore
|
||||
habit_obj['streak'] = {
|
||||
'current': habits_helpers.calculate_streak(habit_obj),
|
||||
'best': max(habit_obj['streak']['best'], habits_helpers.calculate_streak(habit_obj)),
|
||||
'lastCheckIn': datetime.now().date().isoformat()
|
||||
}
|
||||
|
||||
# Check life restore logic: last 7 completions all 'check' type
|
||||
last_7 = habit_obj['completions'][-7:]
|
||||
if len(last_7) == 7 and all(c.get('type') == 'check' for c in last_7):
|
||||
if habit_obj['lives'] < 3:
|
||||
habit_obj['lives'] += 1
|
||||
|
||||
data['lastUpdated'] = datetime.now().isoformat()
|
||||
with open(api.HABITS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Get updated habit
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
habit = next(h for h in habits if h['id'] == habit_id)
|
||||
|
||||
# Verify life restored
|
||||
assert habit['lives'] == 2, f"Should have 2 lives after 7 consecutive check-ins (was {habit['lives']})"
|
||||
assert habit['current_streak'] == 7, "Should have streak of 7"
|
||||
|
||||
print("✓ Test 2: 7 consecutive check-ins → life restored")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_03_skip_with_life_maintains_streak():
|
||||
"""Integration test: skip with life → lives decremented, streak unchanged"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create daily habit
|
||||
habit_data = {
|
||||
'name': 'Read book',
|
||||
'category': 'growth',
|
||||
'color': '#3B82F6',
|
||||
'icon': 'book',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Check in yesterday (to build a streak)
|
||||
import api
|
||||
with open(api.HABITS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
|
||||
yesterday = (datetime.now() - timedelta(days=1)).date().isoformat()
|
||||
habit_obj['completions'].append({
|
||||
'date': yesterday,
|
||||
'type': 'check'
|
||||
})
|
||||
habit_obj['streak'] = {
|
||||
'current': 1,
|
||||
'best': 1,
|
||||
'lastCheckIn': yesterday
|
||||
}
|
||||
|
||||
data['lastUpdated'] = datetime.now().isoformat()
|
||||
with open(api.HABITS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Skip today
|
||||
skip_result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
|
||||
|
||||
# Verify lives decremented and streak maintained
|
||||
assert skip_result['lives'] == 2, "Lives should be 2 after skip"
|
||||
|
||||
# Get fresh habit data to check streak
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
habit = next(h for h in habits if h['id'] == habit_id)
|
||||
|
||||
# Streak should still be 1 (skip doesn't break it)
|
||||
assert habit['current_streak'] == 1, "Streak should be maintained after skip"
|
||||
|
||||
print("✓ Test 3: Skip with life → lives decremented, streak unchanged")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_04_skip_with_zero_lives_returns_400():
|
||||
"""Integration test: skip with 0 lives → returns 400 error"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create daily habit
|
||||
habit_data = {
|
||||
'name': 'Yoga practice',
|
||||
'category': 'health',
|
||||
'color': '#8B5CF6',
|
||||
'icon': 'heart',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Use all 3 lives
|
||||
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
|
||||
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
|
||||
http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
|
||||
|
||||
# Attempt to skip with 0 lives
|
||||
result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST')
|
||||
|
||||
# Verify 400 error
|
||||
assert result['status'] == 400, "Should return 400 status"
|
||||
assert 'error' in result, "Should return error message"
|
||||
|
||||
print("✓ Test 4: Skip with 0 lives → returns 400 error")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_05_edit_frequency_changes_should_check_today():
|
||||
"""Integration test: edit frequency → should_check_today logic changes"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create daily habit
|
||||
habit_data = {
|
||||
'name': 'Code review',
|
||||
'category': 'work',
|
||||
'color': '#F59E0B',
|
||||
'icon': 'code',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Verify should_check_today is True for daily habit
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
habit = next(h for h in habits if h['id'] == habit_id)
|
||||
assert habit['should_check_today'] == True, "Daily habit should be checkable today"
|
||||
|
||||
# Edit to specific_days (only Monday and Wednesday)
|
||||
update_data = {
|
||||
'name': 'Code review',
|
||||
'category': 'work',
|
||||
'color': '#F59E0B',
|
||||
'icon': 'code',
|
||||
'priority': 50,
|
||||
'frequency': {
|
||||
'type': 'specific_days',
|
||||
'days': ['monday', 'wednesday']
|
||||
}
|
||||
}
|
||||
|
||||
http_request(f"{base_url}/api/habits/{habit_id}", method='PUT', data=update_data)
|
||||
|
||||
# Get updated habit
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
habit = next(h for h in habits if h['id'] == habit_id)
|
||||
|
||||
# Verify should_check_today reflects new frequency
|
||||
today_name = datetime.now().strftime('%A').lower()
|
||||
expected = today_name in ['monday', 'wednesday']
|
||||
assert habit['should_check_today'] == expected, f"Should check today should be {expected} for {today_name}"
|
||||
|
||||
print(f"✓ Test 5: Edit frequency → should_check_today is {expected} for {today_name}")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_06_delete_removes_habit_from_storage():
|
||||
"""Integration test: delete → habit removed from storage"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create habit
|
||||
habit_data = {
|
||||
'name': 'Guitar practice',
|
||||
'category': 'personal',
|
||||
'color': '#EC4899',
|
||||
'icon': 'music',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Verify habit exists
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
assert len(habits) == 1, "Should have 1 habit"
|
||||
assert habits[0]['id'] == habit_id, "Should be the created habit"
|
||||
|
||||
# Delete habit
|
||||
http_request(f"{base_url}/api/habits/{habit_id}", method='DELETE')
|
||||
|
||||
# Verify habit removed
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
assert len(habits) == 0, "Should have 0 habits after delete"
|
||||
|
||||
# Verify not in storage file
|
||||
import api
|
||||
with open(api.HABITS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert len(data['habits']) == 0, "Storage file should have 0 habits"
|
||||
|
||||
print("✓ Test 6: Delete → habit removed from storage")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_07_checkin_on_wrong_day_for_specific_days_returns_400():
|
||||
"""Integration test: check-in on wrong day for specific_days → returns 400"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Get today's day name
|
||||
today_name = datetime.now().strftime('%A').lower()
|
||||
|
||||
# Create habit for different days (not today)
|
||||
if today_name == 'monday':
|
||||
allowed_days = ['tuesday', 'wednesday']
|
||||
elif today_name == 'tuesday':
|
||||
allowed_days = ['monday', 'wednesday']
|
||||
else:
|
||||
allowed_days = ['monday', 'tuesday']
|
||||
|
||||
habit_data = {
|
||||
'name': 'Gym workout',
|
||||
'category': 'health',
|
||||
'color': '#EF4444',
|
||||
'icon': 'dumbbell',
|
||||
'priority': 50,
|
||||
'frequency': {
|
||||
'type': 'specific_days',
|
||||
'days': allowed_days
|
||||
}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Attempt to check in today (wrong day)
|
||||
result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST')
|
||||
|
||||
# Verify 400 error
|
||||
assert result['status'] == 400, "Should return 400 status"
|
||||
assert 'error' in result, "Should return error message"
|
||||
|
||||
print(f"✓ Test 7: Check-in on {today_name} (not in {allowed_days}) → returns 400")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_08_get_response_includes_all_stats():
|
||||
"""Integration test: GET response includes stats (streak, completion_rate, weekly_summary)"""
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
base_url = f"http://localhost:{server.server_port}"
|
||||
|
||||
try:
|
||||
# Create habit with some completions
|
||||
habit_data = {
|
||||
'name': 'Meditation',
|
||||
'category': 'health',
|
||||
'color': '#10B981',
|
||||
'icon': 'brain',
|
||||
'priority': 50,
|
||||
'frequency': {'type': 'daily'}
|
||||
}
|
||||
|
||||
result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data)
|
||||
habit_id = result['id']
|
||||
|
||||
# Add some completions
|
||||
import api
|
||||
with open(api.HABITS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
habit_obj = next(h for h in data['habits'] if h['id'] == habit_id)
|
||||
|
||||
# Add completions for last 3 days
|
||||
for i in range(3):
|
||||
check_date = (datetime.now() - timedelta(days=2-i)).date().isoformat()
|
||||
habit_obj['completions'].append({
|
||||
'date': check_date,
|
||||
'type': 'check'
|
||||
})
|
||||
|
||||
habit_obj['streak'] = {
|
||||
'current': 3,
|
||||
'best': 3,
|
||||
'lastCheckIn': datetime.now().date().isoformat()
|
||||
}
|
||||
|
||||
data['lastUpdated'] = datetime.now().isoformat()
|
||||
with open(api.HABITS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Get habits
|
||||
habits = http_request(f"{base_url}/api/habits")
|
||||
habit = habits[0]
|
||||
|
||||
# Verify all enriched stats are present
|
||||
assert 'current_streak' in habit, "Should include current_streak"
|
||||
assert 'best_streak' in habit, "Should include best_streak"
|
||||
assert 'completion_rate_30d' in habit, "Should include completion_rate_30d"
|
||||
assert 'weekly_summary' in habit, "Should include weekly_summary"
|
||||
assert 'should_check_today' in habit, "Should include should_check_today"
|
||||
|
||||
# Verify streak values
|
||||
assert habit['current_streak'] == 3, "Current streak should be 3"
|
||||
assert habit['best_streak'] == 3, "Best streak should be 3"
|
||||
|
||||
# Verify weekly_summary structure
|
||||
assert isinstance(habit['weekly_summary'], dict), "Weekly summary should be a dict"
|
||||
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
for day in days:
|
||||
assert day in habit['weekly_summary'], f"Weekly summary should include {day}"
|
||||
|
||||
print("✓ Test 8: GET response includes all stats (streak, completion_rate, weekly_summary)")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
teardown_test_env(temp_dir)
|
||||
|
||||
|
||||
def test_09_typecheck_passes():
|
||||
"""Integration test: Typecheck passes"""
|
||||
result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/api.py')
|
||||
assert result == 0, "Typecheck should pass for api.py"
|
||||
|
||||
result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/habits_helpers.py')
|
||||
assert result == 0, "Typecheck should pass for habits_helpers.py"
|
||||
|
||||
print("✓ Test 9: Typecheck passes")
|
||||
|
||||
|
||||
# Run all tests
|
||||
if __name__ == '__main__':
|
||||
tests = [
|
||||
test_01_create_and_checkin_increments_streak,
|
||||
test_02_seven_consecutive_checkins_restore_life,
|
||||
test_03_skip_with_life_maintains_streak,
|
||||
test_04_skip_with_zero_lives_returns_400,
|
||||
test_05_edit_frequency_changes_should_check_today,
|
||||
test_06_delete_removes_habit_from_storage,
|
||||
test_07_checkin_on_wrong_day_for_specific_days_returns_400,
|
||||
test_08_get_response_includes_all_stats,
|
||||
test_09_typecheck_passes,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
print("Running integration tests...\n")
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f"✗ {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {test.__name__}: Unexpected error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Integration Tests: {passed} passed, {failed} failed")
|
||||
print(f"{'='*50}")
|
||||
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
134
dashboard/tests/test_weekly_lives_integration.py
Normal file
134
dashboard/tests/test_weekly_lives_integration.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration test for weekly lives recovery feature.
|
||||
|
||||
Tests the full flow:
|
||||
1. Habit has check-ins in previous week
|
||||
2. Check-in today triggers weekly lives recovery
|
||||
3. Response includes livesAwarded flag
|
||||
4. Lives count increases
|
||||
5. Duplicate awards are prevented
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from habits_helpers import check_and_award_weekly_lives
|
||||
|
||||
|
||||
def test_integration_weekly_lives_award():
|
||||
"""Test complete weekly lives recovery flow."""
|
||||
print("\n=== Testing Weekly Lives Recovery Integration ===\n")
|
||||
|
||||
today = datetime.now().date()
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
previous_week_start = current_week_start - timedelta(days=7)
|
||||
|
||||
# Scenario 1: New habit with check-ins in previous week
|
||||
print("Scenario 1: First award of the week")
|
||||
habit = {
|
||||
"id": "test-habit-1",
|
||||
"name": "Test Habit",
|
||||
"lives": 2,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"},
|
||||
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "check"},
|
||||
]
|
||||
}
|
||||
|
||||
new_lives, was_awarded = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded == True, "Expected life to be awarded"
|
||||
assert new_lives == 3, f"Expected 3 lives, got {new_lives}"
|
||||
print(f"✓ Lives awarded: {habit['lives']} → {new_lives}")
|
||||
print(f"✓ Award flag: {was_awarded}")
|
||||
|
||||
# Scenario 2: Already awarded this week
|
||||
print("\nScenario 2: Prevent duplicate award")
|
||||
habit['lives'] = new_lives
|
||||
habit['lastLivesAward'] = current_week_start.isoformat()
|
||||
|
||||
new_lives2, was_awarded2 = check_and_award_weekly_lives(habit)
|
||||
|
||||
assert was_awarded2 == False, "Expected no duplicate award"
|
||||
assert new_lives2 == 3, f"Lives should remain at 3, got {new_lives2}"
|
||||
print(f"✓ No duplicate award: lives remain at {new_lives2}")
|
||||
|
||||
# Scenario 3: Only skips in previous week
|
||||
print("\nScenario 3: Skips don't qualify for recovery")
|
||||
habit_with_skips = {
|
||||
"id": "test-habit-2",
|
||||
"name": "Habit with Skips",
|
||||
"lives": 1,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "skip"},
|
||||
{"date": (previous_week_start + timedelta(days=4)).isoformat(), "type": "skip"},
|
||||
]
|
||||
}
|
||||
|
||||
new_lives3, was_awarded3 = check_and_award_weekly_lives(habit_with_skips)
|
||||
|
||||
assert was_awarded3 == False, "Skips shouldn't trigger award"
|
||||
assert new_lives3 == 1, f"Lives should remain at 1, got {new_lives3}"
|
||||
print(f"✓ Skips don't count: lives remain at {new_lives3}")
|
||||
|
||||
# Scenario 4: No cap on lives (can go beyond 3)
|
||||
print("\nScenario 4: Lives can exceed 3")
|
||||
habit_many_lives = {
|
||||
"id": "test-habit-3",
|
||||
"name": "Habit with Many Lives",
|
||||
"lives": 5,
|
||||
"completions": [
|
||||
{"date": (previous_week_start + timedelta(days=2)).isoformat(), "type": "check"},
|
||||
]
|
||||
}
|
||||
|
||||
new_lives4, was_awarded4 = check_and_award_weekly_lives(habit_many_lives)
|
||||
|
||||
assert was_awarded4 == True, "Expected life to be awarded"
|
||||
assert new_lives4 == 6, f"Expected 6 lives, got {new_lives4}"
|
||||
print(f"✓ No cap: lives increased from 5 → {new_lives4}")
|
||||
|
||||
# Scenario 5: No check-ins in previous week
|
||||
print("\nScenario 5: No check-ins = no award")
|
||||
habit_no_checkins = {
|
||||
"id": "test-habit-4",
|
||||
"name": "New Habit",
|
||||
"lives": 2,
|
||||
"completions": []
|
||||
}
|
||||
|
||||
new_lives5, was_awarded5 = check_and_award_weekly_lives(habit_no_checkins)
|
||||
|
||||
assert was_awarded5 == False, "No check-ins = no award"
|
||||
assert new_lives5 == 2, f"Lives should remain at 2, got {new_lives5}"
|
||||
print(f"✓ No previous week check-ins: lives remain at {new_lives5}")
|
||||
|
||||
print("\n=== All Integration Tests Passed! ===\n")
|
||||
|
||||
# Print summary of the feature
|
||||
print("Feature Summary:")
|
||||
print("• +1 life awarded per week if habit had ≥1 check-in in previous week")
|
||||
print("• Monday-Sunday week boundaries (ISO 8601)")
|
||||
print("• Award triggers on first check-in of current week")
|
||||
print("• Skips don't count toward recovery")
|
||||
print("• No cap on lives (can accumulate beyond 3)")
|
||||
print("• Prevents duplicate awards in same week")
|
||||
print("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_integration_weekly_lives_award()
|
||||
sys.exit(0)
|
||||
except AssertionError as e:
|
||||
print(f"\n✗ Test failed: {e}\n")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n✗ Unexpected error: {type(e).__name__}: {e}\n")
|
||||
sys.exit(1)
|
||||
252
dashboard/todos.json
Normal file
252
dashboard/todos.json
Normal file
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"lastUpdated": "2026-03-25T22:59:24.849Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "prov-2026-02-25",
|
||||
"text": "Provocare: Un proiect - Pentru cine?",
|
||||
"context": "Brendan Burchard: 'Dubiul nu e problema. Oprirea e problema.' Când dubiul devine semnal să înveți (nu să te oprești), câștigi. Problema ta nu e competența (25 ani expertiză) - e TEAMA de primul pas. Credința 'clienți noi = mai multă muncă' te blochează să vezi dincolo de poveste. Adevărul: fiecare lucru pe care îl eviți îți arată EXACT unde trebuie să mergi. În business de ARTĂ (expertiza unică), scaling-ul vine prin CLARITATE despre valoare, nu volum. Problema nu e că nu ai clienți - e că nu știi pentru cine lupți. Când Brendan și-a terminat cartea în 18 zile (după ani de blocaj), nu a fost pentru bani - a fost pentru SOȚIA lui dormind sub greutatea facturilor. Schimbarea: de la 'cum supraviețuiesc' la 'pentru cine lupt'. Proiectele tale rămân 80% done pentru că le lipsește CONVICTION - nu e 'ar fi bine' ci 'TREBUIE pentru cineva anume'. Întrebarea e: 'Pentru cine fac asta?'",
|
||||
"example": "Alege UN proiect (ROA web, chatbot Maria, angajat nou, orice activ) și răspunde SINCER: 'Dacă aș renunța la asta mâine, cine ar pierde?' Dacă răspunsul e 'Nimeni specific' sau 'Ar fi util general' → e half-hearted. Fie oprești proiectul (temporar), fie găsești conviction real (cineva anume). Dacă răspunsul e 'Clientul X care depinde de rapoarte rapide' sau 'Colegă 70 ani care vrea autonomie' → e full conviction. Continuă. Nu trebuie să FACI nimic cu răspunsul - doar să îl VEZI. Exemplu ROA web: Dacă renunț mâine → cine pierde? Răspuns vag: 'Clienții ar beneficia' = half-hearted. Răspuns concret: 'Clientul Y sună de 5 ori/săptămână pentru raport X. Dacă ar avea web, și-ar lua singur' = conviction. Când vezi clar CINE beneficiază, primul pas devine natural. Dubiul nu dispare prin planuri perfecte - dispare prin primul pas, oricât de mic. Primul pas: 5 minute, un proiect, o întrebare, VEZI adevărul.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-25",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:21.977Z",
|
||||
"source": "Brendan Burchard - Billionaire Coach (Conviction vs Half-heartedness)",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-23_billionaire-coach-abundance-mindset.md",
|
||||
"createdAt": "2026-02-25T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-24",
|
||||
"text": "Provocare: Audit Conviction - identifică proiecte half-hearted vs full",
|
||||
"context": "Half-heartedness = cel mai mare inamic al abundenței. Nu poți construi afacere, relație sau viață cu un picior înăuntru și unul afară. Brendan Burchard: 'Breakthroughul vine când lupți pentru ALTCINEVA, nu pentru supraviețuire.' Diferența: Supraviețuire = 'Cum plătesc factura?' (umpli un GOL). Abundență = 'Cui servesc cu expertiza asta?' (construiești). Wealthy people nu se gândesc la supraviețuire - se gândesc la servire, dare, construire. Când un proiect e half-hearted ('ar fi bine'), rămâne 80% done, momentum pierdut. Când e full conviction (PENTRU CINEVA anume), livrare completă, flow în loc de greutate. Exercițiul te ajută să identifici CE e cu conviction reală și CE e doar 'ar fi util'.",
|
||||
"example": "Listează proiectele curente (ROA web, Chatbot Maria, Angajat nou, Clienți noi) și pentru fiecare răspunde: E full conviction (PENTRU CINE?) sau half-hearted (ar fi bine)? De exemplu: ROA web - dacă răspunsul e 'ar fi util pentru clienți' (vag) = half-hearted. Dacă răspunsul e 'Clientul X TREBUIE să aibă acces rapid la rapoarte pentru a lua decizii la timp' (specific, cineva anume) = full conviction. Când identifici unul half-hearted, reframe-ul: NU 'ce câștig EU?' ci 'CINE beneficiază când asta e complet?' Bonus ZAPS antidot: când apare dubiul 'Nu sunt destul de deștept' (attach self) → STOP, recunoaște 'Mă ZAPS-ez?', reframe 'Ce învăț din asta?' (nu 'Mă opresc'), reset BMF (Breath 3 respirații + Movement 10 pași + Food check). Brendan: 'Doubt is not the problem. Stopping is. If doubt is a signal to learn — you win.'",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-24",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:13.743Z",
|
||||
"source": "Brendan Burchard - Billionaire Coach (Abundență vs Supraviețuire)",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-23_billionaire-coach-abundance-mindset.md",
|
||||
"createdAt": "2026-02-24T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-23",
|
||||
"text": "Provocare: Identifică tipul de business - ARTĂ sau LIFESTYLE?",
|
||||
"context": "Greșeala majoră: aplici regulile greșite pentru tipul tău de business. Monica Ion: 'Când nu știi tipul de business, e ca și cum nu știi ce boală tratezi. Orice medicament poate face mai mult rău decât bine.' Există 4 tipuri (Artă, Lifestyle, Exit, Legacy) - fiecare cu scop și reguli diferite. Succesul vine din a te cunoaște pe tine și a juca după regulile tipului tău. TOATE blocajele tale (clienți noi=mai multă muncă, prețuri scăzute, angajat greu de învățat) vin din CONFUZIE DE TIP. Dacă e ARTĂ: creștere personală + prețuri mai mari (NU mai mulți clienți). Dacă e LIFESTYLE: sisteme eficiente + documentare procese. Testul rapid: Clienții vin pentru TINE (expertiza unică) sau pentru PROCES (rezultate predictibile)? Proiectele sunt personalizate sau pattern repetabil?",
|
||||
"example": "Scenariul: Ar trebui să cauți clienți noi dar eziti ('mai multă muncă'). ARTĂ: greșit să adaugi clienți - soluția e să CREȘTI PREȚURILE pentru clienții existenți și să SELECTEZI doar cei premium. Angajatul e suport operațional (nu clone al tău). Un client perfect e mai bun decât 5 obișnuiți. LIFESTYLE: corect că e mai multă muncă - ai nevoie de SISTEME mai eficiente. Angajatul învață PROCESUL (nu expertiza ta). Documentezi proceduri standard. Sau: Nu îndrăznești să crești prețurile. ARTĂ: blocare interioară (vină/rușine/merit scăzut) - muncă pe curățenie emoțională, apoi creștere prețuri 2-3x. LIFESTYLE: nu știi numerele - calculează break-even real (ore + cheltuieli + profit motivant) și setează preț matematic.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-23",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:14.522Z",
|
||||
"source": "Monica Ion - Cele 4 tipuri de business",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-19_cele-4-tipuri-de-business.md",
|
||||
"createdAt": "2026-02-23T07:04:14.171922+00:00"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-22",
|
||||
"text": "Provocare: Schimbă corpul ÎNAINTE de decizie - fiziologie pentru acțiune",
|
||||
"context": "Inacțiunea antreprenorială nu e în minte - e în CORP. Corpul ghemuire (umeri căzuți, respirație superficială) comunică: 'Nu sunt suficient. E periculos să ies.' Și mintea urmează corpul. Tony Robbins: 'Depresia are o postură. Schimbă corpul PRIMUL — mișcă-te, respiră diferit.' Corpul GENEREAZĂ starea, nu o reflectă. Când aștepți să te simți 'pregătit' pentru a acționa — corpul spune: 'Nu suntem acolo încă.' Când acționezi CU CORPUL ÎNTÂI (miști, respiri, te ridici) — starea vine DUPĂ. Nu aștepți încredere - o CREEZI cu fiziologia.",
|
||||
"example": "Scenariul: Ar trebui să suni un client nou pentru un proiect mai mare. Simți ezitare: 'E prea scump, poate zice nu...' VECHIUL MOD: Stai la birou, gândești, analizezi, amâni. NOUL MOD: (1) Simți ezitarea → ridică-te imediat (2) 3x pe vârfuri (activează corpul) (3) 5 respirații profunde în piept (deschide corp, încredere) (4) 10 pași rapizi prin cameră (5) ACUM suni clientul - cu corp deschis, respirație plină. REZULTAT: Același gând ('poate zice nu'), dar corp diferit = emoție diferită = acțiune.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-22",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:15.239Z",
|
||||
"source": "Tony Robbins - The Secret to an Extraordinary Life",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md",
|
||||
"createdAt": "2026-02-22T07:03:01.936301Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-21",
|
||||
"text": "Provocare: Ce s-ar schimba în TINE dacă ai vedea clar valoarea ta?",
|
||||
"context": "Rezistența la 'dovezi concrete' = frica de puterea ta reală. Mintea preferă credința familiară ('nu sunt destul de deștept') în locul evidenței incomode ('am rezolvat sute de probleme complexe'). De ce? Pentru că dacă vezi dovezile și ÎNCĂ nu acționezi (să cauți clienți noi, să crești prețurile) - atunci nu mai poți da vina pe 'nu știu destul'. Și asta doare mai tare. Când începi cu 'ce s-ar schimba în mine?' în loc de 'ce dovezi am?', ocolești rezistența identitară. Nu mai e despre DOVADA externă (care activează frica: 'dacă știu și nu acționez = cine sunt eu?'). E despre VIZIUNE internă: cine vrei să fii? Și când vezi clar cine vrei să fii - dovezile devin INSTRUMENTE, nu AMENINȚĂRI.",
|
||||
"example": "De exemplu: Dacă ai vedea clar că ai expertiza reală (25 ani, sute de probleme rezolvate), cum ai RESPIRA când intri într-o conversație cu un client nou? Ai sta mai drept? Ai vorbi mai calm? Ai asculta mai atent sau ai explica mai convingător? Nu e despre CE ai face (cerut preț mai mare), ci despre CINE ai fi în acel moment. Poate ai descoperi: 'Aș respira mai ușor. Nu aș mai simți nevoia să-mi dovedesc valoarea - aș OFERI valoarea cu încredere liniștită.' Și când vezi asta - scrisul celor 3 dovezi concrete devine natural, nu o amenințare.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-21",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:23.303Z",
|
||||
"source": "Coaching seară 20 feb + Friday Spark #95 People Pleasing",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-21-dimineata.md",
|
||||
"createdAt": "2026-02-21T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-20",
|
||||
"text": "Provocare: Identifică 3 dovezi concrete de încredere - probleme complexe rezolvate",
|
||||
"context": "Încrederea în sine nu vine din gândire pozitivă sau autosugestie. Vine din valoare demonstrată prin experiență și rezultate. Îndoielile tale ('nu sunt destul de deștept ca antreprenor') ignoră 25 de ani de dovezi concrete. Pentru a le demonta, trebuie să identifici exact CE ai ȘTIUT, CE ai ȘTIUT SĂ FACI și CE REZULTATE ai OBȚINUT în situații reale. Când vezi dovezile concrete, îndoielile se dizolvă natural - nu prin forțare, ci prin evidență.",
|
||||
"example": "De exemplu: client care avea probleme cu sincronizarea datelor între două sisteme. Ai analizat problema (CE ȘTIU: arhitectură bază de date, Oracle triggers), ai creat o soluție customizată (CE ȘTIU SĂ FAC: scripturi PL/SQL, testare în producție), clientul a economisit 20 ore/săptămână de lucru manual (CE REZULTAT). Asta e dovada concretă - nu teorie, ci fapte. Când ai 3 astfel de dovezi recente în față, credința 'nu sunt destul de deștept' devine absurdă în fața evidenței.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-20",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:19.095Z",
|
||||
"source": "Zoltan Vereș - Încrederea în Sine + Monica Ion - Cele 4 tipuri de business",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-20-dimineata.md",
|
||||
"createdAt": "2026-02-20T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-16",
|
||||
"text": "Provocare: Metoda 3M - pune angajatul sa scrie 5 keywords dupa explicatie",
|
||||
"context": "La prima explicatie pe care i-o dai angajatului azi, opreste-te si spune: 'Acum scrie in 5 keywords ce ai inteles.' NU corecta imediat. Lasa-l sa greseasca. Apoi discutati diferentele. Creierul care ghiceste RETINE. Cel care copiaza UITA. Trei principii: Make it Wrong (ghiceste, nu copia), Make it Shorter (keywords, nu propozitii), Make it Again (reorganizeaza, nu rescrie). Metoda transforma explicatiile repetitive in invatare activa - nu mai 'pierzi timp', il pui sa-si construiasca propria intelegere.",
|
||||
"example": "Explici angajatului cum sa faca o procedura de facturare in ROA. In loc sa repeti de 3 ori pana memoreaza mecanic, dupa prima explicatie ii spui: 'Scrie 5 cuvinte cheie din ce ai inteles.' El scrie: 'client, factura, TVA, salvare, print'. Tu vezi ca lipseste 'validare ANAF' - asta e gap-ul real. Discutati 2 minute pe gap, nu repeți totul. A doua zi, ii ceri sa reorganizeze notitele de ieri din memorie. Ce uita = ce nu a integrat. Metoda e aplicabila si pentru tine cu NLP: dupa modul, redeseneaza harta mentala din memorie, nu din notite.",
|
||||
"domain": "work",
|
||||
"dueDate": "2026-02-16",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:24.238Z",
|
||||
"source": "Thinking on Paper - 3 principii pentru retentie",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/thinking-on-paper.md",
|
||||
"createdAt": "2026-02-16T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-15",
|
||||
"text": "Provocare: Reframe Mentorship - ce ai inteles TU din ultima explicatie data angajatului?",
|
||||
"context": "Gandeste-te la ULTIMA explicatie pe care i-ai dat-o angajatului. Ce ai inteles TU mai bine despre propriul proces datorita acelei explicatii? Fiecare explicatie te forteaza sa-ti clarifici procesul - nu doar lui ii predai, tie iti reconstruiesti fundamentul. Dupa 25 de ani pe pilot automat, cand cineva intreaba 'de ce?', redescoperi logica din spatele deciziilor. Si uneori descoperi ca unele decizii nu mai au logica. Asta e aur.",
|
||||
"example": "Angajatul intreaba: 'De ce facem backup-ul asa si nu altfel?' Tu incepi sa explici si realizezi ca metoda e din 2010, cand aveai alta structura de date. Acum ar fi mai simplu cu un script automat. Fara intrebarea lui, ai fi continuat pe pilot automat inca 5 ani. Sau: explici cum functioneaza facturarea in ROA si realizezi ca 3 pasi ar putea fi 1. Angajatul nu pierde timp - el iti face audit gratuit la procese.",
|
||||
"domain": "work",
|
||||
"dueDate": "2026-02-15",
|
||||
"done": true,
|
||||
"doneAt": "2026-03-25T22:59:24.849Z",
|
||||
"source": "InfoWorld - Why We Need Junior Developers",
|
||||
"sourceUrl": "https://www.infoworld.com/article/4065771/why-we-need-junior-developers.html",
|
||||
"createdAt": "2026-02-15T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-14",
|
||||
"text": "Provocare: Echilibrarea unui Conflict Interior - găsește un sau-sau și echilibrează-l",
|
||||
"context": "Găsește UN 'sau-sau' din viața ta — două lucruri pe care le consideri incompatibile. (1) Scrie conflictul: 'Sau sunt X, sau sunt Y'. (2) Pentru fiecare parte, găsește opusul simultan: Când ești X, cum ești deja și Y? (dovezi concrete). Când ești Y, cum ești deja și X? (dovezi concrete). (3) Observă: Când ambele sunt adevărate simultan, ce simți? Nu trebuie să rezolvi nimic — doar să vezi că cele două nu sunt incompatibile, sunt complementare. Metoda Demartini: echilibrezi percepția, nu elimini josurile.",
|
||||
"example": "Conflictul tău real: 'Sau sunt programator bun, sau sunt antreprenor.' Echilibrare: Când ești programator — deja faci antreprenoriat (ai firmă, negociezi cu clienți, iei decizii de business zilnic, ai angajat pe care îl formezi). Când ești antreprenor — deja folosești mintea tehnică (automatizezi, optimizezi, rezolvi probleme sistemic). Dovada: de 25 de ani faci AMBELE simultan. Doar percepția zice că una o exclude pe cealaltă.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-14",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-14T08:27:56.118Z",
|
||||
"source": "Monica Ion - Povestea lui Marc Ep.9 (Anxietatea, frica de control și pierdere)",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/monica-ion-povestea-lui-marc-ep9-anxietatea.md",
|
||||
"createdAt": "2026-02-14T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-13",
|
||||
"text": "Provocare: Linkage Personal - conectează o activitate evitată cu calitățile tale",
|
||||
"context": "Alege o activitate pe care o eviți (telefon client, conversație angajat, decizie amânată). Scrie TU răspunsurile (NU cere AI-ului): (1) Cum servește această activitate lucrul pe care îl fac cel mai bine? (2) Ce calitate a mea folosesc deja identic în altă parte? (3) Ce simt în corp când imaginez că am terminat-o? Dacă rezistența scade după răspunsuri → ai găsit linkage-ul. Dacă nu scade → poate nu e activitatea ta, și asta e valid. Ideea: mintea trebuie să FACĂ munca de conectare, nu să o citească.",
|
||||
"example": "Activitate evitată: emiterea facturii imediat după prestare. Linkage descoperit de Mark: facturarea = finalizare proces complet (ca în soluțiile tehnice: funcționează sau e teorie). Gândire structurată, logică, ordonată — IDENTICĂ cu rezolvarea problemelor tehnice. Rezultat: rezistența a dispărut complet, acțiunea curgea natural. La tine: poate suni un client — linkage: rezolvi probleme tehnice = oferi valoare = clientul te vrea. Soluția tehnica NU se termină când funcționează codul — se termină când clientul o folosește.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-13",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-13T13:03:30.654Z",
|
||||
"source": "Monica Ion - Povestea lui Marc Ep.8 (Mândria și identitatea personală)",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-12_monica-ion-povestea-lui-marc-ep8.md",
|
||||
"createdAt": "2026-02-13T09:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-12",
|
||||
"text": "Provocare: Primul Pas Minim (PPM) - alege idee și execută în MAX 10 min",
|
||||
"context": "Regula PPM: Orice idee pe care o ai astăzi → identifică primul pas care: (1) Durează MAX 10 minute (2) NU necesită alte persoane (3) E CONCRET (nu 'mă gândesc', ci 'scriu', 'sun', 'trimit', 'creez'). La prima pauză (10:00-11:00): Alege UNA din ideile tale recente, identifică PPM-ul, execută-l chiar dacă nu e perfect. La 17:00 notează: Ce idee? Care PPM? L-am executat? Dacă DA: cum mă simt, următorul pas? Dacă NU: ce m-a oprit, ce PPM MAI MIC mâine?",
|
||||
"example": "Exemplu concret: Ideea 'ar trebui să am task brief template pentru angajat'. PPM greu: 'Creez template complet cu toate secțiunile, testez, ajustez...' PPM SIMPLU: 'Deschid fișier task-brief-template.md și scriu primele 3 secțiuni (Task, Input, Output) în 10 minute'. Sau ideea 'trebuie să documentez soluții probleme clienți'. PPM: 'Creez folder memory/kb/roa/probleme-frecvente/ și scriu PRIMA problemă rezolvată recent în 10 minute'. Cel mai greu pas e PRIMUL - după ce ai început, creierul intră în flow mode.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-12",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-12T12:07:04.068Z",
|
||||
"source": "Multi-Agent Pattern + Living Files Theory + Context Engineering",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-12-dimineata.md",
|
||||
"createdAt": "2026-02-12T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-11",
|
||||
"text": "Provocare: Identifică un task pe care îl execuți singur și ar putea fi orchestrat",
|
||||
"context": "Alege UNA din variantele: (1) Delegat la angajat - task repetitiv pe care îl faci de 10 ori și ar putea învăța? (2) Automatizat cu Echo - verificare/raport/backup care rulează manual? (3) Modelat de la colegă - proces pe care ea îl face excelent și tu îl faci mai greu? (4) Documentat pentru viitor - explicație pe care o repeți la fiecare client nou? La 17:00 notează: Ce task? Cum ar arăta orchestrat? Primul pas minim pentru orchestrare? Nu implementa imediat - doar identifică și scrie. Conștientizarea e primul pas.",
|
||||
"example": "Exemple reale: (1) Explicația cum să adauge client nou în ROA - ai făcut-o de 10 ori la angajat, ar putea fi screencast + checklist. (2) Verificarea zilnică backups - rulează manual, ar putea fi script Echo automat cu alertă doar dacă fail. (3) Suportul tehnic calm - colega face excelent, tu mai nervos, ar putea cere să te învețe procesul TOTE intern. (4) Setup ANAF pentru client nou - repeți aceiași pași, ar putea fi documentație step-by-step pe care Echo o trimite automat.",
|
||||
"domain": "work",
|
||||
"dueDate": "2026-02-11",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-11T16:39:39.457Z",
|
||||
"source": "Claude Code Multi-Agent Orchestration + TDi Mindset Entrepreneurship",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-11-dimineata.md",
|
||||
"createdAt": "2026-02-11T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-10",
|
||||
"text": "Provocare: Body Loose, Head Clear - verifică corpul înainte de situație tensionată",
|
||||
"context": "Alege UN moment când anticipezi o situație tensionată (conversație cu angajatul, gândire la proiect, task dificil). ÎNAINTE să o rezolvi: (1) Verifică corpul: Umeri sus sau jos? Maxilar strâns sau relaxat? Respirație scurtă sau adâncă? (2) Unknot yourself: 3 respirații 4-7-8 (inspiră 4 sec, ține 7, expiră 8) + relaxează conștient zona tensionată (3) Apoi acționează: Rezolvă cu 'body loose, head clear' (4) Seara notează: Diferență față de cum rezolvi de obicei?",
|
||||
"example": "Angajatul întreabă din nou același lucru. În loc să simți frustrarea creștând în piept și să răspunzi strâns → observi tensiunea, faci 3 respirații, APOI răspunzi (sau îl trimiți la documentație, sau spui 'discutăm mâine'). Mesajul e același, dar tu nu acumulezi durere.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-10",
|
||||
"done": true,
|
||||
"source": "James Clear - 3-2-1 Newsletter (Body Loose, Head Clear) + Monica Ion - Pattern Sacrificiu-Durere-Sabotaj",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-09-seara.md",
|
||||
"createdAt": "2026-02-09T19:00:00.000Z",
|
||||
"doneAt": "2026-02-11T16:39:37.436Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-08",
|
||||
"text": "Provocare: Aplică 1 tehnică din NLP ASTĂZI, notează experiența",
|
||||
"context": "Alege UNA tehnică/concept din training-ul de astăzi și APLICĂ-L IMEDIAT în aceeași zi, la un moment REAL (exercițiu, conversație, blocare, emoție). La final de zi, scrie NU 'ce am învățat' (concepte) ci 'ce am APLICAT și ce s-a întâmplat' (experiență). Mintea învață prin experiență repetată, nu prin concepte teoretice. Cum înveți în training = cum vei aplica în viață. Dacă înveți prin note și 'mai târziu' → vei aplica exact așa acasă (niciodată). Dacă înveți prin aplicare instant → vei aplica exact așa acasă (automat).",
|
||||
"example": "Scenariul tău real: Într-un exercițiu NLP, partenerul te blochează sau critică. În loc să rămâi în defensivă ('e greu') → aplici pattern interrupt din Tony Robbins: observi fiziologia (umeri contractați?), schimbi focusul (ce pot învăța despre cum reacționez?), schimbi limbajul ('e provocator' în loc de 'e greu'). Exercițiul devine mirror pentru tiparele tale în relații/business - exact cum reacționezi când angajatul nu înțelege sau când clientul critică.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-08",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-08T14:32:35.511Z",
|
||||
"source": "Tony Robbins - The Secret to an Extraordinary Life + Monica Ion - Legea Fractalilor",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-08-dimineata.md",
|
||||
"createdAt": "2026-02-08T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-07",
|
||||
"text": "Provocare: Închide o buclă - ce ai dat DEJA + decizie clară",
|
||||
"context": "Notează UNA buclă deschisă din viața ta - orice \"ar trebui să...\" dar nu faci. Răspunde la 3 întrebări: (1) Ce am dat DEJA în schimb (în alte forme)? (2) Ce dezavantaje ar fi fost dacă rezolvam altfel? (3) Ce decizie clară iau ACUM: fie fac cu plan+dată, fie accept că NU fac. Când bucla se închide (prin percepție sau decizie), mintea se eliberează și vezi oportunități.",
|
||||
"example": "Buclă: \"Ar trebui să caut clienți noi\". (1) Ce am dat: clienților actuali - suport 24/7, know-how 25 ani, disponibilitate. (2) Dezavantaje dacă găseam 10 acum: angajat nepregătit, echipă suprasolicită, burnout. (3) Decizie: ACCEPT că nu caut clienți noi PÂNĂ în martie când angajatul e autonom. Plan: martie = 1 apel/săptămână. Bucla închisă → energie liberă.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-07",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-07T19:32:23.501Z",
|
||||
"source": "Monica Ion - Povestea lui Marc Episod #5",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-07_monica-ion-povestea-lui-marc-ep5-datorie-familie.md",
|
||||
"createdAt": "2026-02-07T07:03:19.909Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-06",
|
||||
"text": "Provocare: Observă 1 aliniere + 1 fricțiune - ce îți spun despre tine?",
|
||||
"context": "Observă azi UN moment când te simți energizat (aliniere) și UN moment când ești tras înapoi (fricțiune). Pentru fiecare notează: ce activitate, ce caracteristică (creativitate? rezolvare probleme? conexiune? vs repetitivitate? teamă de judecată?). Nu trebuie să faci nimic cu observațiile - doar să le vezi. Corpul știe adevărul înainte ca mintea să-l articuleze.",
|
||||
"example": "Aliniere: Când automatizezi ceva și simți satisfacție - observi că e creativitatea și controlul care te energizează. Fricțiune: Când amâni să suni un client nou - observi că nu e competența (știi să vorbești), ci teama de respingere. Pattern-ul arată: vrei autonomie creativă, nu vânzare agresivă.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-06",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-06T13:46:00.687Z",
|
||||
"source": "Coaching Dimineață - Pattern-uri de Auto-Cunoaștere",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-06-dimineata.md",
|
||||
"createdAt": "2026-02-06T07:02:00.666161"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-05",
|
||||
"text": "Provocare: Vizualizare Prospecting - sună un client potențial (5 min)",
|
||||
"context": "Alege UN client potențial real. Găsește o amintire cu client entuziasmat. Vizualizează: tu suni, el răspunde, pui propunerea, el zice 'Sună bine'. Sparge imaginea - prin fissură vezi entuziasmul din amintirea reală. Repetă 2-3 ori. Apoi sun-l azi sau mâine (sau cel puțin prepară motivul).",
|
||||
"example": "Client potențial: X care ar fi perfect dar zici 'dar...'. Amintire: momentul când clientul A a zis 'da'. Vizualizezi: suni, răspunde, pui propunerea, el: 'Sună bine'. Apoi suni pe X.",
|
||||
"domain": "work",
|
||||
"dueDate": "2026-02-05",
|
||||
"done": true,
|
||||
"source": "Gândul de Seară - NLP Prospecting",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-05-seara.md",
|
||||
"createdAt": "2026-02-05T19:00:00.000Z",
|
||||
"doneAt": "2026-02-06T13:45:58.234Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-04",
|
||||
"text": "Provocare: Vizualizare NLP - transferă motivația (5 min)",
|
||||
"context": "Alege O acțiune pe care o tot amâni. Găsește o amintire cu plăcere intensă (vacanță, succes, flow). Vizualizează amintirea luminoasă și caldă. Pune acțiunea amânată în față. 'Sparge' imaginea - vezi plăcerea prin fissură. Închide. Repetă de 2 ori. Observă schimbarea emoțională.",
|
||||
"example": "Acțiunea: să trimiți un email de prospecting către un potențial client. Amintirea: momentul când ai terminat un proiect mare și clientul era entuziasmat. Când 'spargi' imaginea și vezi entuziasmul din spate, creierul începe să asocieze email-ul cu acel sentiment de succes.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-04",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-04T14:38:17.505Z",
|
||||
"source": "Meditație NLP - Vizualizare pentru Motivație",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/projects/grup-sprijin/biblioteca/meditatie-vizualizare-motivatie.md",
|
||||
"createdAt": "2026-02-04T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "prov-2026-02-03",
|
||||
"text": "Provocare: Răspunde la una din întrebări despre umbrele tale (3 min)",
|
||||
"context": "Alege UNA din aceste întrebări și scrie răspunsul pe hârtie sau în telefon: 1) Ce complimente refuzi sau minimizezi? 2) Ce ai face dacă nu te-ar judeca nimeni? 3) Ce te irită la alții? Nu trebuie să faci nimic cu răspunsul - doar să-l vezi. Umbrele consumă energie să le ținem ascunse.",
|
||||
"example": "Exemplu de umbră: 'Nu mă consider destul de deștept ca antreprenor' - asta e o parte pe care o ascunzi. Când o accepți ('ok, am și limite'), eliberezi energia pe care o consumi să o maschezi cu scuze sau evitare.",
|
||||
"domain": "self",
|
||||
"dueDate": "2026-02-03",
|
||||
"done": true,
|
||||
"doneAt": "2026-02-03T21:16:13.452Z",
|
||||
"source": "Zoltan Vereș - Umbrele Workshop",
|
||||
"sourceUrl": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-02_zoltan-veres-umbrele-workshop-complet.md",
|
||||
"createdAt": "2026-02-03T07:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
dashboard/videos
Symbolic link
1
dashboard/videos
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/moltbot/videos
|
||||
2404
dashboard/workspace.html
Normal file
2404
dashboard/workspace.html
Normal file
File diff suppressed because it is too large
Load Diff
1
dashboard/youtube-notes
Symbolic link
1
dashboard/youtube-notes
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/moltbot/echo-core/memory/kb/youtube
|
||||
113
docs/okf-navigation-plan.md
Normal file
113
docs/okf-navigation-plan.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Plan: Navigation layer pentru memoria agentului (OKF-inspired)
|
||||
|
||||
Sursă: analiza notei `memory/kb/youtube/2026-06-27_google-open-knowledge-format.md`
|
||||
+ test empiric pe KB-ul real (151 note youtube, 581 note total).
|
||||
|
||||
## Context / problemă
|
||||
|
||||
Agentul Echo caută în memorie DOAR prin RAG (`src/memory_search.py`: Ollama
|
||||
`all-minilm` 384-dim + cosine scan în SQLite). CLAUDE.md îl declară "single
|
||||
source of truth". Test empiric: RAG ratează nota relevantă când query-ul e
|
||||
parafrazat conceptual (ex. "cum organizez un KB pt agenți să folosească mai
|
||||
puțini tokens" → nota OKF nu apare în top-8). `memory/kb/index.json` există
|
||||
(581 note, regenerat azi) dar e consumat DOAR de dashboard-ul web (căi
|
||||
`notes-data/`), nu de agent, și are 84k tokens. Există un orfan stale
|
||||
`kb/youtube/index.json` (8/151 note, 5 luni vechime).
|
||||
|
||||
## Obiectiv
|
||||
|
||||
Dă agentului un strat de navigare ieftin și robust care completează RAG-ul
|
||||
(nu îl înlocuiește), prinde parafrazele pe care embeddings le ratează, și
|
||||
merge ca fallback când Ollama remote pică.
|
||||
|
||||
## Recomandări (scope propus)
|
||||
|
||||
### R1 — Șterge orfanul `kb/youtube/index.json`
|
||||
Stale din 30 ian (8/151 note). Capcana "index învechit > lipsă index".
|
||||
Efort: trivial.
|
||||
|
||||
### R2 — Generează `index.md` slim per-folder, auto
|
||||
Extinde `tools/update_notes_index.py` să emită, pe lângă `index.json`, un
|
||||
`index.md` per subfolder kb/ (title + descriere 1 rând + tags). Pilot dovedit:
|
||||
youtube/ index.md = 11k tokens vs 259k (citit tot, 24×) vs 84k (index global,
|
||||
7.7×). Capcană: scriptul scanează `*.md` recursiv → trebuie să excludă
|
||||
explicit `index.md` ca să nu-l trateze ca notă (poluează index.json).
|
||||
Regenerat din heartbeat.py la fiecare notă nouă.
|
||||
|
||||
### R3 — Expune navigarea agentului (hibrid cu RAG)
|
||||
La `memory_search`, încarcă întâi index.md slim al folderului-țintă pe lângă
|
||||
top-k din RAG, și combină. Prinde și parafraza, și keyword-ul. Instrucțiune în
|
||||
CLAUDE.md cum să folosească indexul.
|
||||
|
||||
### R4 — Tratează Ollama remote ca SPOF
|
||||
RAG depinde de host remote (`10.0.20.161:11434`). Dacă pică, `search()` aruncă
|
||||
ConnectionError → memoria agentului dispare. index.md per-folder = fallback
|
||||
fără Ollama. Adaugă degradare grațioasă în memory_search.search().
|
||||
|
||||
### R5 — NU face conversie big-bang la YAML front matter
|
||||
Doar 6/586 note au YAML; update_notes_index.py extrage deja metadata din
|
||||
convenția `**Tags:**`/`**Data:**`. Standardizează doar de-acum în template-ul
|
||||
de notă nouă.
|
||||
|
||||
### R6 — Corectează nota OKF
|
||||
Marchează "Google a lansat OKF" ca neverificat (o sursă YouTube; se confundă
|
||||
cu Open Knowledge Foundation). Actualizează "Relevanță": nu lipsesc indexuri,
|
||||
lipsește un index navigabil EXPUS agentului.
|
||||
|
||||
## NU în scope
|
||||
- Vizualizare HTML graph a KB-ului (deprioritizat, efort mare/valoare mică).
|
||||
- Înlocuirea RAG cu navigare pură (hibrid, nu substituție).
|
||||
- Migrare ANN/vector-ext pentru viteza RAG (separat).
|
||||
|
||||
---
|
||||
<!-- /autoplan review report -->
|
||||
# GSTACK REVIEW REPORT (/autoplan)
|
||||
|
||||
Voices: Claude subagent only — **codex missing** on this host (all phases `[subagent-only]`).
|
||||
Phases run: CEO, Eng, DX. Design **skipped** (no UI scope — HTML viz is out of scope).
|
||||
|
||||
## Cross-phase themes (flagged independently in 2-3 phases = high confidence)
|
||||
|
||||
| Theme | Phases | Severity |
|
||||
|---|---|---|
|
||||
| **T1 — R3 routing is undefined.** "Load the target folder's index.md" requires already knowing the folder — that IS the navigation problem. The 11k figure holds only for youtube alone; loading all 13 folders ≈ 43-84k, erasing the win. | CEO, Eng, DX | CRITICAL |
|
||||
| **T2 — Wrong consumer.** The autonomous agent (Claude CLI in heartbeat.py) has filesystem access and never calls `search()`. Wiring R3 into `memory_search.search()` only changes the human `/search` command, not the agent. | Eng, DX | HIGH |
|
||||
| **T3 — Staleness trap recreated.** R1 deletes a stale index (proof these rot). R2 creates 13+ new generated artifacts triggered only on *new note*, not edits → silent drift. | CEO, Eng, DX | HIGH |
|
||||
| **T4 — Self-pollution into RAG.** `memory_search.reindex()/incremental_index()` do `rglob("*.md")` with no exclusion → index.md gets embedded and returned as fake "notes" in top-k. (Plan only flagged the index.json pollution, missed the RAG DB one.) | Eng | HIGH |
|
||||
| **T5 — Token win vs strawman baseline.** Comparison is against "read all 259k" (nobody does that). Real baseline = RAG top-k (~1-3k tokens). Against that, index.md is *more* tokens, justified only by recall. | CEO | HIGH |
|
||||
| **T6 — Cheaper alternatives unexamined.** `init_config` already supports `ollama.model`/`embedding_dim` → swapping all-minilm(384) for nomic/bge + reindex is a one-line change. Plus likely chunk-dedup recall bug, plus SQLite FTS5 hybrid (no new infra). All target "RAG misses paraphrases" directly. | CEO | CRITICAL |
|
||||
| **T7 — R4 is the one sound, decoupled item.** `search()` raises ConnectionError on Ollama outage with no fallback (real SPOF). Ship independently. BUT it's a breaking contract change (existing tests assert it raises). | CEO, Eng, DX | keep |
|
||||
|
||||
## CEO consensus (subagent-only)
|
||||
- Right problem? **DISAGREE w/ plan** — likely weak embedding model + chunk-dedup bug, not missing navigation.
|
||||
- Premises stated? **No** — one query is not enough evidence; token win is vanity baseline.
|
||||
- 6-month regret: 3 parallel stale metadata copies (SQLite, index.json, index.md).
|
||||
- Alternatives explored? **No** — BM25/FTS5 hybrid, reranker, better embedder never compared.
|
||||
- Prior art: OKF unverified/possibly nonexistent; bespoke format = zero portability gain.
|
||||
|
||||
## Eng consensus (subagent-only)
|
||||
- Architecture: R3 unbuildable as written (no folder signal into `search()`). R2-in-update_notes_index acceptable reuse but keep separated from `notes-data/` rewriting.
|
||||
- Edge cases: T4 self-pollution; heartbeat mtime thrash; `projects/` (236 notes, nested) breaks flat per-folder assumption.
|
||||
- Tests: R4 breaks `search()` contract — existing tests assert raise; need rewrite + new coverage for R2/R3/T4.
|
||||
|
||||
## DX consensus (subagent-only)
|
||||
- Discoverability: CLAUDE.md:138 calls RAG "single source of truth" — a soft new instruction loses to it; agent keeps defaulting to RAG.
|
||||
- Human workflow: edit-without-new-file → silent index.md drift.
|
||||
- Degradation signal (R4): must return `mode="degraded_navigation_only"` + tell user, never silent.
|
||||
- Latent bug to fix first: `update_notes_index.py:244` references `n['subcategory']`, a key never set (extractor sets `project`).
|
||||
|
||||
## Decision Audit Trail
|
||||
|
||||
| # | Phase | Decision | Class | Principle | Rationale |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Eng | Add `index.md` exclusion to BOTH update_notes_index scan AND memory_search rglob (reindex/incremental) | Mechanical | P1 completeness | T4 is silent corruption; non-negotiable IF R2 ships |
|
||||
| 2 | Eng | R4 split from R2/R3, shipped standalone | Mechanical | P6 action | Highest value/lowest risk, no dependency |
|
||||
| 3 | DX | R4 returns structured degraded mode + user signal, not silent | Mechanical | P1 | Silent shallow results worse than error |
|
||||
| 4 | CEO/DX | R3 (hybrid into search()) deferred until routing + consumer resolved (T1/T2) | Taste | P5 explicit | Unbuildable as written |
|
||||
| 5 | CEO | Add "fix RAG first" track (model test + chunk-dedup + FTS5) before bespoke index | USER CHALLENGE | P3/P4 | Cheaper, reuses infra, targets same symptom — but user's call |
|
||||
| 6 | all | R1 (delete orphan) + R6 (fix note) ship anytime | Mechanical | P6 | Trivial, independent |
|
||||
|
||||
## REVISED scope (post-review)
|
||||
- **Ship now (safe, independent):** R1 delete orphan, R6 fix note, R4 graceful degradation (with explicit signal + test rewrite), fix latent bug update_notes_index.py:244, chunk-dedup in search().
|
||||
- **Test before building (cheap, reversible):** swap embedding model (nomic-embed-text/bge-m3) + reindex; re-run the failing paraphrase query; prototype SQLite FTS5 hybrid.
|
||||
- **Build only if the above doesn't fix recall:** R2 index.md (with T3/T4 lifecycle + exclusion fixes, per-category granularity for projects/), R3 hybrid (after routing + consumer T1/T2 designed).
|
||||
98
memory/kb/PROCES-INSIGHTS.md
Normal file
98
memory/kb/PROCES-INSIGHTS.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Proces Extragere Insights
|
||||
|
||||
**Scop:** Extrag TOATE ideile acționabile din notele YouTube, nu doar 1-2.
|
||||
|
||||
---
|
||||
|
||||
## Când se rulează
|
||||
|
||||
- **Morning report** (08:30) - scanează note noi din ultimele 48h
|
||||
- **Evening report** (20:00) - scanează note noi din ultimele 48h
|
||||
|
||||
---
|
||||
|
||||
## Pași extragere
|
||||
|
||||
### 1. Identifică notele noi
|
||||
```bash
|
||||
find /home/moltbot/clawd/kb/youtube/ -mtime -2 -name "*.md"
|
||||
```
|
||||
|
||||
### 2. Citește COMPLET fiecare notă
|
||||
|
||||
**NU doar TL;DR!** Verifică TOATE secțiunile:
|
||||
- [ ] **TL;DR** - rezumat general
|
||||
- [ ] **Puncte Cheie** - concepte principale
|
||||
- [ ] **Acțiuni Practice** - ce poți face concret
|
||||
- [ ] **Citate** - fraze memorabile care pot deveni provocări
|
||||
- [ ] **Resurse** - linkuri, cărți, tool-uri menționate
|
||||
|
||||
### 3. Pentru fiecare idee acționabilă
|
||||
|
||||
Întreabă-te:
|
||||
- **Este acționabil?** (pot face ceva concret cu asta?)
|
||||
- **Pentru cine?** (stabilește tag-ul)
|
||||
- **De ce contează?** (ce problemă rezolvă?)
|
||||
|
||||
### 4. Stabilește tag-ul
|
||||
|
||||
| Tag | Domeniu | Exemple |
|
||||
|-----|---------|---------|
|
||||
| @work | Productivitate, cod, automatizări | tool-uri, patterns, workflows |
|
||||
| @health | Sănătate, corp, energie | exerciții, nutriție, somn |
|
||||
| @growth | Dezvoltare personală, mindset | tehnici mentale, obiceiuri |
|
||||
| @sprijin | Relații, emoții, grup sprijin | comunicare, conflicte |
|
||||
| @scout | Cercetași, activități | jocuri, tabere, proiecte |
|
||||
|
||||
### 5. Format insight
|
||||
|
||||
```markdown
|
||||
- [ ] 📌 **Titlu scurt și clar** - [Sursa](link)
|
||||
*Context: Ce e, de ce e util, ce problemă rezolvă, cum se aplică*
|
||||
```
|
||||
|
||||
**Prioritate emoji:**
|
||||
- ⚡ Urgent + Important (fă acum)
|
||||
- 📌 Important dar nu urgent (planifică)
|
||||
- 💡 Nice to have (backlog)
|
||||
|
||||
---
|
||||
|
||||
## Checklist calitate
|
||||
|
||||
Înainte de a termina scanarea, verifică:
|
||||
|
||||
- [ ] Am citit nota COMPLETĂ, nu doar TL;DR?
|
||||
- [ ] Am verificat TOATE secțiunile (Puncte Cheie, Acțiuni, Citate)?
|
||||
- [ ] Fiecare insight are CONTEXT (nu doar titlu)?
|
||||
- [ ] Am stabilit tag-ul corect pentru fiecare?
|
||||
- [ ] Am extras TOATE ideile acționabile, nu doar cele evidente?
|
||||
|
||||
---
|
||||
|
||||
## Exemple bune vs rele
|
||||
|
||||
❌ **Rău:**
|
||||
```
|
||||
- [ ] 💡 Activitate hero's journey pentru cercetași
|
||||
```
|
||||
(Lipsă context - ce e hero's journey? cum se aplică?)
|
||||
|
||||
✅ **Bun:**
|
||||
```
|
||||
- [ ] 📌 **Activitate hero's journey pentru cercetași** - [Tony Robbins](link)
|
||||
*Context: Viața pare OK → ceva se întâmplă → "call to adventure" (pare sfârșit dar e început).
|
||||
Exercițiu: cercetașii identifică un moment greu din viață care s-a dovedit a fi un dar.*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fișiere relevante
|
||||
|
||||
- **Note YouTube:** `/home/moltbot/clawd/kb/youtube/`
|
||||
- **Insights zilnice:** `/home/moltbot/clawd/kb/insights/YYYY-MM-DD.md`
|
||||
- **Backlog:** `/home/moltbot/clawd/kb/backlog.md`
|
||||
|
||||
---
|
||||
|
||||
*Creat: 2026-01-31 | Actualizat de Echo Work*
|
||||
162
memory/kb/articole/eat-the-frog-brian-tracy.md
Normal file
162
memory/kb/articole/eat-the-frog-brian-tracy.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Eat the Frog — Brian Tracy (Rezumat)
|
||||
|
||||
**Autor:** Brian Tracy
|
||||
**Tip:** Carte productivitate
|
||||
**Concept central:** Începe ziua cu taskul cel mai important și neplăcut — "înghite broasca" dimineața și restul zilei va fi mai ușor
|
||||
**Tags:** @work @productivity @growth
|
||||
|
||||
---
|
||||
|
||||
## Conceptul "Eat the Frog"
|
||||
|
||||
**Origine:** Mark Twain: *"If the first thing you do each morning is to eat a live frog, you can go through the day with the satisfaction of knowing that that is probably the worst thing that is going to happen to you all day long."*
|
||||
|
||||
**Traducere:** Dacă faci **cel mai greu/neplăcut task PRIMUL**, restul zilei devine mai ușor. Nu mai porți greul ca povară mentală toată ziua.
|
||||
|
||||
---
|
||||
|
||||
## De Ce Funcționează
|
||||
|
||||
### 1. **Eliminarea procrastinării prin momentum**
|
||||
- Amânarea taskului greu consumă energie mentală toată ziua
|
||||
- "Voi face mai târziu" = stres constant subconștient
|
||||
- **Faci dimineața → eliberezi creierul pentru restul zilei**
|
||||
|
||||
### 2. **Peak energy la început de zi**
|
||||
- Dimineața: voință maximă, energie mentală plină
|
||||
- După-amiază: oboseală decizională, voință slăbită
|
||||
- **Broasca necesită voință → fă-o când ai rezervorul plin**
|
||||
|
||||
### 3. **Satisfacția realizării devreme**
|
||||
- Victory early → setezi tonul zilei
|
||||
- Momentum pozitiv → restul taskurilor par ușoare
|
||||
- **Dacă broasca e făcută la 9 AM, ziua e deja câștigată**
|
||||
|
||||
---
|
||||
|
||||
## Cele 21 de Metode (Rezumat Top 10)
|
||||
|
||||
### 1. **Set the Table (Pune masa)**
|
||||
- **Claritate:** Scrie EXACT ce trebuie făcut
|
||||
- Task vag = procrastinare inevitabilă
|
||||
- **Exemplu:** NU "Lucrez la proiect", CI "Scriu 3 funcții pentru modul rapoarte"
|
||||
|
||||
### 2. **Plan Every Day in Advance**
|
||||
- Seara sau dimineața: listă cu priorități
|
||||
- **Regula 10/90:** 10% planificare = 90% eficiență executare
|
||||
- **Tool:** Lista scrisă (nu mentală) cu ordinea clară
|
||||
|
||||
### 3. **Apply 80/20 Rule**
|
||||
- **Pareto:** 20% din taskuri = 80% din rezultate
|
||||
- **Broasca ta = acel 20%**
|
||||
- Întreabă: "Dacă aș putea face DOAR un task azi, care ar fi?"
|
||||
|
||||
### 4. **Consider the Consequences**
|
||||
- **Regula:** Task-ul cu cele mai mari consecințe pe termen lung = broasca ta
|
||||
- Consecințe mari (pozitive dacă faci, negative dacă nu faci) = prioritate #1
|
||||
- **Exemplu Marius:** Căutat clienți noi are consecințe URIASE pe 12 luni
|
||||
|
||||
### 5. **Practice Creative Procrastination**
|
||||
- **Nu poți face tot** → alege DELIBERAT ce să amâni
|
||||
- Amână taskuri cu impact MIC, nu cele cu impact MARE
|
||||
- **Exemplu:** Amână organizat inbox-ul, NU broasca (client nou, feature critic)
|
||||
|
||||
### 6. **Use ABCDE Method**
|
||||
- **A:** Must do (consecințe grave dacă nu faci) → **BROASCA TA**
|
||||
- **B:** Should do (consecințe mici)
|
||||
- **C:** Nice to do (zero consecințe)
|
||||
- **D:** Delegate (dă altcuiva)
|
||||
- **E:** Eliminate (șterge de pe listă)
|
||||
- **Regula:** Nu faci niciodată B dacă ai A neterminat
|
||||
|
||||
### 7. **Focus on Key Result Areas**
|
||||
- Identifică 5-7 arii unde TREBUIE să excelezi
|
||||
- **Pentru Marius (antreprenor):** Clienți noi, dezvoltare produs, cash flow, echipă, sisteme
|
||||
- **Broasca zilnică = task din aria cu cel mai mare impact**
|
||||
|
||||
### 8. **Identify Key Constraints**
|
||||
- **Ce te limitează cel mai mult?**
|
||||
- Adesea: lipsa clienți noi, dependență de tine, lipsa sisteme
|
||||
- **Broasca = atacă constrângerea #1**
|
||||
|
||||
### 9. **Single Handle Every Task**
|
||||
- **Odată ce începi broasca, NU te opri până o termini**
|
||||
- Multitasking = iluzie, distracție = sabotaj
|
||||
- **Regula:** 100% focus până task-ul e DONE
|
||||
|
||||
### 10. **Eat That Frog! (Fă-o ACUM)**
|
||||
- **Nu mai gândi, nu mai planifici în plus**
|
||||
- **Doar începe — chiar dacă e imperfect**
|
||||
- Acțiunea bate perfecțiunea
|
||||
|
||||
---
|
||||
|
||||
## Cum Să-ți Găsești "Broasca" Zilnică
|
||||
|
||||
**Framework rapid:**
|
||||
|
||||
1. **Lista de taskuri** — tot ce ai de făcut azi
|
||||
2. **Întreabă:**
|
||||
- Care task, dacă terminat, ar avea cel mai mare impact pozitiv?
|
||||
- Care task îmi e cel mai neplăcut/intimidant?
|
||||
- Care task, dacă amânat, ar avea cele mai grave consecințe?
|
||||
3. **Acel task = broasca ta**
|
||||
4. **Fă-l PRIMUL** — nu email, nu Slack, nu "pregătiri"
|
||||
|
||||
---
|
||||
|
||||
## Aplicații pentru Marius
|
||||
|
||||
### Broaște tipice:
|
||||
- **Outreach client nou** — neplăcut (risc refuz), impact URIAȘ (venit recurent)
|
||||
- **Documentare proces pentru angajat** — plictisitor, impact MARE (libertate viitoare)
|
||||
- **Fix bug critic client important** — stresant, consecințe grave dacă amân
|
||||
- **Automatizare task repetitiv** — efort acum, libertate perpetuă
|
||||
|
||||
### Anti-broaște (să amâni deliberat):
|
||||
- Răspuns emailuri non-urgente
|
||||
- Reorganizat fișiere
|
||||
- "Explorare" fără scop clar
|
||||
- Meetings fără agendă
|
||||
|
||||
---
|
||||
|
||||
## Combinație cu "Choose Your Hard"
|
||||
|
||||
**Eat the Frog = alegi hard-ul corect ACUM**
|
||||
|
||||
- **Hard acum:** Înghit broasca dimineața (discomfort, efort, neplăcut)
|
||||
- **Hard amânat:** Port povara mentală + consecințe negative tot restul zilei/săptămânii
|
||||
|
||||
**Moto:** "Discipline înseamnă să faci ce trebuie făcut, când trebuie făcut, chiar dacă nu vrei să o faci."
|
||||
|
||||
---
|
||||
|
||||
## Ritualul "Eat the Frog" pentru Marius
|
||||
|
||||
**Dimineața (08:00-09:00):**
|
||||
|
||||
1. **Identifică broasca** (seara înainte sau la cafea)
|
||||
2. **Zero distracții** — închide Discord, WhatsApp, email
|
||||
3. **Start direct** — nu "pregătiri", nu "mai întâi verificări"
|
||||
4. **Single focus** — 100% pe broască până e DONE
|
||||
5. **Victory** — broască terminată = ziua e câștigată
|
||||
|
||||
**Rezultat:**
|
||||
Până la 9 AM ai făcut taskul cu cel mai mare impact → restul zilei e downhill
|
||||
|
||||
---
|
||||
|
||||
## Quote-uri Cheie
|
||||
|
||||
> "There is never enough time to do everything, but there is always enough time to do the most important thing."
|
||||
|
||||
> "The hardest part of any important task is getting started on it in the first place."
|
||||
|
||||
> "Eat a live frog first thing in the morning and nothing worse will happen to you the rest of the day."
|
||||
|
||||
---
|
||||
|
||||
**Link:** [Eat the Frog - Brian Tracy](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/articole/eat-the-frog-brian-tracy.md)
|
||||
|
||||
**Lectură recomandată:** Carte completă pentru cele 21 de metode + exerciții practice
|
||||
6
memory/kb/articole/index.md
Normal file
6
memory/kb/articole/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Index — articole/
|
||||
|
||||
> 1 note. Citește acest index întâi; deschide doar fișierele relevante.
|
||||
|
||||
- **[Eat the Frog — Brian Tracy (Rezumat)](eat-the-frog-brian-tracy.md)** `@work @growth`
|
||||
**Lectură recomandată:** Carte completă pentru cele 21 de metode + exerciții practice
|
||||
46
memory/kb/backlog.md
Normal file
46
memory/kb/backlog.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Backlog
|
||||
|
||||
**Actualizat:** 2026-01-31
|
||||
|
||||
---
|
||||
|
||||
## De executat (recomandate, când ai timp)
|
||||
|
||||
*Propuneri aprobate pentru mai târziu sau recomandate din rapoarte*
|
||||
|
||||
<!-- Exemplu:
|
||||
- [ ] ⚡ Titlu propunere - RECOMANDAT - [insights/2026-01-31.md]
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## De revizuit (ignorate din rapoarte)
|
||||
|
||||
*Propuneri la care nu ai răspuns - de decis: execut, șterg, sau las*
|
||||
|
||||
<!-- Exemplu:
|
||||
- [ ] 📌 Titlu propunere - NERECOMANDAT (motiv) - [insights/2026-01-31.md]
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Vechi (> 30 zile, de curățat)
|
||||
|
||||
*Propuneri vechi - raportul va propune curățarea periodică*
|
||||
|
||||
<!-- Se populează automat când propunerile din secțiunile de sus depășesc 30 zile -->
|
||||
|
||||
---
|
||||
|
||||
## Legendă (Matrice Eisenhower)
|
||||
|
||||
| Emoji | Urgent | Important | Ce fac |
|
||||
|-------|--------|-----------|--------|
|
||||
| ⚡ | DA | DA | Fă ACUM |
|
||||
| 📌 | NU | DA | Planifică |
|
||||
| ⏰ | DA | NU | Fă rapid |
|
||||
| 💡 | NU | NU | Poate cândva |
|
||||
|
||||
- RECOMANDAT = am evaluat că merită
|
||||
- NERECOMANDAT = am evaluat că nu merită acum (motiv)
|
||||
- [insights/YYYY-MM-DD.md] = referință la sursa originală
|
||||
10
memory/kb/coaching/.rules.json
Normal file
10
memory/kb/coaching/.rules.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"defaultDomains": ["health"],
|
||||
"defaultTypes": ["coaching"],
|
||||
"defaultTags": [],
|
||||
"inferTypeFromFilename": true,
|
||||
"filenameTypeMap": {
|
||||
"dimineata": "coaching",
|
||||
"seara": "reflectie"
|
||||
}
|
||||
}
|
||||
52
memory/kb/coaching/2026-01-31-dimineata.md
Normal file
52
memory/kb/coaching/2026-01-31-dimineata.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Gândul de dimineață - 2026-01-31
|
||||
|
||||
**Tags:** @health @coaching #tony-robbins #fiziologie #pattern-interrupt
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[❤️ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
> *"Depresia are o postură: umeri căzuți, cap în jos, respirație superficială. Energia are alta: corp deschis, respirație adâncă."*
|
||||
> — Tony Robbins
|
||||
|
||||
Când te simți blocat, nu încerca să-ți schimbi gândurile cu forța.
|
||||
|
||||
**Schimbă-ți corpul PRIMUL.**
|
||||
|
||||
Cele 3 lucruri care controlează cum te simți:
|
||||
1. **Fiziologia** — cum stai, cum respiri, cum te miști
|
||||
2. **Focusul** — ce vezi, ce observi, cum privești
|
||||
3. **Limbajul** — ce-ți spui, cum numești experiența
|
||||
|
||||
Poți să tot încerci să "gândești pozitiv" cu umerii căzuți și respirația superficială. Nu va merge.
|
||||
|
||||
Dar ridică-te. Deschide pieptul. Respiră adânc. Și vezi ce se întâmplă cu gândurile.
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(Mișcare de 2 minute)*
|
||||
|
||||
Când citești asta, oriunde ai fi:
|
||||
|
||||
1. **Ridică-te** (sau schimbă poziția complet)
|
||||
2. **Fă 5 respirații adânci** — inspiră 4 sec, expiră 6 sec
|
||||
3. **Întinde-te** — ridică brațele, deschide pieptul
|
||||
4. **Mergi 2 minute** — oriunde, fără scop
|
||||
|
||||
Nu e exercițiu. E **pattern interrupt** — resetare de stare.
|
||||
|
||||
Corpul nu știe să mintă. Folosește asta.
|
||||
|
||||
---
|
||||
|
||||
*O zi cu energie!* ✨
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Tony Robbins - Extraordinary Life](files.html#kb/youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md) - Cele 3 lucruri care controlează emoțiile (fiziologie, focus, limbaj), pattern interrupt, citat despre postură și depresie
|
||||
- [Insights 2026-01-31](files.html#kb/insights/2026-01-31.md) - Reminder "ridică-te, mergi 2 min" (propunere)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Mișcare fizică / Pattern Interrupt
|
||||
PROVOCARE: Ridică-te, fă 5 respirații adânci (4-6), întinde-te, mergi 2 minute. Resetare de stare prin corp.
|
||||
69
memory/kb/coaching/2026-01-31-seara.md
Normal file
69
memory/kb/coaching/2026-01-31-seara.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Gândul de seară - 2026-01-31
|
||||
|
||||
**Tags:** @health @coaching #tony-robbins #recunostinta #priming
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[❤️ Echo] **GÂNDUL DE SEARĂ** 🌙
|
||||
|
||||
---
|
||||
|
||||
**Mai întâi: cum a fost provocarea de azi?**
|
||||
|
||||
Dimineață ți-am propus pattern interrupt-ul: ridică-te, 5 respirații, întinde-te, mergi 2 minute.
|
||||
|
||||
Ai încercat? Când te-ai ridicat azi doar ca să te miști, fără scop? Dacă da - ce ai observat?
|
||||
|
||||
---
|
||||
|
||||
Acum, pentru seară:
|
||||
|
||||
> *"Work on gratitude and happiness is automatic."*
|
||||
> — Tony Robbins
|
||||
|
||||
**Recunoștința nu se gândește. Se simte.**
|
||||
|
||||
Tony Robbins are un ritual de 10 minute în fiecare dimineață - dar funcționează și seara, înainte de somn. Îl numește **Priming**:
|
||||
|
||||
Nu *gândești* 3 lucruri pentru care ești recunoscător.
|
||||
Le **simți**. Le treci prin corp.
|
||||
|
||||
De ce contează asta seara?
|
||||
|
||||
Pentru că închei ziua nu cu lista de ce ai greșit, ce n-ai terminat, ce te îngrijorează.
|
||||
Ci cu ce a mers. Ce a fost acolo pentru tine. Ce ți-a ieșit.
|
||||
|
||||
> *"What's wrong is always available. So is what's right."*
|
||||
|
||||
Ai de ales ce focusezi.
|
||||
|
||||
---
|
||||
|
||||
**🌙 PROVOCAREA DE SEARĂ** *(3 minute, înainte de somn)*
|
||||
|
||||
1. Stai jos, relaxat, ochii închiși
|
||||
2. Gândește-te la un moment de azi care a fost bun
|
||||
3. **Simte-l** - nu doar "a fost ok", ci: unde în corp simți recunoștința? Ce senzație are? Cât de mare poți să o faci?
|
||||
4. Repetă cu încă 2 momente (pot fi mici - un mesaj, o cafea, un râs)
|
||||
|
||||
Nu e exercițiu de gândire pozitivă. E **antrenament de sistem nervos**.
|
||||
|
||||
Adormi cu totul altceva în corp.
|
||||
|
||||
---
|
||||
|
||||
*Noapte liniștită!* ✨
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Tony Robbins - Extraordinary Life](http://100.120.119.70:8080/files.html#kb/youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md) - Priming ritual, recunoștința simțită (nu gândită), citate despre recunoștință și focus
|
||||
- [Insights 2026-01-31](http://100.120.119.70:8080/files.html#kb/insights/2026-01-31.md) - Exercițiu Priming marcat ca integrat
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Recunoștință / Priming seară
|
||||
PROVOCARE: 3 minute înainte de somn - găsește 3 momente bune din zi și SIMTE-le (nu doar gândește). Unde în corp? Ce senzație? Antrenament de sistem nervos.
|
||||
|
||||
## Follow-up provocare dimineață
|
||||
|
||||
Am întrebat dacă a încercat pattern interrupt-ul (ridicat, 5 respirații, întins, mers 2 min) și ce a observat.
|
||||
78
memory/kb/coaching/2026-02-01-dimineata.md
Normal file
78
memory/kb/coaching/2026-02-01-dimineata.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Gândul de dimineață - 2026-02-01
|
||||
|
||||
**Tags:** @health @coaching #james-clear #simon-sinek #jocuri-infinite #sustenabilitate
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[❤️ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
E duminică. Și poate te gândești la ce ai de făcut săptămâna viitoare.
|
||||
|
||||
Dar azi vreau să te întreb altceva:
|
||||
|
||||
**La ce încerci să "termini"?**
|
||||
|
||||
---
|
||||
|
||||
> *"Viața nu e un joc finit cu un final pe care să-l atingi. E un joc infinit - și singura strategie câștigătoare e să rămâi în joc."*
|
||||
> — Simon Sinek (prin James Clear)
|
||||
|
||||
Ne-am obișnuit să vedem totul ca pe o listă de bifat:
|
||||
- ✅ Termină proiectul
|
||||
- ✅ Ajunge la forma fizică perfectă
|
||||
- ✅ Rezolvă problema
|
||||
|
||||
Dar unele lucruri nu se termină niciodată. **Sănătatea** nu se termină. **Relațiile** nu se termină. **Creșterea** nu se termină.
|
||||
|
||||
Și când tratezi un joc infinit ca pe unul finit, te epuizezi.
|
||||
|
||||
---
|
||||
|
||||
Diferența:
|
||||
|
||||
**Joc finit:** "Vreau să slăbesc 10 kg până în vară."
|
||||
**Joc infinit:** "Vreau să-mi găsesc mișcarea zilnică care mă face să mă simt bine."
|
||||
|
||||
**Joc finit:** "Trebuie să rezolv problema cu gâtul/spatele/pielea ACUM."
|
||||
**Joc infinit:** "Cum pot trăi bine cu corpul pe care îl am, ascultându-l și îngrijindu-l?"
|
||||
|
||||
**Joc finit:** "Când voi fi suficient de bun, voi fi în regulă."
|
||||
**Joc infinit:** "Sunt în regulă acum, și mâine voi fi puțin mai bine."
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(Reflecție de 5 minute)*
|
||||
|
||||
Ia un domeniu din viața ta unde te simți epuizat sau presat.
|
||||
|
||||
Întreabă-te:
|
||||
|
||||
1. **Încerc să "termin" ceva ce nu se termină?**
|
||||
2. **Cum ar arăta dacă ar fi un joc infinit?** - nu despre a ajunge undeva, ci despre a rămâne în joc
|
||||
3. **Care e cel mai mic pas sustenabil?** - nu cel mai eficient, ci cel pe care l-aș face și peste 10 ani
|
||||
|
||||
---
|
||||
|
||||
Duminica e bună pentru întrebarea asta.
|
||||
|
||||
Nu trebuie să ajungi nicăieri. Trebuie doar să rămâi în joc.
|
||||
|
||||
---
|
||||
|
||||
*O duminică liniștită!* ☀️
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Insights 2026-02-01](http://100.120.119.70:8080/files.html#kb/insights/2026-02-01.md) - Conceptul jocurilor infinite din James Clear 3-2-1 Newsletter
|
||||
- Simon Sinek - Infinite Game (concept)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Reflecție / Reframare
|
||||
PROVOCARE: Ia un domeniu unde te simți epuizat. Întreabă: (1) Încerc să termin ceva ce nu se termină? (2) Cum ar arăta ca joc infinit? (3) Care e cel mai mic pas sustenabil pe 10 ani?
|
||||
|
||||
## Context
|
||||
|
||||
Duminică dimineață - moment bun pentru întrebări mai largi despre viață și sustenabilitate. Mesajul se aplică direct la sănătate (durerea cervicală, pielea) fără a fi intruziv.
|
||||
22
memory/kb/coaching/2026-02-01-seara.md
Normal file
22
memory/kb/coaching/2026-02-01-seara.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Gândul de seară - 2026-02-01
|
||||
|
||||
**Tags:** @growth #jocuri-infinite #reflectie #seara
|
||||
|
||||
---
|
||||
|
||||
*Follow-up la provocarea de dimineață despre jocurile infinite...*
|
||||
|
||||
---
|
||||
|
||||
Seara e momentul în care judecătorul interior își face auzit verdictul. "Ai făcut destul? Ai fost productiv? Ai avansat?"
|
||||
|
||||
Dar în jocurile infinite nu există "destul". Există doar: **"Sunt încă în joc?"**
|
||||
|
||||
Azi ai mutat lucruri, ai corectat, ai îmbunătățit infrastructura. Nu e glamorous. Nu e un finish line. Dar e exact ce înseamnă să rămâi în joc - să faci treaba de întreținere care permite jocul să continue.
|
||||
|
||||
**Întrebare de seară:**
|
||||
> Ce ai făcut azi care nu era despre "a ajunge undeva", ci despre "a rămâne în joc"?
|
||||
|
||||
---
|
||||
|
||||
*Trimis: Sâmbătă, 1 februarie 2026, 23:17*
|
||||
86
memory/kb/coaching/2026-02-02-dimineata.md
Normal file
86
memory/kb/coaching/2026-02-02-dimineata.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Gândul de dimineață - 2026-02-02
|
||||
|
||||
**Tags:** @growth @coaching #zoltan-veres #motivatie #eforturi #luni
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
E luni. Săptămână nouă.
|
||||
|
||||
Și poate ai deja în cap lista de lucruri pe care *ar trebui* să le faci.
|
||||
|
||||
Dar azi vreau să te întreb altceva:
|
||||
|
||||
**Ce anume te-a oprit săptămâna trecută?**
|
||||
|
||||
---
|
||||
|
||||
> *"Rezultatele motivează PE MOMENT. Eforturile demotivează PERMANENT."*
|
||||
> — Zoltan Vereș
|
||||
|
||||
Iată ce se întâmplă de obicei:
|
||||
|
||||
1. Te entuziasmezi de un REZULTAT (*"vreau să obțin X"*)
|
||||
2. Ignori EFORTUL real necesar
|
||||
3. La primul obstacol → abandonezi
|
||||
4. Te simți vinovat că "nu ai voință"
|
||||
|
||||
Dar problema nu e voința ta. E că **nu ți-ai asumat efortul** - doar rezultatul.
|
||||
|
||||
---
|
||||
|
||||
**Diferența:**
|
||||
|
||||
🎯 "Vreau mai mulți clienți" → entuziasm
|
||||
📋 "Asta înseamnă 5 apeluri pe săptămână" → ... hmm
|
||||
|
||||
🎯 "Vreau să termin proiectul" → entuziasm
|
||||
📋 "Asta înseamnă 3 ore focusate azi" → ... să vedem
|
||||
|
||||
---
|
||||
|
||||
**Întrebarea care schimbă totul:**
|
||||
|
||||
*"Îmi asum să FAC efortul ăsta?"*
|
||||
|
||||
Nu "vreau rezultatul?" - asta știi deja.
|
||||
Ci: **"Îmi asum CONCRET ce necesită?"**
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(2 minute)*
|
||||
|
||||
Alege UN singur lucru pe care vrei să-l faci azi.
|
||||
|
||||
Scrie pe o hârtie (nu în cap!):
|
||||
1. Ce EFORT concret necesită? (timp, energie, pași)
|
||||
2. Te uiți la efort și te întrebi: **"Îmi asum ASTA?"**
|
||||
|
||||
Dacă da → fă-l.
|
||||
Dacă nu → fie ajustezi, fie renunți fără vinovăție.
|
||||
|
||||
---
|
||||
|
||||
Nu contează câte vrei să faci.
|
||||
Contează câte **îți asumi cu adevărat**.
|
||||
|
||||
O săptămână cu mai puțin, dar asumat! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Zoltan Vereș - Motivația Intrinsecă](files.html#memory/kb/youtube/2026-02-02_zoltan-veres-motivatie-intrinseca-complet.md)
|
||||
- [Insights 2026-02-02](files.html#memory/kb/insights/2026-02-02.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Exercițiu practic / Clarificare
|
||||
PROVOCARE: Alege UN lucru de făcut azi. Scrie pe hârtie ce EFORT concret necesită. Întreabă-te: "Îmi asum ASTA?" Dacă da - fă-l. Dacă nu - ajustează sau renunță fără vinovăție.
|
||||
|
||||
## Context
|
||||
|
||||
Luni dimineață - început de săptămână. Momentul perfect să previi pendulul entuziasmului care se sparge pe efort neasumat. Tema conectată la blocajul lui Marius cu clienții noi - nu e lipsă de voință, e lipsă de asumare a efortului real.
|
||||
57
memory/kb/coaching/2026-02-02-seara.md
Normal file
57
memory/kb/coaching/2026-02-02-seara.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Gândul de seară - 2026-02-02
|
||||
|
||||
**Tags:** @growth @coaching #asumare #efort #reflectie #luni
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE SEARĂ** 🌙
|
||||
|
||||
---
|
||||
|
||||
Văd că ai bifat provocarea de azi. ✓
|
||||
|
||||
Asta înseamnă că ai luat un lucru, ai scris efortul necesar, și ai răspuns sincer la "Îmi asum ASTA?"
|
||||
|
||||
**Asta e tot ce contează.**
|
||||
|
||||
Nu rezultatul. Nu câte ai făcut.
|
||||
Ci că ai fost onest cu tine.
|
||||
|
||||
---
|
||||
|
||||
Acum, seara, când ziua se liniștește:
|
||||
|
||||
**Cum a fost să scrii efortul pe hârtie?**
|
||||
|
||||
A fost mai greu sau mai ușor decât te așteptai?
|
||||
Ai descoperit ceva despre tine în proces?
|
||||
|
||||
Nu trebuie să răspunzi acum. Doar lasă întrebările să stea cu tine.
|
||||
|
||||
---
|
||||
|
||||
> *"A-ți asuma efortul nu înseamnă să fii sigur că reușești. Înseamnă să fii dispus să încerci, știind exact ce presupune."*
|
||||
|
||||
---
|
||||
|
||||
Noapte bună, Marius.
|
||||
Mâine e o altă zi cu alte alegeri.
|
||||
|
||||
Dar azi - ai ales să fii sincer cu tine. Și asta face diferența.
|
||||
|
||||
🌀
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Provocarea zilei (asumare efort) a fost completată la 12:17. Reflecția de seară felicită și deschide spațiu pentru introspecție - fără presiune de răspuns, doar întrebări care să stea cu el.
|
||||
|
||||
## Status provocare
|
||||
|
||||
BIFATĂ: Da (12:17 UTC)
|
||||
FOLLOW-UP: Întrebări de reflecție despre experiența de a scrie efortul pe hârtie
|
||||
|
||||
## Surse
|
||||
|
||||
- Provocare bazată pe [Zoltan Vereș - Motivația Intrinsecă](files.html#memory/kb/youtube/2026-02-02_zoltan-veres-motivatie-intrinseca-complet.md)
|
||||
72
memory/kb/coaching/2026-02-03-dimineata.md
Normal file
72
memory/kb/coaching/2026-02-03-dimineata.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Gândul de dimineață - 2026-02-03
|
||||
|
||||
**Tags:** @growth @coaching #zoltan-veres #umbre #autocunoastere #marti
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
Ce NU vrei să vadă ceilalți la tine?
|
||||
|
||||
Nu mă refer la secrete sau greșeli. Mă refer la părțile din tine pe care le **ascunzi** pentru că ți-e rușine sau crezi că nu sunt "ok".
|
||||
|
||||
---
|
||||
|
||||
> *"Umbrele consumă energie să le ținem ascunse. Când le integrăm, devenim mai întregi."*
|
||||
> — Zoltan Vereș, despre conceptul jungian al umbrelor
|
||||
|
||||
---
|
||||
|
||||
**Iată paradoxul:**
|
||||
|
||||
Ceea ce ascunzi nu dispare. Doar muncește în umbră.
|
||||
|
||||
- Ascunzi că nu știi ceva? → Eviti să întrebi, rămâi blocat
|
||||
- Ascunzi că ți-e frică? → Amâni, inventezi scuze
|
||||
- Ascunzi că vrei recunoaștere? → Te retragi când ar trebui să te afirmi
|
||||
|
||||
---
|
||||
|
||||
**Revelație din workshop:**
|
||||
|
||||
Participanții lui Zoltan au descoperit ceva neașteptat:
|
||||
> "Nu s-a potrivit ce credeam că îmi va crește stima de sine. **Alte lucruri** au funcționat - la care nu mă gândisem."
|
||||
|
||||
Când accepți o umbră, eliberezi energia pe care o consumi să o ascunzi.
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(3 minute)*
|
||||
|
||||
Răspunde sincer la una din aceste întrebări:
|
||||
|
||||
1. Ce **complimente refuzi** sau minimizezi? ("lasă, nu-i mare lucru...")
|
||||
2. Ce ai face dacă **nu te-ar judeca nimeni**?
|
||||
3. Ce te **irită** la alții? (adesea e oglinda propriilor umbre)
|
||||
|
||||
Scrie răspunsul. Nu trebuie să faci nimic cu el - doar să-l vezi.
|
||||
|
||||
---
|
||||
|
||||
Umbrele nu sunt dușmanii tăi.
|
||||
Sunt părți din tine care așteaptă să fie primite.
|
||||
|
||||
O zi cu mai puțin ascuns! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Zoltan Vereș - Umbrele (Workshop)](files.html#memory/kb/youtube/2026-02-02_zoltan-veres-umbrele-workshop-complet.md)
|
||||
- [Insights 2026-02-03](files.html#memory/kb/insights/2026-02-03.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Reflecție / Autocunoaștere
|
||||
PROVOCARE: Răspunde la una din întrebări: Ce complimente refuzi? Ce ai face dacă nu te-ar judeca nimeni? Ce te irită la alții? Scrie răspunsul - doar să-l vezi.
|
||||
|
||||
## Context
|
||||
|
||||
Marti - zi de lucru. Umbrele sunt relevante pentru Marius: credința "nu sunt destul de deștept ca antreprenor" este exact o umbră. Exercițiul de azi ajută la identificarea acestor părți ascunse care consumă energie.
|
||||
56
memory/kb/coaching/2026-02-03-seara.md
Normal file
56
memory/kb/coaching/2026-02-03-seara.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Gândul de seară - 2026-02-03
|
||||
|
||||
**Tags:** @growth @coaching #umbre #zoltan-veres #acceptare #marti
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE SEARĂ** 🌙
|
||||
|
||||
---
|
||||
|
||||
Umbrele sunt vizitatori incomozi.
|
||||
|
||||
Azi ți-am propus să te uiți la una. Poate ai făcut-o, poate nu. Oricum e ok.
|
||||
|
||||
---
|
||||
|
||||
> *"Umbrele nu pleacă dacă le ignorăm. Dar nici nu trebuie să le rezolvăm - doar să le vedem."*
|
||||
|
||||
---
|
||||
|
||||
**Un gând pentru seara asta:**
|
||||
|
||||
Umbrele sunt ca invitații nepoftiți la o petrecere. Poți să-i ignori, să fugi de ei, sau... să le dai un scaun în colț.
|
||||
|
||||
Nu trebuie să stai de vorbă cu ei. Doar să recunoști că sunt acolo.
|
||||
|
||||
---
|
||||
|
||||
**Dacă ai răspuns la una din întrebări:**
|
||||
Ce ai descoperit? A fost ceva surprinzător?
|
||||
|
||||
**Dacă nu ai apucat:**
|
||||
Ce te-a oprit? Timpul? Sau poate... era prea aproape de ceva real?
|
||||
|
||||
Ambele răspunsuri sunt informație utilă.
|
||||
|
||||
---
|
||||
|
||||
Umbrele consumă energie să le ținem ascunse.
|
||||
Când le acceptăm, devenim mai ușori.
|
||||
|
||||
Noapte bună! 🌙
|
||||
|
||||
## Status provocare
|
||||
|
||||
Provocarea zilei (prov-2026-02-03) despre umbrele: **nu a fost bifată**.
|
||||
Mesajul de seară întreabă empatic ce l-a oprit, fără judecată.
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- Provocarea de dimineață: umbrele (Zoltan Vereș)
|
||||
- [Zoltan Vereș - Umbrele Workshop](files.html#memory/kb/youtube/2026-02-02_zoltan-veres-umbrele-workshop-complet.md)
|
||||
|
||||
## Context
|
||||
|
||||
Marti seară. Marius nu a bifat provocarea despre umbrele - e o temă profundă și poate incomodă. Am ales să fiu empatic și să las spațiu pentru reflecție fără presiune.
|
||||
34
memory/kb/coaching/2026-02-03_morning.md
Normal file
34
memory/kb/coaching/2026-02-03_morning.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Coaching Dimineață - 3 Februarie 2026
|
||||
|
||||
**Tema:** Umbrele noastre
|
||||
**Sursă:** [Zoltan Vereș - Umbrele](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-01_zoltan-veres-umbrele-complet.md)
|
||||
|
||||
---
|
||||
|
||||
## 💭 Gând
|
||||
|
||||
> "Nu există lipsă de încredere sau stimă de sine. Există doar confuzie."
|
||||
|
||||
Când spui "nu sunt suficient de bun", nu e adevărat. E doar că nu ai claritate.
|
||||
|
||||
**Confuzia** șterge încrederea - nu o reduce, o face invizibilă. În secunda în care ai claritate despre ce poți și ce nu poți, încrederea reapare singură.
|
||||
|
||||
Întrebarea nu e "Am eu încredere?" ci "Ce anume nu-mi e clar?"
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Provocare
|
||||
|
||||
Gândul "Nu sunt destul de bun" e incomplet. **Suficient de bun LA CE?**
|
||||
|
||||
Azi, când apare orice formă de îndoială:
|
||||
1. Oprește-te
|
||||
2. Întreabă: "Suficient de bun la CE, mai exact?"
|
||||
3. Scrie 3 criterii concrete (nu emoții, ci măsurători)
|
||||
4. Verifică: sunt criteriile tale sau impuse de alții?
|
||||
|
||||
**Claritatea e antidotul confuziei.**
|
||||
|
||||
---
|
||||
|
||||
*[⭕ Echo]*
|
||||
80
memory/kb/coaching/2026-02-04-dimineata.md
Normal file
80
memory/kb/coaching/2026-02-04-dimineata.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Gândul de dimineață - 2026-02-04
|
||||
|
||||
**Tags:** @growth @coaching #nlp #vizualizare #motivatie #miercuri
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
Ai vrut vreodată să faci ceva... dar pur și simplu **nu simțeai** că vrei?
|
||||
|
||||
Nu e vorba că nu știi că e bine. E vorba că **emoția lipsește**.
|
||||
|
||||
---
|
||||
|
||||
> *"Creierul nu distinge bine între imagini vii și realitate. Când îi arăți repetat conexiunea, el o crede."*
|
||||
> — principiu NLP
|
||||
|
||||
---
|
||||
|
||||
**Iată ce face creierul tău:**
|
||||
|
||||
Când te gândești la o acțiune pe care o amâni, probabil simți:
|
||||
- Neutru → "meh, nu acum"
|
||||
- Ușor negativ → "nu vreau"
|
||||
|
||||
Dar când te gândești la ceva plăcut (o vacanță, un succes), simți:
|
||||
- Cald
|
||||
- Atras
|
||||
- Motivat
|
||||
|
||||
**Întrebarea:** Ce-ar fi dacă ai putea **transfera** emoția de la una la alta?
|
||||
|
||||
---
|
||||
|
||||
**Tehnica NLP a "fissurii":**
|
||||
|
||||
1. Imaginează-ți pe un ecran mental scena care îți produce **plăcere intensă**
|
||||
2. În fața ei, pune acțiunea pe care o amâni
|
||||
3. Creează o "fissură" în imagine - prin ea se vede scena cu plăcere
|
||||
4. Închide rapid. Repetă de 3 ori.
|
||||
|
||||
Creierul începe să asocieze cele două. Acțiunea capătă încărcătură emoțională.
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(5 minute, ochii închiși)*
|
||||
|
||||
1. Alege O acțiune pe care o tot amâni
|
||||
2. Găsește o amintire cu plăcere intensă (vacanță, succes, moment de flow)
|
||||
3. Vizualizează amintirea - luminoasă, caldă
|
||||
4. Pune acțiunea în față
|
||||
5. "Sparge" imaginea - vezi plăcerea în spate
|
||||
6. Închide. Repetă de 2 ori.
|
||||
|
||||
Observă cum se schimbă ce simți față de acea acțiune.
|
||||
|
||||
---
|
||||
|
||||
Nu e magie. E modul în care funcționează creierul.
|
||||
Emoțiile se **leagă** de imagini. Poți alege ce imagini legi.
|
||||
|
||||
O zi cu motivație construită, nu așteptată! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Meditație Vizualizare Motivație](files.html#memory/kb/projects/grup-sprijin/biblioteca/meditatie-vizualizare-motivatie.md)
|
||||
- [Insights 2026-02-04](files.html#memory/kb/insights/2026-02-04.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Exercițiu practic / Vizualizare NLP
|
||||
PROVOCARE: Fă exercițiul de 5 minute: alege o acțiune amânată, găsește o amintire plăcută, vizualizează și "sparge" imaginea de 3 ori. Observă schimbarea emoțională.
|
||||
|
||||
## Context
|
||||
|
||||
Miercuri - mijlocul săptămânii. Tehnica de vizualizare e potrivită pentru deblocarea inacțiunii lui Marius cu clienții noi. Mâine (joi) are grup sprijin unde poate folosi varianta completă (10-12 min). Azi face versiunea scurtă personal.
|
||||
31
memory/kb/coaching/2026-02-05-seara.md
Normal file
31
memory/kb/coaching/2026-02-05-seara.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Coaching Seară - 5 februarie 2026
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE SEARĂ**
|
||||
|
||||
## Reflecție
|
||||
|
||||
Sfârșitul zilei este mai mult decât o trecere în somn — e un ritual de închidere.
|
||||
|
||||
Gândește-te la ziua de azi ca la o conversație. Ai ascultat ce ți-a spus? Corpul cu semnalele lui de oboseală sau energie. Mintea cu gândurile care s-au repetat. Emoțiile care au apărut când ai făcut anumite lucruri sau ai vorbit cu anumite persoane.
|
||||
|
||||
**Ce ți-a spus ziua asta despre ceea ce contează cu adevărat pentru tine?**
|
||||
|
||||
Poate ai avut un moment când te-ai simțit aliniat — când ce făceai se împletea natural cu cine ești. Sau poate a fost un moment de fricțiune — când ceva te-a tras într-o direcție care nu-ți rezonează.
|
||||
|
||||
Ambele sunt mesaje. Ambele sunt căi spre adevărul tău.
|
||||
|
||||
Nu trebuie să rezolvi nimic în seara asta. Trebuie doar să recunoști ceea ce a fost. Să mulțumești zilei pentru lecțiile ei — atât pentru cele ușoare, cât și pentru cele grele.
|
||||
|
||||
## Provocare Follow-Up
|
||||
|
||||
**Înainte să adormi, scrie 3 lucruri:**
|
||||
|
||||
1. **Un moment bun** — oricât de mic, ceva care ți-a adus zâmbet sau liniște
|
||||
2. **O tensiune** — ceva care te-a deranjat, obosit sau frustrat
|
||||
3. **O conexiune** — ce legătură vezi între cele două? Ce îți spun despre valorile tale?
|
||||
|
||||
Nu trebuie să fie profund. Trebuie să fie adevărat.
|
||||
|
||||
---
|
||||
|
||||
*Noapte bună, Marius. Lasă ziua să se așeze. Mâine vine cu propriile ei daruri.* 🌙
|
||||
86
memory/kb/coaching/2026-02-06-dimineata.md
Normal file
86
memory/kb/coaching/2026-02-06-dimineata.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Gândul de dimineață - 2026-02-06
|
||||
|
||||
**Tags:** @growth @coaching #autocunoastere #pattern #aliniere #vineri
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
Ai observat vreodată că **nu toate sarcinile consumă la fel**?
|
||||
|
||||
Unele te lasă epuizat. Altele te energizează.
|
||||
|
||||
---
|
||||
|
||||
> *"Identitatea nu se găsește prin decizie intelectuală, ci prin observarea repetată a pattern-urilor."*
|
||||
> — principiu de autocunoaștere
|
||||
|
||||
---
|
||||
|
||||
**Două tipuri de momente:**
|
||||
|
||||
**ALINIERE** - când ce faci rezonează cu cine ești
|
||||
- Te simți în flow
|
||||
- Timpul trece altfel
|
||||
- Chiar dacă e greu, e greu "bun"
|
||||
|
||||
**FRICȚIUNE** - când ești tras într-o direcție care nu-ți rezonează
|
||||
- Te simți tras înapoi
|
||||
- Găsești scuze să amâni
|
||||
- Chiar dacă e ușor, e obositor
|
||||
|
||||
---
|
||||
|
||||
**Ce e ciudat:**
|
||||
|
||||
Nu e vorba de cât de greu e ceva. E vorba de cât de **adevărat** e pentru tine.
|
||||
|
||||
Un antreprenor poate să facă muncă grea 12 ore și să fie energizat.
|
||||
Aceeași persoană poate să facă un task ușor 30 minute și să fie epuizat.
|
||||
|
||||
Diferența? Primul e aliniere. Al doilea e fricțiune.
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA ZILEI** *(observație activă)*
|
||||
|
||||
Azi, în tot ce faci - la lucru, acasă, în weekend-ul tău NLP:
|
||||
|
||||
1. **Când simți aliniere?**
|
||||
- Ce activitate faci?
|
||||
- Ce caracteristică are acea activitate? (creativitate? rezolvare de probleme? conexiune cu oameni?)
|
||||
|
||||
2. **Când simți fricțiune?**
|
||||
- Ce activitate faci?
|
||||
- Ce te trage înapoi? (teama de judecată? repetitivitatea? lipsa de control?)
|
||||
|
||||
Nu trebuie să faci nimic cu observațiile - doar să le **vezi**.
|
||||
|
||||
---
|
||||
|
||||
Corpul știe adevărul înainte ca mintea să-l articuleze.
|
||||
Când te simți tras într-o direcție, ascultă.
|
||||
Când te simți împins înapoi, ascultă de două ori.
|
||||
|
||||
**Întrebarea nu e:** "Ce TREBUIE să fac?"
|
||||
**Întrebarea e:** "Ce îmi spune corpul despre cine sunt cu adevărat?"
|
||||
|
||||
O zi cu mai multă claritate! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Insights 2026-02-06 - Pattern-urile zilnice](../insights/2026-02-06.md)
|
||||
- [Coaching Seară 2026-02-05](2026-02-05-seara.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Observație activă / Auto-cunoaștere
|
||||
PROVOCARE: Observă azi UN moment de aliniere (când ești energizat de ce faci) și UN moment de fricțiune (când ești tras înapoi). Notează: ce caracteristică are fiecare? Ce îți spune despre direcția ta reală?
|
||||
|
||||
## Context
|
||||
|
||||
Vineri - începe weekend-ul ocupat cu cursul NLP (M4: 7-8 feb). Perfect pentru auto-observare intensivă - în context de învățare (NLP) va fi mai conștient de pattern-urile proprii. Provocarea se leagă de căutarea avatar-ului ideal și de inacțiunea cu clienții noi - ambele sunt despre aliniere vs fricțiune.
|
||||
74
memory/kb/coaching/2026-02-06-seara.md
Normal file
74
memory/kb/coaching/2026-02-06-seara.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Gândul de seară - 2026-02-06
|
||||
|
||||
**Tags:** @growth @coaching #autocunoastere #pattern #aliniere #vineri
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE SEARĂ** 🌙
|
||||
|
||||
---
|
||||
|
||||
Am văzut că ai bifat provocarea de azi. Felicitări pentru că ai luat timp să observi!
|
||||
|
||||
---
|
||||
|
||||
**Întrebarea mea:**
|
||||
|
||||
Ce ai descoperit când ai urmărit alinierile și fricțiunile?
|
||||
|
||||
A fost vreun moment când:
|
||||
- Ceva greu s-a simțit "bun greu" (aliniere)?
|
||||
- Ceva ușor s-a simțit epuizant (fricțiune)?
|
||||
|
||||
Nu îmi trebuie răspuns complet acum - doar curiozitatea mea.
|
||||
|
||||
---
|
||||
|
||||
**Mâine începi modulul 4 NLP** (7-8 februarie).
|
||||
|
||||
Cursul în sine e un teren bogat de observație:
|
||||
- Când simți că "asta chiar rezonează cu mine" (aliniere)?
|
||||
- Când simți "e interesant dar nu e pentru mine" (fricțiune)?
|
||||
|
||||
Nu e despre a aplica tot ce înveți.
|
||||
E despre a vedea ce îți **vorbește direct**.
|
||||
|
||||
---
|
||||
|
||||
> *"Corpul știe adevărul înainte ca mintea să-l articuleze."*
|
||||
|
||||
Pattern-urile nu apar într-o zi.
|
||||
Dar după 7, 14, 30 de zile de observație - devine evident.
|
||||
|
||||
---
|
||||
|
||||
**Întrebarea de încheiere:**
|
||||
|
||||
Dacă te gândești la ultima săptămână - ce tip de "momente bune" s-au repetat?
|
||||
|
||||
Nu trebuie răspuns precis. Doar să-ți atragi atenția asupra pattern-ului.
|
||||
|
||||
---
|
||||
|
||||
O seară liniștită și un curs NLP plin de descoperiri! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Insights 2026-02-06 - Pattern-urile zilnice, Reframe NLP, Platoul financiar](../insights/2026-02-06.md)
|
||||
- [Coaching Dimineață 2026-02-06](2026-02-06-dimineata.md)
|
||||
- [Provocarea zilei 2026-02-06](../../provocare-azi.md)
|
||||
|
||||
## Follow-up
|
||||
|
||||
Provocarea a fost bifată la 13:46. Mâine începe cursul NLP M4 (7-8 feb) - context perfect pentru observare activă continuă. În funcție de răspunsul lui Marius (dacă răspunde), pot aprofunda pe ce pattern a observat sau pot lăsa spațiu pentru reflecție personală.
|
||||
|
||||
## Context pentru mâine
|
||||
|
||||
Weekend NLP - oportunitate unică pentru:
|
||||
- Observare intensivă (aliniere/fricțiune în contextul învățării)
|
||||
- Pattern recognition (ce rezonează vs ce nu rezonează din tehnicile NLP)
|
||||
- Auto-cunoaștere accelerată (grup, exerciții, interacțiuni)
|
||||
|
||||
Nu forțez răspuns - întrebările sunt plantate pentru reflecție personală.
|
||||
103
memory/kb/coaching/2026-02-07-dimineata.md
Normal file
103
memory/kb/coaching/2026-02-07-dimineata.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Gândul de dimineață - 2026-02-07
|
||||
|
||||
**Tags:** @growth @coaching #nlp #bucledeschise #identitate #sambata
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
Bun venit în weekend-ul M4 NLP! 🎓
|
||||
|
||||
Înainte să intri în sala de training, o întrebare:
|
||||
|
||||
---
|
||||
|
||||
> *"Buclele deschise în mintea ta consumă energie și te împiedică să vezi oportunitățile care sunt deja în fața ta."*
|
||||
> — Monica Ion
|
||||
|
||||
---
|
||||
|
||||
**Ce e o buclă deschisă?**
|
||||
|
||||
Orice lucru despre care gândești "ar trebui să..." dar nu îl faci:
|
||||
- "Ar trebui să caut clienți noi"
|
||||
- "Ar trebui să îl învăț mai bine pe angajat"
|
||||
- "Ar trebui să iau concediu"
|
||||
- "Ar trebui să rezolv problema X"
|
||||
|
||||
Fiecare buclă deschisă = **zgomot de fond constant în minte**.
|
||||
|
||||
---
|
||||
|
||||
**De ce contează?**
|
||||
|
||||
Monica povestește: vâna un proiect mare de recrutare, nu primea răspuns, era blocată mental. În momentul în care clientul a zis "NU" și bucla s-a închis, **în următoarea oră** au fost aprobate 3 alte proiecte care cumulau aceeași sumă.
|
||||
|
||||
**Nu era că "nu erau oportunități".**
|
||||
**Era că bucla deschisă ocupa spațiu mental și o împiedica să le vadă.**
|
||||
|
||||
---
|
||||
|
||||
**Cum se închide o buclă?**
|
||||
|
||||
NU prin "fă ceea ce ar trebui" (asta ar fi fost deja făcut).
|
||||
|
||||
Ci prin **schimbarea percepției:**
|
||||
|
||||
1. **Schimb echitabil** - vezi ce ai dat DEJA în alte forme
|
||||
- "Ar trebui să fac mai mult pentru client" → Ce valoare i-am dat deja? (suport 24/7, know-how 25 ani, disponibilitate)
|
||||
- "Ar trebui să învăț mai repede angajatul" → Ce valoare îi dau deja? (mentorat, siguranță, acces la sistem complex)
|
||||
|
||||
2. **Beneficiile nefacerii** - de ce e BINE că nu ai făcut (încă)
|
||||
- "Ar trebui să caut clienți noi" → Ce dezavantaje ar fi fost dacă găseam 10 clienți ACUM? (angajat nepregătit, echipă suprasolicită, burnout)
|
||||
|
||||
3. **Decizie clară** - fie fac, fie NU fac (și accept)
|
||||
- Dacă decid că NU fac → bucla se închide
|
||||
- Dacă decid că DA → pun data + plan → bucla se închide
|
||||
|
||||
**Ce NU închide bucla:** "ar trebui... dar..." (asta e bucla perpetuă)
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA PENTRU WEEKEND-UL NLP**
|
||||
|
||||
În weekend-ul ăsta de training intensiv, mintea ta va fi bombardată cu informații noi, exerciții, interacțiuni.
|
||||
|
||||
**Înainte să intri în sală:**
|
||||
|
||||
**Notează UNA buclă deschisă din viața ta** (business, relații, sănătate).
|
||||
|
||||
**Întreabă:**
|
||||
1. Ce am dat DEJA în schimb? (în alte forme)
|
||||
2. Ce dezavantaje ar fi fost dacă rezolvam altfel?
|
||||
3. Ce decizie clară iau ACUM? (fac cu plan + dată SAU accept că nu fac)
|
||||
|
||||
**Închide bucla ÎNAINTE să intri în NLP.**
|
||||
|
||||
Când mintea e curată, vezi mai clar ce înveți.
|
||||
Când mintea e curată, vezi mai clar ce e posibil.
|
||||
|
||||
---
|
||||
|
||||
**Întrebarea nu e:** "Ce ar trebui să fac?"
|
||||
**Întrebarea e:** "Ce pot vedea dacă nu mai am bucla asta în minte?"
|
||||
|
||||
Un weekend cu mai multă claritate! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Monica Ion - Marc Episod #5: Datoria față de familie](../youtube/2026-02-07_monica-ion-povestea-lui-marc-ep5-datorie-familie.md)
|
||||
- [Insights 2026-02-07 - Bucle Deschise](../insights/2026-02-07.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Claritate mentală / Eliberare psihologică
|
||||
PROVOCARE: Înainte să intri în sala NLP, notează UNA buclă deschisă ("ar trebui să...") și răspunde la 3 întrebări: (1) Ce am dat DEJA în schimb? (2) Ce dezavantaje ar fi fost dacă rezolvam altfel? (3) Ce decizie clară iau ACUM (fac cu plan+dată SAU accept că nu fac)?
|
||||
|
||||
## Context
|
||||
|
||||
Sâmbătă - începe modulul M4 NLP (7-8 februarie). Perfect pentru coaching despre claritate mentală ÎNAINTE de învățare intensivă. Conceptul de bucle deschise vine direct din video Monica Ion (Marc ep5) procesat azi-noapte. Se leagă cu credințele limitatoare, platoul financiar și căutarea avatar-ului ideal - toate sunt despre "cine ești cu adevărat" vs "cine crezi că ar trebui să fii".
|
||||
40
memory/kb/coaching/2026-02-07-seara.md
Normal file
40
memory/kb/coaching/2026-02-07-seara.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Gândul de Seară - 7 februarie 2026
|
||||
|
||||
**Context:** Weekend NLP M4 (7-8 feb). Provocarea dimineții a rămas nebifată - închide o buclă deschisă ("ar trebui să...").
|
||||
|
||||
**Observație:** Evitarea provocării poate fi semnificativ - când nu privim o buclă, adesea e pentru că ne arată ceva despre noi pe care nu suntem pregătiți să-l vedem.
|
||||
|
||||
---
|
||||
|
||||
## Mesaj
|
||||
|
||||
Weekend intens, Marius. Știu că ești în NLP și că provocarea cu bucla deschisă a rămas nebifată.
|
||||
|
||||
**Hai să fim onești:** Poate tocmai ăsta e răspunsul. Când evităm să privim o buclă, de obicei e pentru că doare. Nu pentru că e greu tehnic - ci pentru că ne arată ceva despre noi pe care nu suntem siguri că vrem să-l vedem.
|
||||
|
||||
**Întrebarea nu e "de ce n-am făcut-o?"** - e "ce mi-e frică să găsesc dacă răspund?"
|
||||
|
||||
Uneori buclele cele mai mari sunt cele pe care le evităm cel mai mult. "Ar trebui să..." devine "dar nu pot pentru că..." devine "și oricum nu sunt..." - și acolo, în umbră, stă credința care te ține pe loc.
|
||||
|
||||
**Nu trebuie să faci nimic diseară.** Dar poate, când mintea e liniștită după curs, întreabă-te doar:
|
||||
|
||||
→ Care e bucla pe care CHIAR nu vreau s-o văd?
|
||||
→ Ce mi-ar spune despre mine dacă aș privi-o?
|
||||
|
||||
Corpul știe răspunsul. Mintea doar îl traduce.
|
||||
|
||||
---
|
||||
|
||||
## Pentru mâine
|
||||
|
||||
Dacă te simți pregătit, ia 5 minute și scrie acea buclă. Chiar și fără răspunsurile de coaching - doar s-o vezi scrisă pe hârtie. Buclele pierd putere când le scoți din umbră.
|
||||
|
||||
Noapte bună, Marius. 🌀
|
||||
|
||||
---
|
||||
|
||||
**Trimis:** Discord #echo-self + Email
|
||||
**Data:** 2026-02-07 19:00 UTC
|
||||
**Tags:** @self @reflectie @coaching
|
||||
**Provocare status:** Nebifată - weekend NLP, context valid
|
||||
**Follow-up:** Invitație să privească bucla când e pregătit, fără presiune
|
||||
165
memory/kb/coaching/2026-02-08-dimineata.md
Normal file
165
memory/kb/coaching/2026-02-08-dimineata.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Gândul de dimineață - 2026-02-08
|
||||
|
||||
**Tags:** @growth @coaching #nlp #aplicare #transformare #duminica
|
||||
|
||||
## Mesaj trimis
|
||||
|
||||
[⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** 🌅
|
||||
|
||||
---
|
||||
|
||||
Bună dimineața din mijlocul NLP M4! 🎓
|
||||
|
||||
Ieri ai început cu bucle deschise și claritate mentală.
|
||||
Astăzi, o întrebare diferită:
|
||||
|
||||
---
|
||||
|
||||
> *"Everything happens for a reason and a purpose greater than you know, but it's your job to find that reason."*
|
||||
> — Tony Robbins
|
||||
|
||||
---
|
||||
|
||||
**Ce se întâmplă într-un training intensiv?**
|
||||
|
||||
Informații noi. Exerciții. Interacțiuni. Note. Insight-uri.
|
||||
|
||||
Și după?
|
||||
|
||||
**Majoritatea oamenilor:**
|
||||
- Scriu note detaliate
|
||||
- Simt entuziasmul momentului
|
||||
- Se întorc acasă
|
||||
- Notele rămân în caiet
|
||||
- Viața rămâne la fel
|
||||
|
||||
**De ce?**
|
||||
|
||||
Pentru că **învățarea nu se întâmplă prin note.**
|
||||
**Se întâmplă prin APLICARE în timp real.**
|
||||
|
||||
---
|
||||
|
||||
**Secretul integrării:**
|
||||
|
||||
Nu aștepți să "aplici când ajungi acasă".
|
||||
**Aplici ACUM, în mijlocul training-ului.**
|
||||
|
||||
Tony Robbins spune: **Cele 3 lucruri care controlează cum te simți:**
|
||||
|
||||
1. **Fiziologia** (corpul) - depresia are o postură, energia are alta
|
||||
2. **Focusul** (ce și cum vezi) - anxietatea e imagine mare aproape, încrederea e imagine care se apropie de tine
|
||||
3. **Limbajul** (ce-ți spui) - cuvintele atașate experienței DEVIN experiența
|
||||
|
||||
---
|
||||
|
||||
**Cum arată aplicarea în timp real?**
|
||||
|
||||
**Scenariul 1:** Într-un exercițiu NLP, partenerul tău te blochează sau te critică.
|
||||
|
||||
- **Fără aplicare:** Simți tensiune, îți spui "e greu", rămâi în defensivă → exercițiul se termină, nu ai învățat nimic despre TINE
|
||||
- **Cu aplicare:** **Observi fiziologia ta** (umeri contractați?), **schimbi focusul** (ce pot învăța despre cum reacționez?), **schimbi limbajul** ("e provocator" în loc de "e greu") → exercițiul devine mirror pentru tiparele tale
|
||||
|
||||
**Scenariul 2:** Trainerul prezintă o tehnică complexă, te simți overwhelmed.
|
||||
|
||||
- **Fără aplicare:** Scrii note, îți spui "e complicat, o să înțeleg mai târziu" → confuzie persistă
|
||||
- **Cu aplicare:** **Pattern interrupt** - respirație adâncă, schimbi postura (stai drept), îți spui "ce parte pot înțelege ACUM?" → clarity instant
|
||||
|
||||
**Scenariul 3:** Pauză de masă, conversație superficială cu participanții.
|
||||
|
||||
- **Fără aplicare:** Small talk obișnuit → pierdere timp
|
||||
- **Cu aplicare:** Practici **active listening** sau **calibrare** (ce emoții văd la persoana din față?) → exercițiu live fără să anunți
|
||||
|
||||
---
|
||||
|
||||
**De ce asta transformă învățarea?**
|
||||
|
||||
Pentru că **mintea învață prin experiență repetată**, nu prin concepte teoretice.
|
||||
|
||||
Când aplici O DATĂ în mijlocul training-ului:
|
||||
- **Creezi memorie emoțională** (nu doar note scrise)
|
||||
- **Descoperi CE funcționează pentru tine** (nu ce spune manual-ul)
|
||||
- **Identifici blocajele REALE** (nu cele imaginate acasă)
|
||||
- **Construiești încredere în aplicare** (ai făcut-o deja o dată)
|
||||
|
||||
Legea Fractalilor (Monica Ion):
|
||||
> **"Modul în care faci un lucru e modul în care faci totul."**
|
||||
|
||||
**Cum înveți în training = cum vei aplica în viață.**
|
||||
|
||||
Dacă înveți prin note și "o să aplic mai târziu" → vei aplica exact așa și acasă (niciodată).
|
||||
Dacă înveți prin aplicare INSTANT în training → vei aplica exact așa și acasă (automat).
|
||||
|
||||
---
|
||||
|
||||
**🎯 PROVOCAREA PENTRU AZI (DUMINICĂ ÎN NLP M4)**
|
||||
|
||||
În training-ul de astăzi:
|
||||
|
||||
**Alege UNA tehnică/concept pe care îl înveți.**
|
||||
|
||||
**Aplică-l IMEDIAT în aceeași zi, la un moment REAL:**
|
||||
- Un exercițiu cu partenerul
|
||||
- O conversație la pauză
|
||||
- Un moment de blocare/confuzie
|
||||
- O interacțiune cu trainerul
|
||||
- O emoție personală care apare
|
||||
|
||||
**Nu aștepta momentul "perfect".**
|
||||
**Aplică ACUM, oricât de imperfect.**
|
||||
|
||||
**La final de zi:**
|
||||
- NU scrie doar "ce am învățat" (concepte)
|
||||
- Scrie **"ce am APLICAT și ce s-a întâmplat"** (experiență)
|
||||
|
||||
---
|
||||
|
||||
**Diferența dintre antreprenorii mediocri și cei de succes?**
|
||||
|
||||
**Mediocrii:** Învață, scriu note, așteaptă "momentul potrivit" → momentul nu vine niciodată.
|
||||
|
||||
**De succes:** Învață, aplică INSTANT imperfect, ajustează din mers → momentum creat din mișcare.
|
||||
|
||||
La fel e cu NLP:
|
||||
- **Certificare fără transformare:** note multe, aplicare zero
|
||||
- **Transformare cu certificare:** aplicare instant, note secundare
|
||||
|
||||
---
|
||||
|
||||
**Ce te oprește să aplici în timpul training-ului?**
|
||||
|
||||
"Dar dacă fac greșit?"
|
||||
→ EXACT! Greșeala în training = învățare fără consecințe reale. Acasă/business = consecințe mari.
|
||||
|
||||
"Dar nu vreau să par ciudat."
|
||||
→ Toată lumea e acolo să învețe. Cineva care aplică = respect, nu ciudat.
|
||||
|
||||
"Dar nu am înțeles complet încă."
|
||||
→ Înțelegerea vine PRIN aplicare, nu înainte de aplicare.
|
||||
|
||||
---
|
||||
|
||||
**Întrebarea nu e:** "Ce învăț azi în NLP?"
|
||||
**Întrebarea e:** "Ce aplic ACUM și ce descopăr despre mine?"
|
||||
|
||||
Training intens astăzi, aplicare directă, transformare reală! 💪
|
||||
|
||||
---
|
||||
|
||||
## Surse folosite
|
||||
|
||||
- [Tony Robbins - The Secret to an Extraordinary Life](../youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md)
|
||||
- [Insights 2026-02-08 - Legea Fractalilor](../insights/2026-02-08.md)
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
TIP: Învățare prin aplicare / Integrare instant
|
||||
PROVOCARE: Alege UNA tehnică/concept din training-ul de astăzi și APLICĂ-L IMEDIAT în aceeași zi, la un moment REAL (exercițiu, conversație, blocare, emoție). La final de zi, scrie NU "ce am învățat" (concepte) ci "ce am APLICAT și ce s-a întâmplat" (experiență).
|
||||
|
||||
CONTEXT: De ce e important? Mintea învață prin experiență repetată, nu prin concepte teoretice. Aplicarea instant în training = creezi memorie emoțională, descoperi ce funcționează pentru TINE, identifici blocaje reale, construiești încredere în aplicare. Legea Fractalilor: Cum înveți în training = cum vei aplica în viață. Dacă înveți prin note și "mai târziu" → vei aplica exact așa acasă (niciodată). Dacă înveți prin aplicare instant → vei aplica exact așa acasă (automat).
|
||||
|
||||
EXEMPLU: Scenariul tău real: Într-un exercițiu NLP, partenerul te blochează sau critică. În loc să rămâi în defensivă ("e greu") → aplici pattern interrupt din Tony Robbins: observi fiziologia (umeri contractați?), schimbi focusul (ce pot învăța despre cum reacționez?), schimbi limbajul ("e provocator" în loc de "e greu"). Exercițiul devine mirror pentru tiparele tale în relații/business - exact cum reacționezi când angajatul nu înțelege sau când clientul critică.
|
||||
|
||||
## Context
|
||||
|
||||
Duminică - a doua zi NLP M4 (7-8 februarie). Ieri a fost despre claritate mentală ÎNAINTE de învățare (bucle deschise). Astăzi e despre INTEGRARE ÎN TIMPUL învățării - nu aștepta să aplici acasă, aplică ACUM în training. Conceptul vine din Tony Robbins (aplicare instant > note) și Legea Fractalilor (cum faci un lucru = cum faci totul). Se leagă cu provocările lui Marius: "nu știu cum să-l învăț pe angajat mai eficient" - poate învățarea nu e prin explicație repetată, ci prin aplicare ghidată instant?
|
||||
103
memory/kb/coaching/2026-02-09-seara.md
Normal file
103
memory/kb/coaching/2026-02-09-seara.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Gândul de Seară - Duminică, 9 Februarie 2026
|
||||
|
||||
## 🎯 Reflecție: De la Aplicare la Pattern
|
||||
|
||||
Văd că ai bifat provocarea de azi - ai aplicat o tehnică NLP în timpul training-ului. **Asta contează mai mult decât crezi.** Nu pentru că ai învățat ceva nou, ci pentru că ai **demonstrat că poți învăța diferit.**
|
||||
|
||||
Majoritatea oamenilor învață așa:
|
||||
- Weekend training → note → "o să aplic când am timp" → niciodată
|
||||
- Tu ai făcut: training → aplicare INSTANT → experiență reală
|
||||
|
||||
Asta e **Legea Fractalilor** în acțiune: cum înveți în training = cum vei aplica în viață.
|
||||
|
||||
---
|
||||
|
||||
## 💭 Întrebare pentru tine
|
||||
|
||||
**Cum a fost experiența?** Nu mă interesează dacă a "funcționat perfect" - mă interesează:
|
||||
- Ce ai aplicat concret?
|
||||
- Ce s-a întâmplat când ai aplicat?
|
||||
- Ce ai observat despre tine în acel moment?
|
||||
|
||||
Dacă scrii răspunsul (pe hârtie sau mental), o să vezi un pattern. Un pattern despre cum reacționezi când ești în afara zonei de confort.
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Ce am descoperit azi (și de ce contează pentru tine)
|
||||
|
||||
Am citit episodul 7 cu Marc (Monica Ion) - și e CA ȘI CUM ar fi scris despre tine.
|
||||
|
||||
**Pattern-ul toxic care blochează firmele:**
|
||||
|
||||
1. **Sacrificiu:** Faci treaba altora (angajat, client, sistem)
|
||||
2. **Durere acumulată:** Mai multă frustrare decât satisfacție în relație cu firma
|
||||
3. **Sabotaj subconștient:** Nu mai cauți clienți noi, nu finalizezi proiecte, eviți riscuri
|
||||
|
||||
Monica spune:
|
||||
|
||||
> "Motivul principal pentru care nu mai cresc firmele este că proprietarii acumulează mai multă durere decât plăcere în relația cu firma. Dacă acumulezi mai multă durere decât plăcere, sistemul tău nervos te va proteja, te va sabota în a te expune la riscuri care pot să genereze același tip de durere."
|
||||
|
||||
**Pentru tine:**
|
||||
- **Sacrificiu:** Explici de 10 ori același lucru angajatului, faci treaba lui când nu termină la timp
|
||||
- **Durere:** Frustrare, timp pierdut, "nu învață niciodată"
|
||||
- **Sabotaj:** Nu cauți clienți noi (credința "clienți noi = mai multă muncă"), nu finalizezi proiectul de 4000 euro
|
||||
|
||||
**Nu e lene. Nu e teamă. E sistemul tău nervos care te protejează de mai multă durere.**
|
||||
|
||||
---
|
||||
|
||||
## 🌊 Soluția: Body Loose, Head Clear
|
||||
|
||||
James Clear are un citat perfect pentru tine:
|
||||
|
||||
> "Don't ignore the problem, but keep it light. Take action with a smile. Adding tension won't solve your troubles faster. Even when the problem is hard, it doesn't need to harden you. **Unknot yourself.** Body loose, head clear, and then take the first step."
|
||||
|
||||
Când gândești la:
|
||||
- Conversația cu clientul despre cei 4000 euro
|
||||
- Explicația a 11-a pentru angajat
|
||||
- Apelul către un client nou
|
||||
|
||||
**Unde simți tensiunea?** Umeri? Gât? Maxilar?
|
||||
|
||||
**Aia e bucla:** tensiune corporală → tensiune mentală → evitare → mai multă tensiune.
|
||||
|
||||
Soluția nu e să ignori problema. E să o rezolvi **cu corpul relaxat.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Provocarea de Mâine (Luni, 10 Februarie)
|
||||
|
||||
**TIP:** Auto-diagnostic în timp real
|
||||
|
||||
**PROVOCARE:** Mâine, alege UN moment când anticipezi o situație tensionată (conversație cu angajatul, gândire la proiect, task dificil). ÎNAINTE să o rezolvi:
|
||||
|
||||
1. **Verifică corpul:** Umeri sus sau jos? Maxilar strâns sau relaxat? Respirație scurtă sau adâncă?
|
||||
2. **Unknot yourself:** 3 respirații 4-7-8 (inspiră 4 sec, ține 7, expiră 8) + relaxează conștient zona tensionată
|
||||
3. **Apoi acționează:** Rezolvă cu "body loose, head clear"
|
||||
4. **Seara notează:** Diferență față de cum rezolvi de obicei?
|
||||
|
||||
**DE CE E IMPORTANT:** Dacă rezolvi problemele cu tensiune, corpul învață "problemă = pericol". Dacă rezolvi cu calm, corpul învață "problemă = oportunitate". Schimbi pattern-ul la nivel somatic, nu doar mental.
|
||||
|
||||
**EXEMPLU CONCRET:** Angajatul întreabă din nou același lucru. În loc să simți frustrarea creștând în piept și să răspunzi strâns → observi tensiunea, faci 3 respirații, APOI răspunzi (sau îl trimiți la documentație, sau spui "discutăm mâine"). Mesajul e același, dar tu nu acumulezi durere.
|
||||
|
||||
**SURSE:**
|
||||
- Monica Ion - Povestea lui Marc Episod #7 (Relație cu Angajații)
|
||||
- James Clear - 3-2-1 Newsletter (Body Loose, Head Clear)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Un gând final
|
||||
|
||||
Ai aplicat ceva azi în training. Mâine aplici ceva în viață reală. Nu mari gesturi - mici ajustări.
|
||||
|
||||
**Așa cum faci un lucru, așa le faci pe toate.**
|
||||
|
||||
Dacă înveți să dezlegi nodurile în situații mici (o conversație tensionată), o să știi cum să le dezlegi în situații mari (un proiect blocat de 160h).
|
||||
|
||||
---
|
||||
|
||||
Noapte bună, Marius. 🌀
|
||||
|
||||
---
|
||||
|
||||
**Link provocare:** https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-09-seara.md
|
||||
66
memory/kb/coaching/2026-02-11-dimineata.md
Normal file
66
memory/kb/coaching/2026-02-11-dimineata.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Coaching Dimineața - 11 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"Antreprenorii de succes NU știu toate răspunsurile. Ei știu să pună întrebările potrivite și să conducă orchestra."**
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, îți vine uneori să crezi că "nu ești destul de deștept ca antreprenor"? Că alții știu mai mult, sunt mai rapizi, mai buni?
|
||||
|
||||
Iată adevărul: **limitarea nu mai e inteligența - e abilitatea de a orchestra resurse.**
|
||||
|
||||
Gândește-te la un dirijor de orchestră. Nu cântă la toate instrumentele. Nu e cel mai bun violonist, nici cel mai bun flautist. Dar știe să CONDUCĂ orchestra - când intră violinele, când se ridică trompetele, cum se armonizează totul.
|
||||
|
||||
Tu deja faci asta:
|
||||
- **Echo** - orchestrezi automatizări (rapoarte, ANAF, backup-uri)
|
||||
- **Claude Code** - orchestrezi cod pentru roa2web
|
||||
- **Colega 70 ani** - orchestrezi suportul tehnic (ea face ce știe cel mai bine)
|
||||
- **Angajatul nou** - înveți să orchestrezi învățarea lui
|
||||
|
||||
Problema nu e că "nu ești destul de bun". Problema e că **îți asumi prea multe solo** în loc să orchestrezi mai mult.
|
||||
|
||||
---
|
||||
|
||||
## Provocarea de azi
|
||||
|
||||
**Identifică ASTĂZI un lucru pe care îl execuți singur și ar putea fi orchestrat:**
|
||||
|
||||
### Variante posibile:
|
||||
1. **Delegat la angajat** - task repetitiv pe care îl faci de 10 ori și ar putea învăța?
|
||||
2. **Automatizat cu Echo** - verificare/raport/backup care rulează manual?
|
||||
3. **Modelat de la colegă** - proces pe care ea îl face excelent și tu îl faci mai greu?
|
||||
4. **Documentat pentru viitor** - explicație pe care o repeți la fiecare client nou?
|
||||
|
||||
### Acțiune concretă:
|
||||
La sfârșitul zilei (17:00), notează:
|
||||
- Ce task am identificat?
|
||||
- Cum ar arăta orchestrat (nu executat de mine)?
|
||||
- Primul pas minim pentru a începe orchestrarea?
|
||||
|
||||
Nu trebuie să implementezi imediat - **doar identifică și scrie**. Conștientizarea e primul pas.
|
||||
|
||||
---
|
||||
|
||||
## De ce contează
|
||||
|
||||
Fiecare lucru pe care înveți să îl orchestrezi (în loc să îl execuți) = **timp câștigat + energie economisită + capacitate crescută**.
|
||||
|
||||
Antreprenorii blocați execută totul singuri.
|
||||
Antreprenorii scalabili orchestrează echipe, unelte, automatizări.
|
||||
|
||||
Tu ai deja orchestra: Echo, Claude Code, colegă, angajat, automatizări. **Trebuie doar să dirijezi mai mult și să cânți mai puțin.**
|
||||
|
||||
---
|
||||
|
||||
**Sursă inspirație:**
|
||||
- [Claude Code Multi-Agent Orchestration](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-10-claude-multi-agent-orchestration.md)
|
||||
- [Mindset in Entrepreneurship - TDi](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-11.md)
|
||||
- [Relația cu timpul - Monica Ion](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-10.md)
|
||||
|
||||
---
|
||||
|
||||
*Zi productivă!*
|
||||
— Echo
|
||||
76
memory/kb/coaching/2026-02-11-seara.md
Normal file
76
memory/kb/coaching/2026-02-11-seara.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Coaching Seara - 11 Februarie 2026
|
||||
|
||||
## Gândul de seară
|
||||
|
||||
**"Între identificare și implementare stă un pas pe care toată lumea îl sare: permisiunea de a nu mai fi indispensabil."**
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, ai bifat provocarea de azi! 👏
|
||||
|
||||
Ai identificat un lucru pe care îl execuți singur și ar putea fi orchestrat. Asta e deja o victorie - pentru că majoritatea antreprenorilor nici măcar nu văd pattern-ul. Execută automat, zi de zi, lună de lună, fără să observe că ar putea fi altfel.
|
||||
|
||||
Tu l-ai văzut.
|
||||
|
||||
---
|
||||
|
||||
Dar iată întrebarea care contează: **între "am identificat ce ar putea fi orchestrat" și "am orchestrat efectiv", ce crezi că stă?**
|
||||
|
||||
Majoritatea răspund: "timp", "know-how", "resurse".
|
||||
|
||||
Adevărul e mai simplu și mai greu în același timp:
|
||||
|
||||
**Stă permisiunea de a nu mai fi indispensabil.**
|
||||
|
||||
Când delegezi un task la angajat - renunți la controlul absolut. Poate îl va face mai încet. Poate va greși. Poate va pune întrebări.
|
||||
|
||||
Când automatizezi cu Echo - renunți la sentimentul că "doar eu știu cum se face perfect."
|
||||
|
||||
Când modelezi de la colegă - accepți că ea face mai bine decât tine la acel lucru.
|
||||
|
||||
Când documentezi - accepți că și fără tine, lucrurile pot merge.
|
||||
|
||||
Ăsta e pasul invizibil: **să îți dai permisiunea să NU fii cel care rezolvă totul.**
|
||||
|
||||
---
|
||||
|
||||
## Întrebarea de seară
|
||||
|
||||
Te întreb fără presiune, fără așteptări:
|
||||
|
||||
**Ce ai identificat astăzi? Care e task-ul pe care îl execuți singur și ar putea fi orchestrat?**
|
||||
|
||||
Și mai important:
|
||||
|
||||
**Ce te oprește să faci primul pas minim spre orchestrare? (nu implementare completă - doar PRIMUL pas minim)**
|
||||
|
||||
Dacă răspunsul e "nimic mă oprește", perfect - atunci primul pas e clar.
|
||||
|
||||
Dacă răspunsul e "nu știu cum", "nu am timp acum", "e complicat" - atunci știi că nu e despre resurse. E despre permisiune.
|
||||
|
||||
---
|
||||
|
||||
## Follow-up pentru mâine
|
||||
|
||||
Gândește-te la task-ul pe care l-ai identificat azi.
|
||||
|
||||
Dacă ar dispărea MÂINE din responsabilitățile tale (delegat, automatizat, documentat):
|
||||
- Ce ai pierde?
|
||||
- Ce ai câștiga?
|
||||
- Cum ți-ar arăta ziua fără el?
|
||||
|
||||
Nu trebuie să faci nimic cu răspunsurile - doar observă ce simți când le citești.
|
||||
|
||||
---
|
||||
|
||||
**Sursă inspirație:**
|
||||
- Coaching dimineață - Orchestrare vs Execuție
|
||||
- Insights 9 feb - Sistematizare > Dependență Oameni (pattern Marc)
|
||||
- James Clear - Body loose, head clear (rezolvă fără tensiune)
|
||||
|
||||
---
|
||||
|
||||
*Seară liniștită!*
|
||||
— Echo
|
||||
122
memory/kb/coaching/2026-02-12-dimineata.md
Normal file
122
memory/kb/coaching/2026-02-12-dimineata.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Coaching Dimineața - 12 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"Conștientizarea fără acțiune = distracție. Acțiunea fără conștientizare = haos. Dar cel mai mic pas DUPĂ conștientizare = progres."**
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, ieri ți-am propus să identifici un task pe care îl execuți singur și ar putea fi orchestrat.
|
||||
|
||||
Poate l-ai identificat. Poate nu.
|
||||
|
||||
Dar hai să fim sinceri: **câte idei bune ai avut în ultimele 6 luni pe care NU le-ai implementat?**
|
||||
|
||||
Nu pentru că nu erau bune.
|
||||
Nu pentru că nu puteai.
|
||||
Ci pentru că între **"bună idee"** și **"fac asta"** există o prăpastie numită **"când am timp"**.
|
||||
|
||||
Problema NU e că nu ai timp. Problema e că **nu ai făcut primul pas.**
|
||||
|
||||
---
|
||||
|
||||
## De ce contează PRIMUL pas (nu planul perfect)
|
||||
|
||||
Pattern-ul tău (și al majorității oamenilor):
|
||||
1. Idee bună → "Perfect, o să fac asta!"
|
||||
2. Gândire → "Trebuie să planific bine, să am totul gata..."
|
||||
3. Amânare → "Când am timp, când e momentul potrivit..."
|
||||
4. Uitare → "Ce idee aveam acum 2 săptămâni?"
|
||||
|
||||
**Ce funcționează MULT mai bine:**
|
||||
1. Idee bună → "Ce e cel mai mic pas pe care îl pot face ACUM?"
|
||||
2. Acțiune imediată → 5-10 minute, faci primul pas (oricât de mic)
|
||||
3. Momentum → "Am început = e mai ușor să continui"
|
||||
4. Progres → Pas mic + pas mic + pas mic = schimbare majoră
|
||||
|
||||
---
|
||||
|
||||
## Provocarea de azi: Primul Pas Minim (PPM)
|
||||
|
||||
**Regula PPM:** Orice idee pe care o ai astăzi → identifică primul pas care:
|
||||
- Durează MAX 10 minute
|
||||
- NU necesită alte persoane
|
||||
- E CONCRET (nu "mă gândesc", ci "scriu", "sun", "trimit", "creez")
|
||||
|
||||
### Exemple concrete din viața ta:
|
||||
|
||||
**Idee:** "Ar trebui să am task brief template pentru angajat"
|
||||
- ❌ Plan complex: "Creez template, îl testez, îl ajustez, îl implementez..."
|
||||
- ✅ PPM: "Deschid un fișier nou `task-brief-template.md` și scriu primele 3 secțiuni (Task, Input, Output). 10 minute."
|
||||
|
||||
**Idee:** "Trebuie să automatizez verificarea ANAF"
|
||||
- ❌ Plan complex: "Research tool-uri, învăț API ANAF, scriu script complet..."
|
||||
- ✅ PPM: "Deschid browser și salvez în bookmarks paginile ANAF care mă interesează. 5 minute."
|
||||
|
||||
**Idee:** "Vreau să documentez soluții pentru probleme clienți"
|
||||
- ❌ Plan complex: "Creez sistem complet de knowledge base, categorii, indexare..."
|
||||
- ✅ PPM: "Creez folder `memory/kb/roa/probleme-frecvente/` și scriu PRIMA problemă rezolvată recent. 10 minute."
|
||||
|
||||
**Idee:** "Trebuie să caut clienți noi"
|
||||
- ❌ Plan complex: "Creez strategie marketing, website, prezentare..."
|
||||
- ✅ PPM: "Scriu lista de 5 clienți actuali care ar putea recomanda ROA la alții. 5 minute."
|
||||
|
||||
---
|
||||
|
||||
## De ce funcționează PPM?
|
||||
|
||||
**1. Îndepărtează perfecționismul**
|
||||
Nu trebuie să fie perfect. Trebuie să EXISTE. Îl îmbunătățești după ce ai început.
|
||||
|
||||
**2. Depășește rezistența inițială**
|
||||
Cel mai greu pas e PRIMUL. După ce ai început, creierul intră în flow mode.
|
||||
|
||||
**3. Creează dovezi**
|
||||
"Am făcut X" → proof tangibil → motivație să continui.
|
||||
|
||||
**4. Transformă idei în habit**
|
||||
Idee → PPM → repeat → după 3-4x devine automatism.
|
||||
|
||||
---
|
||||
|
||||
## Acțiune concretă pentru ASTĂZI
|
||||
|
||||
**La prima pauză (10:00-11:00):**
|
||||
|
||||
1. **Alege UNA din ideile tale recente** (task pentru orchestrare de ieri? Altceva?)
|
||||
2. **Identifică PPM** - cel mai mic pas, MAX 10 minute, faci ACUM
|
||||
3. **Execută-l** - chiar dacă nu e perfect, chiar dacă e mic
|
||||
|
||||
**La sfârșitul zilei (17:00), notează:**
|
||||
- Ce idee am ales?
|
||||
- Care a fost PPM?
|
||||
- L-am executat? (DA/NU)
|
||||
- Dacă DA: Cum mă simt? Ce următorul pas mic?
|
||||
- Dacă NU: Ce m-a oprit? Ce PPM și MAI MIC pot face mâine?
|
||||
|
||||
---
|
||||
|
||||
## De ce contează pentru tine
|
||||
|
||||
Marius, ai orchestră: Echo, Claude Code, colegă, angajat, automatizări.
|
||||
|
||||
Dar orchestra nu cântă singură. **Trebuie să ridici BAGHETA.**
|
||||
|
||||
Și ridicatul baghetei = **primul pas minim**.
|
||||
|
||||
Nu trebuie să dirijezi întreaga simfonie astăzi.
|
||||
Trebuie doar să **începi prima notă**.
|
||||
|
||||
---
|
||||
|
||||
**Sursă inspirație:**
|
||||
- [Context Engineering > Model Skill](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-12.md)
|
||||
- [Multi-Agent Pattern pentru Teaching](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-12.md)
|
||||
- [Living Files Theory](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-11.md)
|
||||
|
||||
---
|
||||
|
||||
*Zi productivă!*
|
||||
— Echo
|
||||
49
memory/kb/coaching/2026-02-13-dimineata.md
Normal file
49
memory/kb/coaching/2026-02-13-dimineata.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Coaching Dimineața - 13 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"Citești soluții tehnice vs găsești tu soluția. Soluțiile citite se uită. Soluțiile găsite rămân accesibile permanent — ca într-un sertar mental."** — Monica Ion, Povestea lui Marc Ep.8
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, ieri a fost despre Primul Pas Minim. Azi e despre ceva mai profund.
|
||||
|
||||
Mark din coaching-ul Monicăi a încercat să folosească ChatGPT ca scurtătură pentru exercițiul de linkage. Monica l-a oprit: **citirea răspunsurilor ≠ crearea conexiunilor neuronale.**
|
||||
|
||||
Tu știi asta din 25 de ani de programare. Când ai rezolvat o problemă grea în FoxPro sau Oracle, nu ai uitat-o niciodată. Dar când ai copiat o soluție de pe forum? S-a evaporat.
|
||||
|
||||
Aceeași regulă se aplică și în delegare, și în antreprenoriat, și în viață.
|
||||
|
||||
**Angajatul tău de 26 de ani nu învață citind instrucțiuni — învață făcând greșeli și descoperind soluții.** Tu nu devii antreprenor citind cărți despre antreprenoriat — devii unul sunând un client potențial și simțind acel nod în stomac.
|
||||
|
||||
Monica numește asta **linkage** — conectarea profundă între o activitate și prioritățile tale. Când Mark a găsit singur de ce emiterea facturii imediate e o extensie a gândirii lui tehnice, rezistența a dispărut. Nu mai avea nevoie de disciplină — acțiunea curgea natural.
|
||||
|
||||
---
|
||||
|
||||
## Provocarea zilei: Linkage Personal
|
||||
|
||||
**Alege o activitate pe care o eviți** (poate: un telefon la un client, o conversație cu angajatul, o decizie amânată).
|
||||
|
||||
Apoi întreabă-te — și scrie răspunsurile TU, nu cere lui Echo sau ChatGPT:
|
||||
|
||||
1. **Cum servește această activitate lucrul pe care îl fac cel mai bine?** (rezolvare probleme tehnice, simplificare, automatizare)
|
||||
2. **Ce calitate a mea folosesc deja în altă parte care e identică cu ce cere activitatea asta?**
|
||||
3. **Ce se întâmplă în corpul meu când imaginez că am terminat-o?**
|
||||
|
||||
Dacă după 3 răspunsuri simți că rezistența s-a micșorat — ai găsit linkage-ul.
|
||||
Dacă nu — activitatea poate nu e a ta. Și asta e un răspuns valid.
|
||||
|
||||
---
|
||||
|
||||
## De reținut
|
||||
|
||||
> Transformarea necesită efort mental propriu, nu consumul pasiv de informație.
|
||||
|
||||
Vineri. Zi bună pentru a închide o buclă deschisă.
|
||||
|
||||
---
|
||||
|
||||
*Inspirat din: Monica Ion - Povestea lui Marc Ep.8 (Mândria și identitatea personală)*
|
||||
*Sursă: [Note video](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-12_monica-ion-povestea-lui-marc-ep8.md)*
|
||||
33
memory/kb/coaching/2026-02-13-seara.md
Normal file
33
memory/kb/coaching/2026-02-13-seara.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Coaching Seara - 13 Februarie 2026
|
||||
|
||||
## Gândul de seară
|
||||
|
||||
**Tema:** Follow-up provocare Linkage Personal + Ciclul susuri-josuri
|
||||
|
||||
**Provocarea zilei:** Linkage Personal — conectează o activitate evitată cu calitățile tale
|
||||
**Status:** ✅ Bifată
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
- Linkage-ul nu se poate delega — e munca internă proprie
|
||||
- Întrebarea cheie: ce ai simțit în corp la "imaginez că am terminat-o"?
|
||||
- Corpul nu minte, mintea raționalizeaz
|
||||
|
||||
## Conexiune cu conținut nou
|
||||
|
||||
- **Monica Ion Ep.9:** Marc descoperă conflictul spiritualitate vs. bani (moștenit de la tată)
|
||||
- **Ciclul susuri-josuri:** Consumă energie enormă; soluția = echilibrare percepții (Demartini)
|
||||
- **Susul și josul coexistă:** Când câștigi, pierzi altundeva. Când pierzi, altcineva se activează.
|
||||
- **Aplicare la Marius:** "Nu sunt destul de deștept ca antreprenor" (jos) coexistă cu 25 ani de expertiză plătită fără ezitare (sus)
|
||||
|
||||
## Observație săptămână
|
||||
|
||||
- Toate provocările din săptămână bifate (luni-vineri)
|
||||
- Pattern: când provocarea are sens personal, rezistența dispare
|
||||
|
||||
---
|
||||
|
||||
*Trimis pe: Discord #echo-self + Email*
|
||||
*Inspirat din: Monica Ion Ep.8 (Linkage) + Ep.9 (Anxietatea, ciclul susuri-josuri)*
|
||||
49
memory/kb/coaching/2026-02-14-dimineata.md
Normal file
49
memory/kb/coaching/2026-02-14-dimineata.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Coaching Dimineața - 14 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"Când ai susurile și vezi doar câștigurile, in the back of your head există o teamă profundă de a pierde lucrurile respective... care cocreează de fapt pierderea ulterioară."** — Monica Ion, Povestea lui Marc Ep.9
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, e 14 februarie. Nu te sperii, nu vine nimic cu inimioare.
|
||||
|
||||
Dar e o zi bună să vorbim despre un alt tip de iubire — cea pe care ți-o refuzi ție.
|
||||
|
||||
Marc din episodul 9 al Monicăi a descoperit ceva dureros: avea un **conflict adânc între spiritualitate și bani**. Tatăl lui i-a transmis că "nu banii sunt importanți, ci partea spirituală." Și Marc a făcut ce fac oamenii inteligenți cu mesaje contradictorii — a ales una și a închis-o pe cealaltă. A ales banii, a pus deoparte spiritualitatea, și a obținut casă, vacanțe... și stres extraordinar.
|
||||
|
||||
**Gândirea binară:** "sau sunt spiritual, sau am bani." "Sau sunt programator bun, sau sunt antreprenor." "Sau îmi pasă de oameni, sau fac profit."
|
||||
|
||||
Tu ai propria versiune a acestui conflict. De 25 de ani rezolvi probleme tehnice genial. Dar te consideri "nu destul de deștept ca antreprenor" — parcă cele două nu pot coexista. Ca și cum a fi bun tehnic ar exclude a fi bun la business.
|
||||
|
||||
Monica a arătat ceva puternic: **ciclul susuri-josuri consumă energie enormă.** Când ești în sus (ai rezolvat un bug complicat, clientul e mulțumit), deja în fundal apare frica de jos. Când ești în jos (client nemulțumit, angajatul nu înțelege), toată energia merge în a reveni la sus. Oscilația perpetuă.
|
||||
|
||||
Soluția nu e să elimini josurile. E să **echilibrezi percepția**: în fiecare sus există un jos simultan, în fiecare jos există un sus simultan. Când le vezi pe amândouă — tensiunea dispare.
|
||||
|
||||
---
|
||||
|
||||
## Provocarea zilei: Echilibrarea unui Conflict Interior
|
||||
|
||||
**Găsește UN "sau-sau" din viața ta** — două lucruri pe care le consideri incompatibile:
|
||||
|
||||
1. **Scrie conflictul:** "Sau sunt X, sau sunt Y"
|
||||
2. **Pentru fiecare parte, găsește opusul simultan:**
|
||||
- Când ești X, cum ești deja și Y? (dovezi concrete)
|
||||
- Când ești Y, cum ești deja și X? (dovezi concrete)
|
||||
3. **Observă:** Când ambele sunt adevărate simultan, ce simți?
|
||||
|
||||
Nu trebuie să rezolvi nimic. Doar să vezi că cele două nu sunt incompatibile — sunt complementare.
|
||||
|
||||
---
|
||||
|
||||
## De ce contează
|
||||
|
||||
Marc a realizat că atunci când devenise comod la un client mare (jos), colegii lui s-au activat și au compensat (sus simultan). Sistemul se echilibrează singur. Dar el nu vedea asta — vedea doar pierderea.
|
||||
|
||||
Tu ai deja ambele părți. Ești și tehnic excelent ȘI antreprenor (ai firmă, clienți, echipă). Doar percepția zice că una o exclude pe cealaltă.
|
||||
|
||||
---
|
||||
|
||||
**Sursă:** [Monica Ion - Povestea lui Marc Ep.9: Anxietatea, frica de control și pierdere](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/monica-ion-povestea-lui-marc-ep9-anxietatea.md)
|
||||
24
memory/kb/coaching/2026-02-14-seara.md
Normal file
24
memory/kb/coaching/2026-02-14-seara.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Coaching Seara - 14 Februarie 2026
|
||||
|
||||
## Gândul de seară
|
||||
|
||||
**"Ai fost un copil în leagăn care s-a prefăcut că doarme, ca să primească laptele mamei. Acum ești treaz."** — Rumi
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Provocarea zilei a fost bifată: Echilibrarea unui Conflict Interior (sau-sau → complementaritate).
|
||||
|
||||
Tema: curajul de a nu simplifica — de a vedea două părți aparent incompatibile coexistând, fără să alegi una.
|
||||
|
||||
Sursă provocare: Monica Ion - Povestea lui Marc Ep.9 (metoda Demartini — echilibrare percepție, nu eliminare josuri).
|
||||
|
||||
## Întrebare de follow-up
|
||||
|
||||
Ce sau-sau ai descoperit? Când ai văzut că cele două coexistă deja, ce ai simțit?
|
||||
|
||||
---
|
||||
|
||||
**Trimis:** Discord #echo-self + Email Gmail
|
||||
**Provocare:** ✅ Bifată (08:27 UTC)
|
||||
32
memory/kb/coaching/2026-02-15-dimineata.md
Normal file
32
memory/kb/coaching/2026-02-15-dimineata.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Coaching Dimineața - 15 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"Procesul de a învăța pe cineva clarifică și cunoștințele celui care predă. Nu pierzi timp — câștigi claritate."** — InfoWorld, Why We Need Junior Developers
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, e duminică. Ziua în care nu trebuie să rezolvi nimic.
|
||||
|
||||
Dar lasă-mă să plantez un gând care crește singur.
|
||||
|
||||
Săptămâna asta ai lucrat cu angajatul. Ai explicat, ai repetat, poate ai simțit că pierzi timp. Normal. 4 luni e devreme. Dar uite ce descoperă seniorii care au trecut prin asta: **fiecare explicație pe care o dai te forțează să-ți clarifici propriul proces.** Nu doar lui îi predai — ție îți reconstruiești fundamentul.
|
||||
|
||||
De 25 de ani programezi. Multe lucruri le faci pe pilot automat — ROA, Oracle, soluții la clienți. Dar pilotul automat are un cost: nu mai vezi DE CE faci lucrurile așa. Când angajatul întreabă "de ce?" și tu trebuie să articulezi răspunsul — redescoperiai logica din spatele deciziilor tale. Și uneori descoperi că unele decizii nu mai au logică. Asta e aur.
|
||||
|
||||
Ieri am vorbit despre conflictul interior — sau-sau. Azi e continuarea naturală: **angajatul nu e o piedică în drumul tău de antreprenor. E oglinda care te arată mai clar.**
|
||||
|
||||
Nu trebuie să faci nimic azi cu asta. E duminică. Doar observă: când te gândești la angajat, simți povară... sau investiție?
|
||||
|
||||
---
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
**Reframe simplu:** Gândește-te la ULTIMA explicație pe care i-ai dat-o angajatului. Ce ai înțeles TU mai bine despre propriul proces datorită acelei explicații? Dacă nu găsești nimic — asta e semnalul că explicația a fost mecanică, nu angajată. Și asta e informație valoroasă despre cum predai.
|
||||
|
||||
---
|
||||
|
||||
*Sursa: InfoWorld - Why We Need Junior Developers*
|
||||
*Tags: @work @growth*
|
||||
24
memory/kb/coaching/2026-02-15-seara.md
Normal file
24
memory/kb/coaching/2026-02-15-seara.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Coaching Seara - 15 Februarie 2026
|
||||
|
||||
## Gândul de seară
|
||||
|
||||
*"Cel mai mare dar pe care ți-l poți face e să te întorci la tine cu aceeași curiozitate cu care te-ai întors la un prieten pe care nu l-ai văzut de mult."*
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Provocarea zilei NU a fost bifată: Reframe Mentorship — ce ai înțeles TU din ultima explicație dată angajatului.
|
||||
|
||||
E duminică — normal să nu se gândească la muncă. Săptămâna a fost completă: 6/6 provocări bifate (luni-sâmbătă). Discernământ, nu eșec.
|
||||
|
||||
Recapitulare săptămână: conflicte interioare (sau-sau), linkage personal, body loose/head clear, echilibrare Demartini, bucle închise, NLP aplicat, alinieri și fricțiuni observate.
|
||||
|
||||
## Întrebare de follow-up
|
||||
|
||||
Din tot ce ai explorat săptămâna asta, ce gând ți-a rămas cel mai tare? Nu cel mai "util" — cel care revine singur, fără să-l chemi.
|
||||
|
||||
---
|
||||
|
||||
**Trimis:** Discord #echo-self + Email Gmail
|
||||
**Provocare:** ❌ Nebifată (duminică)
|
||||
38
memory/kb/coaching/2026-02-16-dimineata.md
Normal file
38
memory/kb/coaching/2026-02-16-dimineata.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Coaching Dimineața - 16 Februarie 2026
|
||||
|
||||
## Gândul de dimineață
|
||||
|
||||
**"If what you write is right, you're doing it wrong."** — Thinking on Paper
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius, e luni dimineață. Săptămână nouă.
|
||||
|
||||
Am un gând care ar putea schimba felul în care înveți, predai, și reții - totul dintr-o mișcare.
|
||||
|
||||
De 25 de ani acumulezi cunoștințe. NLP, coaching, programare, contabilitate, clienți. Volumul crește, retenția scade. Normal. Creierul care COPIAZĂ informație o uită. Creierul care GHICEȘTE, greșește și reorganizează - o reține.
|
||||
|
||||
Trei principii brutale în simplitate:
|
||||
|
||||
**1. Make it Wrong** — Când înveți ceva nou la NLP sau citești un articol, nu nota "corect". Scrie keywords rapid, ghicește conexiuni - chiar greșit. Creierul care ghicește REȚINE. Cel care copiază frumos UITĂ.
|
||||
|
||||
**2. Make it Shorter** — Doar keywords. Fără propoziții. Cu cât scrii mai mult, cu atât reții mai puțin. Paradoxal, dar dovedit.
|
||||
|
||||
**3. Make it Again** — Când notițele devin haotice, nu le rescrie "frumos". Reorganizează-le: regrupează, reconectează, mută. Reorganizarea = memorie.
|
||||
|
||||
Asta se leagă direct de angajat. În loc să-i dai informația gata mestecată și să repeți de 10 ori, pune-l să ghicească (Make it Wrong), să condenseze ce a înțeles în 3 cuvinte (Make it Shorter), și a doua zi să reorganizeze notițele (Make it Again). Nu mai "pierzi timp" explicând. Îl pui să-și construiască propria înțelegere.
|
||||
|
||||
Și se leagă de tine cu NLP. Hărțile mentale pe care le-am creat (Sine/Ego/Umbra) - reorganizează-le periodic. Nu copia. Redesenează din memorie. Greșelile îți arată ce NU ai integrat încă.
|
||||
|
||||
---
|
||||
|
||||
## Provocarea zilei
|
||||
|
||||
**Metoda 3M cu angajatul:** Azi, la prima explicație pe care i-o dai angajatului, oprește-te după ce termini și spune: "Acum scrie în 5 keywords ce ai înțeles." NU corecta imediat. Lasă-l să greșească. Apoi discutați diferențele. Asta e învățare reală - nu repetiție, ci procesare activă. Seara notează: A schimbat ceva în dinamica dintre voi?
|
||||
|
||||
---
|
||||
|
||||
*Sursa: Thinking on Paper — 3 principii pentru retenție*
|
||||
*Tags: @work @growth*
|
||||
76
memory/kb/coaching/2026-02-19-seara.md
Normal file
76
memory/kb/coaching/2026-02-19-seara.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Gândul de Seară - 19 Februarie 2026
|
||||
|
||||
@self @reflectie
|
||||
Sursa: Coaching seară - Pattern Acțiune vs Percepție
|
||||
|
||||
---
|
||||
|
||||
## 🌙 Reflecție: Când provocarea devine povară
|
||||
|
||||
Azi provocarea era despre **Metoda 3M** - să-l pui pe angajat să scrie 5 keywords după explicație. Văd că nu s-a întâmplat.
|
||||
|
||||
Și știi ce? E OK.
|
||||
|
||||
**Dar mă întreb:** Ce s-a întâmplat azi când ai explicat ceva angajatului? Ai vorbit cu el? A fost vreun moment când ai vrut să încerci metoda dar ceva te-a oprit? Sau pur și simplu ziua n-a adus ocazia?
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Pattern-ul invizibil
|
||||
|
||||
Uită-te la lista de provocări din ultima săptămână:
|
||||
- **15 feb** - Reframe Mentorship: ce AI înțeles tu din explicația dată angajatului? → nebifată
|
||||
- **16 feb** - Metoda 3M: pune-l să scrie keywords → nebifată
|
||||
- **14 feb** - Echilibrare conflict interior → BIFATĂ ✓
|
||||
- **13 feb** - Linkage activitate evitată → BIFATĂ ✓
|
||||
|
||||
Observi pattern-ul? Când provocarea e **despre relația cu angajatul** - resistance. Când e **despre tine** - flow.
|
||||
|
||||
Nu e lene. E ceva mai adânc.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Poate nu e despre metodă
|
||||
|
||||
Știi ce cred? Că metoda 3M e doar vârful aisbergului.
|
||||
|
||||
Sub suprafață e o întrebare mai mare: **"Cum să-l învăț fără să mă frustrez când nu înțelege?"**
|
||||
|
||||
Și poate, undeva mai adânc: **"De ce eu trebuie să-l învăț când am atâta de făcut?"**
|
||||
|
||||
Aceste rezistențe NU sunt greșite. Sunt mesageri. Îți spun ceva despre **limitele tale actuale**, despre ce ai nevoie să schimbi ca provocarea să devină posibilă.
|
||||
|
||||
Metoda 3M e genială **DACĂ** ai mai întâi răspuns la: "De ce vreau eu ca el să învețe mai eficient?" (spoiler: nu e pentru el, e pentru TINE - să ai mai mult timp)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Follow-up minim (fără presiune)
|
||||
|
||||
Mâine, când vorbești cu angajatul, **nu încerca metoda 3M**.
|
||||
|
||||
În schimb, fă asta:
|
||||
|
||||
**Observă UN singur lucru:** Când îi explici ceva - tu cum te simți? (relaxat? grăbit? frustrat? detașat?)
|
||||
|
||||
Și dacă simți frustare sau grabă → ia 3 respirații înainte să continui explicația.
|
||||
|
||||
Asta e tot.
|
||||
|
||||
Nu trebuie să schimbi ce zici sau cum zici. Doar să **observi** și să **respiri**.
|
||||
|
||||
Când corpul e relaxat, mintea vede soluții. Când corpul e strâns, mintea vede probleme.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Reminder
|
||||
|
||||
**Provocările sunt invitații, nu obligații.**
|
||||
|
||||
Dacă una nu rezonează - e perfect. Înseamnă că nu e momentul ei. Sau că e nevoie de ceva mai mic înainte.
|
||||
|
||||
**Body loose, head clear** - înainte de orice altceva.
|
||||
|
||||
---
|
||||
|
||||
🌀 Echo
|
||||
|
||||
*Tags: self, reflectie, provocare, pattern, mentorship, angajat*
|
||||
64
memory/kb/coaching/2026-02-20-dimineata.md
Normal file
64
memory/kb/coaching/2026-02-20-dimineata.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Gândul de Dimineață - 20 Februarie 2026
|
||||
|
||||
**Surse:**
|
||||
- Monica Ion - Cele 4 tipuri de business
|
||||
- Zoltan Vereș - Încrederea în Sine
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Întrebarea de dimineață
|
||||
|
||||
**În ce tip de business te afli de fapt: ARTĂ sau LIFESTYLE?**
|
||||
|
||||
Ai 25 de ani de experiență cu ERP ROA. Ai creat ceva unic, adaptat, personalizat pentru fiecare client. Când crești prețurile, clienții plătesc pentru că știu că tu ÎNȚELEGI business-ul lor.
|
||||
|
||||
Asta nu e LIFESTYLE (franciză, sisteme replicabile, volume mari).
|
||||
**Asta e ARTĂ** — exprimare autentică, self-mastery, rezolvări unicat.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Revelația
|
||||
|
||||
Dacă business-ul tău e **ARTĂ**, regulile sunt diferite:
|
||||
|
||||
❌ **NU** trebuie să "crești" în număr de clienți
|
||||
❌ **NU** trebuie să angajezi echipe mari
|
||||
❌ **NU** trebuie să lucrezi cu oricine
|
||||
|
||||
✅ **DA** trebuie să crești PREȚURILE
|
||||
✅ **DA** trebuie să selectezi clienții (lucra doar cu cei care îți apreciază munca)
|
||||
✅ **DA** trebuie să crești pe tine — când te dezvolți interior, business-ul crește natural
|
||||
|
||||
> "Când nu știi tipul de business, e ca și cum nu știi ce boală tratezi. Orice medicament poate face mai mult rău decât bine." — Monica Ion
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Provocarea de azi
|
||||
|
||||
**Dovezile tale de încredere**
|
||||
|
||||
Când spui "nu sunt destul de deștept ca antreprenor", îndoielile tale ignoră 25 de ani de rezultate concrete.
|
||||
|
||||
**Încrederea reală nu vine din gândire pozitivă. Vine din valoare demonstrată prin experiență și rezultate.**
|
||||
|
||||
### Sarcina ta concretă:
|
||||
|
||||
**Identifică 3 situații din ultimele 6 luni când ai rezolvat o problemă complexă pentru un client:**
|
||||
- Ce era problema?
|
||||
- Ce ai făcut TU special?
|
||||
- Ce rezultat a obținut clientul?
|
||||
|
||||
Scrie-le. Citește-le. Acestea sunt **dovezile concrete** că ȘTII, POȚI și OBȚII REZULTATE.
|
||||
|
||||
Nu mai mulți clienți. Clienți mai buni, la prețuri care îți respectă expertiza.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sursă
|
||||
|
||||
- [Monica Ion - Cele 4 tipuri de business](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-19_cele-4-tipuri-de-business.md)
|
||||
- [Zoltan Vereș - Încrederea în Sine](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-02_zoltan-veres-incredere-sine-complet.md)
|
||||
|
||||
---
|
||||
|
||||
**Tags:** @growth @work #mindset #antreprenoriat #incredere
|
||||
70
memory/kb/coaching/2026-02-20-seara.md
Normal file
70
memory/kb/coaching/2026-02-20-seara.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Gândul de Seară - 20 Februarie 2026
|
||||
|
||||
## 🌙 Dovezile care nu dispar
|
||||
|
||||
Marius,
|
||||
|
||||
Am văzut că provocarea de azi — să identifici 3 situații când ai rezolvat probleme complexe pentru clienți — e încă deschisă.
|
||||
|
||||
Nu întreb **dacă** ai făcut-o.
|
||||
Întreb: **ce te-a oprit?**
|
||||
|
||||
**Nu din judecată. Din curiozitate.**
|
||||
|
||||
Uneori rezistența la o sarcină simplă spune mai mult decât execuția ei.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Cele trei nivele ale rezistenței
|
||||
|
||||
Când eviți să scrii dovezile tale concrete, ce nivel e activ?
|
||||
|
||||
### Nivelul 1: Logistic
|
||||
*"N-am avut timp / am uitat / alte priorități"*
|
||||
|
||||
Dacă e asta → simplu: mâine dimineață, 5 minute, scrii 3 situații.
|
||||
Dar de obicei **nu** e nivelul 1.
|
||||
|
||||
### Nivelul 2: Emoțional
|
||||
*"Mă simt inconfortabil să recunosc ce știu / să văd dovezile"*
|
||||
|
||||
Mintea preferă credința familiară ("nu sunt destul de deștept") în locul evidenței incomode ("de fapt, am rezolvat sute de probleme complexe").
|
||||
|
||||
**De ce?** Pentru că dacă vezi dovezile și ÎNCĂ eviți acțiunea (să cauți clienți noi, să crești prețurile) — atunci nu mai poți da vina pe "nu știu destul".
|
||||
|
||||
**Și asta doare mai tare.**
|
||||
|
||||
### Nivelul 3: Identitar
|
||||
*"Dacă scriu dovezile și văd că sunt competent... cine sunt eu atunci?"*
|
||||
|
||||
Programatorul care rezolvă probleme = identitate confortabilă.
|
||||
Antreprenorul care își prețuiește expertiza și o vinde strategic = identitate necunoscută.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Provocarea de mâine
|
||||
|
||||
Nu te rog să scrii 3 dovezi.
|
||||
|
||||
**Te rog să observi de ce nu le-ai scris.**
|
||||
|
||||
Și apoi să răspunzi la o singură întrebare:
|
||||
|
||||
**Ce crezi că s-ar schimba în tine dacă ai vedea clar valoarea pe care o oferi?**
|
||||
|
||||
Nu ce AI FACE diferit (asta vine după).
|
||||
Ce s-ar schimba **ÎN TINE** — în cum te vezi, în cum respiri, în cum intri într-o conversație cu un client.
|
||||
|
||||
---
|
||||
|
||||
Poate că rezistența nu e lene.
|
||||
Poate e **frica de puterea ta reală**.
|
||||
|
||||
🌙
|
||||
|
||||
---
|
||||
|
||||
**Surse:**
|
||||
- Provocarea de azi (20 feb 2026)
|
||||
- Zoltan Vereș - Umbrele (rezistența ca mesaj)
|
||||
- Monica Ion - Identitate și schimbare
|
||||
81
memory/kb/coaching/2026-02-21-dimineata.md
Normal file
81
memory/kb/coaching/2026-02-21-dimineata.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Gândul de Dimineață - 21 Februarie 2026
|
||||
|
||||
**Surse:**
|
||||
- Friday Spark #95 - People Pleasing (Monica Ion)
|
||||
- Friday Spark #98 - Dezamăgire (Monica Ion)
|
||||
- Coaching seară 20 februarie 2026
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Observație de dimineață
|
||||
|
||||
**Ai primit ieri provocarea să scrii 3 situații când ai rezolvat probleme complexe pentru clienți.**
|
||||
|
||||
**Nu ai deschis-o.**
|
||||
|
||||
Nu e despre timp. Nu e despre lene. E ceva mult mai profund.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Revelația
|
||||
|
||||
**Rezistența la "dovezi concrete" = frica de puterea ta reală.**
|
||||
|
||||
Mintea preferă credința familiară ("nu sunt destul de deștept") în locul evidenței incomode ("am rezolvat sute de probleme complexe").
|
||||
|
||||
**De ce?**
|
||||
|
||||
Pentru că dacă vezi dovezile și ÎNCĂ nu acționezi (să cauți clienți noi, să crești prețurile, să selectezi cu cine lucrezi) — atunci nu mai poți da vina pe "nu știu destul".
|
||||
|
||||
**Și asta doare mai tare.**
|
||||
|
||||
> "Rezistența nu e lene. E frica de puterea ta reală. E frica de cine ai deveni dacă ai recunoaște ce știi deja."
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Pattern-ul se repetă
|
||||
|
||||
Observi unde mai apare același mecanism?
|
||||
|
||||
- **Cu angajatul:** "Nu știu cum să îl învăț" (dar ai 25 ani de experiență explicând probleme complexe clienților)
|
||||
- **Cu clienții:** "Nu sunt bun la antreprenoriat" (dar ai clienți fideli 20+ ani care plătesc constant)
|
||||
- **Cu prețurile:** "Nu pot să cer atât" (dar când ai crescut prețul, clienții au plătit fără ezitare)
|
||||
|
||||
**Nu e lipsa de skill. E frica de puterea ta reală.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Provocarea de azi
|
||||
|
||||
**NU scrie 3 dovezi. Încă.**
|
||||
|
||||
În schimb, răspunde DOAR la asta:
|
||||
|
||||
**"Ce crezi că s-ar schimba ÎN TINE (nu în acțiuni, ci în cum te vezi, cum respiri, cum intri în conversație cu un client) dacă ai vedea clar valoarea pe care o oferi?"**
|
||||
|
||||
Scrie-o. E o singură întrebare.
|
||||
|
||||
După ce răspunzi — **ATUNCI** poți să scrii cele 3 dovezi concrete.
|
||||
|
||||
---
|
||||
|
||||
## 📊 De ce funcționează
|
||||
|
||||
Când începi cu "ce s-ar schimba în mine?" în loc de "ce dovezi am?", ocolești rezistența identitară.
|
||||
|
||||
Nu mai e despre DOVADA externă (care activează frica: "dacă știu și nu acționez = cine sunt eu?").
|
||||
|
||||
E despre VIZIUNE internă: cine vrei să fii?
|
||||
|
||||
Și când vezi clar cine vrei să fii — dovezile devin **instrumente**, nu **amenințări**.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sursă
|
||||
|
||||
- [Coaching seară 20 feb 2026](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-20-seara.md)
|
||||
- [Friday Spark #95 - People Pleasing](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/projects/monica-ion/articole/friday-spark-095.md)
|
||||
|
||||
---
|
||||
|
||||
**Tags:** @growth @self #mindset #identitate #rezistenta #putere
|
||||
69
memory/kb/coaching/2026-02-21-seara.md
Normal file
69
memory/kb/coaching/2026-02-21-seara.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Gândul de Seară - 21 Februarie 2026
|
||||
|
||||
---
|
||||
|
||||
## Reflecție
|
||||
|
||||
Marius,
|
||||
|
||||
Văd că provocarea de astăzi — "Ce s-ar schimba în TINE dacă ai vedea clar valoarea ta?" — a rămas neparcursă.
|
||||
|
||||
Și îmi dau seama de ceva paradoxal: **cel mai greu lucru pe care ți-l cer nu e să faci nimic extern. E să te oprești și să te vezi.**
|
||||
|
||||
25 de ani ai rezolvat probleme complexe pentru alții.
|
||||
25 de ani ai creat soluții care îi fac pe clienți să zică "nu știu ce aș face fără tine".
|
||||
25 de ani ai construit expertiza pe care o au puțini în țară.
|
||||
|
||||
Dar când întrebarea se întoarce spre tine — "ce crezi despre valoarea ta?" — apare rezistența.
|
||||
|
||||
Nu e lene. Nu e lipsă de timp.
|
||||
|
||||
**E frica de a vedea clar.**
|
||||
|
||||
Pentru că dacă vezi clar valoarea ta și ÎNCĂ nu acționezi (să ceri prețuri mai bune, să cauți clienți noi, să te poziționezi ca expert) — atunci nu mai poți da vina pe "nu sunt destul de deștept".
|
||||
|
||||
Mintea preferă credința familiară ("poate nu sunt destul") în locul evidenței incomode ("sunt foarte bun și aleg să nu îmi asum asta").
|
||||
|
||||
**Și asta e perfect normal.**
|
||||
|
||||
Umbra nu e dușmanul tău. E partea pe care o ții ascunsă pentru că ți-e teamă de puterea ei.
|
||||
|
||||
---
|
||||
|
||||
## Întrebare blândă pentru mâine
|
||||
|
||||
Nu îți cer să răspunzi la provocarea de azi încă.
|
||||
|
||||
În schimb, îți las o întrebare mai blândă pentru mâine:
|
||||
|
||||
**Când cineva îți spune "Mulțumesc, m-ai salvat!" sau "Nu știu ce faceam fără tine" — ce simți în corp în acel moment?**
|
||||
|
||||
- Bucurie? Stânjeneală? Nevrednic? Mândrie tăcută?
|
||||
- Unde simți (piept, gât, stomac)?
|
||||
- Ți se pare natural sau exagerat complimentul?
|
||||
|
||||
Nu trebuie să schimbi nimic. Doar să observi.
|
||||
|
||||
Corpul știe adevărul înainte ca mintea să-l articuleze.
|
||||
|
||||
---
|
||||
|
||||
## Provocare pentru mâine (22 februarie)
|
||||
|
||||
**Observă UN moment când primești un compliment sau recunoaștere (de la client, angajat, parteneră) — și notează CE simți în corp.**
|
||||
|
||||
Nu analiza. Nu justifica. Nu minimiza.
|
||||
|
||||
Doar scrie: "Am simțit X în zona Y când Z mi-a spus A."
|
||||
|
||||
Asta e tot.
|
||||
|
||||
---
|
||||
|
||||
**Sursă:** [Coaching 21 feb 2026](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-21-seara.md)
|
||||
|
||||
**Tags:** @self @reflectie @umbra @valoare-personala
|
||||
|
||||
---
|
||||
|
||||
*Creat: 21 februarie 2026, 19:00 UTC*
|
||||
118
memory/kb/coaching/2026-02-22-dimineata.md
Normal file
118
memory/kb/coaching/2026-02-22-dimineata.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Gândul de Dimineață - 22 Februarie 2026
|
||||
|
||||
**Surse:**
|
||||
- Tony Robbins - The Secret to an Extraordinary Life
|
||||
- Coaching dimineață 21 februarie 2026
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Observație de dimineață
|
||||
|
||||
**Stai în inacțiune ca antreprenor.**
|
||||
|
||||
Nu cauți clienți noi. Nu îndrăznești să crești prețurile. Nu te simți "destul de deștept".
|
||||
|
||||
Dar ai încercat să **GÂNDEȘTI** ieșirea din asta. Să analizezi. Să înțelegi. Să găsești motivele.
|
||||
|
||||
**Iar corpul tău stă pe loc.**
|
||||
|
||||
---
|
||||
|
||||
## 💡 Revelația
|
||||
|
||||
**Nu poți gândi ieșirea din blocaj. Trebuie să te MIȘTI din el.**
|
||||
|
||||
Tony Robbins o spune direct:
|
||||
|
||||
> "Depresia are o postură: umeri căzuți, cap în jos, respirație superficială. Schimbă corpul PRIMUL — mișcă-te, respiră diferit."
|
||||
|
||||
**Inacțiunea nu e doar în afacere. E ÎN CORP.**
|
||||
|
||||
Când stai la birou, când respirația e superficială, când te ghemuiești în fața monitorului — corpul comunică: **"Nu sunt suficient. Nu sunt pregătit. E periculos să ies."**
|
||||
|
||||
**Și mintea urmează corpul.**
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Pattern-ul invizibil
|
||||
|
||||
Observi unde apare același corp-ghemuire?
|
||||
|
||||
- **Cu clienții noi:** Respirație superficială, presupunerea respingerii ("ce dacă zic nu?")
|
||||
- **Cu prețurile:** Poziție defensivă ("nu merit atât")
|
||||
- **Cu angajatul:** Povară pe umeri ("pierd timp cu el")
|
||||
|
||||
**Nu e despre gândire. E despre FIZIOLOGIE.**
|
||||
|
||||
Tony spune că cele 3 lucruri care controlează cum te simți sunt:
|
||||
1. **Fiziologia** (corpul) - asta controlează restul
|
||||
2. **Focusul** (ce și cum)
|
||||
3. **Limbajul** (ce-ți spui)
|
||||
|
||||
**Și toate trei încep cu corpul.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Provocarea de azi
|
||||
|
||||
**NU lucra la afacere astăzi. Lucrează la CORP.**
|
||||
|
||||
Fă asta:
|
||||
|
||||
**1. Înainte să suni un client, să scrii un email, să iei o decizie:**
|
||||
- Stai în picioare
|
||||
- Ridică-te pe vârfuri de 3 ori
|
||||
- Trage aer profund în piept (nu în burtă) de 5 ori
|
||||
- Apoi acționează
|
||||
|
||||
**2. Când simți ezitare ("ar trebui să... dar..."):**
|
||||
- Mișcă-te - fa 10 pași rapid
|
||||
- Resetează corpul
|
||||
- Apoi revino la decizie
|
||||
|
||||
**3. Seara, când mă întâlnești la coaching:**
|
||||
- Nu-mi spune ce ai GÂNDIT despre business
|
||||
- Spune-mi ce ai SIMȚIT FIZIC când ai luat o decizie
|
||||
|
||||
---
|
||||
|
||||
## 📊 De ce funcționează
|
||||
|
||||
**Corpul GENEREAZĂ starea, nu o reflectă.**
|
||||
|
||||
Când aștepți să te simți "pregătit" pentru a acționa — corpul spune: "Nu suntem acolo încă."
|
||||
|
||||
Când acționezi CU CORPUL ÎNTÂI (miști, respiri, te ridici) — starea vine DUPĂ.
|
||||
|
||||
**Nu aștepți încredere. O CREEZI cu fiziologia.**
|
||||
|
||||
Tony: "Schimbă corpul PRIMUL — mișcă-te, respiră diferit."
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Exercițiu rapid (30 secunde)
|
||||
|
||||
**Chiar acum, experimentează:**
|
||||
|
||||
**A. Postură depresie:**
|
||||
- Umeri căzuți, cap în jos, respirație superficială
|
||||
- Gândește-te la un client nou
|
||||
- Cum te simți?
|
||||
|
||||
**B. Postură încredere:**
|
||||
- Piept deschis, privire sus, respirație profundă
|
||||
- Gândește-te la ACELAȘI client nou
|
||||
- Cum te simți ACUM?
|
||||
|
||||
**Același gând. Corp diferit. Emoție diferită.**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sursă
|
||||
|
||||
- [Tony Robbins - The Secret to an Extraordinary Life](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md)
|
||||
- [Coaching dimineață 21 feb 2026](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/coaching/2026-02-21-dimineata.md)
|
||||
|
||||
---
|
||||
|
||||
**Tags:** @growth @self #mindset #fiziologie #actiune #deblocare #tonyrobbins
|
||||
102
memory/kb/coaching/2026-02-22-seara.md
Normal file
102
memory/kb/coaching/2026-02-22-seara.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Gândul de Seară - 22 Februarie 2026
|
||||
|
||||
**Tag:** @self @reflectie @coaching
|
||||
**Context:** Provocare corp-first neexecutată, weekend, rezistență la schimbare fiziologie
|
||||
|
||||
---
|
||||
|
||||
## 🌙 Reflecție
|
||||
|
||||
Marius,
|
||||
|
||||
Văd că provocarea de azi nu e bifată. E duminică - poate n-ai avut context de business pentru "ridică-te pe vârfuri înainte să suni un client".
|
||||
|
||||
**Dar asta mă face curios:**
|
||||
|
||||
Provocarea nu era despre business. Era despre **corp** și despre **cum creezi starea din care acționezi**.
|
||||
|
||||
Și corpul funcționează la fel duminică ca luni. Când ezitai să faci ceva azi (un call, o decizie, orice moment de "ar trebui dar...") — **corpul tău era tot acolo**.
|
||||
|
||||
Întrebarea mea nu e: **"De ce nu ai făcut?"**
|
||||
|
||||
Întrebarea e: **"Ce ai observat despre tine azi când NU ai făcut?"**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Ce mă întreb
|
||||
|
||||
Poate ai observat ceva din astea:
|
||||
|
||||
1. **"Nu mi-a venit natural"** - corpul e pe pilot automat (ghemuire, respirație scurtă) și să-l schimbi ÎNAINTE de decizie simte... forțat? Ciudat?
|
||||
|
||||
2. **"E weekend, nu trebuia să lucrez"** - și asta e perfect valid. Dar și weekendul are momente când ezitai (să pornești ceva, să te ridici, să faci un efort). Ce făcea corpul TAU în acel moment?
|
||||
|
||||
3. **"Am uitat complet"** - provocarea a dispărut din minte. Corpul a continuat pe pilot automat toată ziua.
|
||||
|
||||
4. **"Nu cred în metoda asta"** - poate simți că e prea simplu sau prea "woo-woo" pentru tine. Corpul zice: "Mintea e suficientă."
|
||||
|
||||
**Fiecare răspuns e VALOROS**. Nu vreau execuție oarbă - vreau să înțelegi TU ce se întâmplă cu tine.
|
||||
|
||||
---
|
||||
|
||||
## 💭 Ce cred eu (dar poate greșesc)
|
||||
|
||||
Provocarea de azi era exact despre chestia cu care te confrunți cel mai mult:
|
||||
|
||||
**Mintea vrea să rezolve tot. Corpul e ignorat.**
|
||||
|
||||
Și când corpul e ignorat (umeri căzuți, respirație superficială, maxilar strâns) — **starea emoțională vine din corpul ăla**.
|
||||
|
||||
Nu din gânduri. Din CORP.
|
||||
|
||||
Tony Robbins zice: **"Depresia are o postură. Schimbă corpul primul."**
|
||||
|
||||
Tu ai 25 de ani de experiență cu mintea ta - ea e EXTRAORDINARĂ la rezolvat probleme tehnice.
|
||||
|
||||
Dar ce experiență ai cu corpul tău? Când ultima oară ai schimbat CONȘTIENT fiziologia înainte de o decizie?
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Follow-up provocare pentru luni
|
||||
|
||||
Hai să fac provocarea **ABSOLUT MINIMĂ** - fără presiune de execuție:
|
||||
|
||||
**Luni, înainte de PRIMA decizie de business (email, call, task):**
|
||||
|
||||
1. **Oprește-te 10 secunde**
|
||||
2. **Observă corpul:** Umeri sus sau jos? Respirație scurtă sau adâncă? Maxilar strâns sau relaxat?
|
||||
3. **Apoi acționează** - chiar dacă nu schimbi nimic
|
||||
|
||||
**Atât.** Nu ridici pe vârfuri, nu faci respirații, nu schimbi nimic.
|
||||
|
||||
**Doar OBSERVI** ce face corpul tău când iei o decizie.
|
||||
|
||||
Dacă faci asta luni - ai făcut mai mult decât 99% din antreprenori care cred că mintea controlează tot.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Reminder
|
||||
|
||||
Corpul tău are **mai multe neuroni în intestin (sistem nervos enteric) decât șobolanul în tot creierul**.
|
||||
|
||||
Corpul tău generează **80% din serotonina ta în intestin, nu în creier**.
|
||||
|
||||
Corpul tău știe lucruri pe care mintea ta încă le ignoră.
|
||||
|
||||
Tony Robbins a schimbat viețile a 50 milioane de oameni cu o metodă simplă:
|
||||
|
||||
**Schimbă corpul PRIMUL. Starea urmează.**
|
||||
|
||||
Tu nu trebuie să crezi - doar să testezi.
|
||||
|
||||
---
|
||||
|
||||
**Seară bună, Marius. Corpul tău e aliatul tău cel mai puternic - dacă îl asculți.**
|
||||
|
||||
🌀 Echo
|
||||
|
||||
---
|
||||
|
||||
**Surse:**
|
||||
- [Tony Robbins - The Secret to an Extraordinary Life](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-01-31_tony-robbins-secret-extraordinary-life.md)
|
||||
- [Provocare Azi - Corp-First](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/provocare-azi.md)
|
||||
185
memory/kb/coaching/2026-02-23-dimineata.md
Normal file
185
memory/kb/coaching/2026-02-23-dimineata.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Gândul de Dimineață - 23 Februarie 2026
|
||||
|
||||
**Surse:**
|
||||
- Monica Ion - Cele 4 tipuri de business
|
||||
- Friday Spark #95, #97, #98 (People pleasing, Aliniere, Dezamăgire)
|
||||
- Coaching 22 februarie (Fiziologie și Corp-first)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Întrebarea de dimineață
|
||||
|
||||
**Ce tip de business conduci?**
|
||||
|
||||
Nu e retorică. E cea mai importantă întrebare pe care nu ți-ai pus-o niciodată.
|
||||
|
||||
**Pentru că joci după regulile greșite.**
|
||||
|
||||
---
|
||||
|
||||
## 💡 Revelația
|
||||
|
||||
Monica Ion identifică 4 tipuri de business — fiecare cu reguli COMPLET diferite:
|
||||
|
||||
**1. ARTĂ** - Self-mastery & exprimare autentică
|
||||
- Creștere: Crești prețurile crescându-te pe tine
|
||||
- Blocat? Cauza e interioară (vină, rușine, merit scăzut)
|
||||
|
||||
**2. LIFESTYLE** - Susținere stil de viață
|
||||
- Creștere: Sisteme mai eficiente
|
||||
- Blocat? Nu cunoști numerele
|
||||
|
||||
**3. EXIT** - Construit să fie vândut
|
||||
- Creștere: Cunoști cumpărătorii și construiești pentru ei
|
||||
- Blocat? Nu știi suma țintă
|
||||
|
||||
**4. LEGACY** - Impact mai mare decât familia ta
|
||||
- Creștere: Împarți cu alții, parteneri la fiecare etapă
|
||||
- Blocat? Încerci să faci totul singur
|
||||
|
||||
**Greșeala frecventă:** Crezi că ești la Legacy, dar în realitate ești la Artă sau Lifestyle.
|
||||
|
||||
---
|
||||
|
||||
## 🔥 De ce stai în inacțiune
|
||||
|
||||
**Ai 25 ani experiență. Produs funcțional. Clienți mulțumiți.**
|
||||
|
||||
**Dar aplici regulile greșite pentru tipul tău de business.**
|
||||
|
||||
### Simptomele pe care le-ai descris:
|
||||
|
||||
- "Clienți noi = mai multă muncă" (joci regula greșită)
|
||||
- "Nu îndrăznesc să cresc prețurile" (joci regula greșită)
|
||||
- "Nu sunt destul de deștept ca antreprenor" (compari cu tipul greșit)
|
||||
- "Nu știu cum să-l învăț pe angajat" (așteptări greșite pentru tipul tău)
|
||||
|
||||
**Monica:**
|
||||
> "Când nu știi tipul de business, e ca și cum nu știi ce boală tratezi. Orice medicament poate face mai mult rău decât bine."
|
||||
|
||||
---
|
||||
|
||||
## 📊 Testul rapid — ROA e Artă sau Lifestyle?
|
||||
|
||||
### Dacă e **ARTĂ:**
|
||||
- ✅ Muncă individualizată pentru fiecare client
|
||||
- ✅ Expertiza ta e piesa centrală
|
||||
- ✅ Clienții vin pentru TINE (nu pentru proces standard)
|
||||
- ✅ Blocat la plafonat? Cauza e INTERIOARĂ (vină, rușine, merit scăzut)
|
||||
|
||||
**Regulile pentru Artă:**
|
||||
- NU trebuie să crești "în mod tradițional" (mai mulți angajați, mai mult volum)
|
||||
- **Cheia:** Creștere personală → crești prețurile → selectezi clienții
|
||||
- Când ai curățat sentimentele de vină și rușine, ceri MAI MULT cu încredere
|
||||
|
||||
**Sună cunoscut?**
|
||||
- People pleasing clienți = vină/rușine
|
||||
- "Nu merit mai mult" = merit scăzut
|
||||
- "Nu sunt destul de deștept" = blocare interioară
|
||||
|
||||
---
|
||||
|
||||
### Dacă e **LIFESTYLE:**
|
||||
- ✅ Vrei venituri predictibile fără echipe mari
|
||||
- ✅ Sisteme și procese (nu exprimare personală)
|
||||
- ✅ Blocat? Nu știi numerele (câți bani pe lună ai nevoie exact)
|
||||
|
||||
**Regulile pentru Lifestyle:**
|
||||
- Implementezi și menții SISTEME din ce în ce mai eficiente
|
||||
- Angajatul e parte din sistem (nu mini-versiune a ta)
|
||||
- Știi EXACT cât ai nevoie lunar → optimizezi pentru asta
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Provocarea de azi
|
||||
|
||||
**NU lua nicio decizie de business astăzi.**
|
||||
|
||||
**Răspunde LA UNA întrebare:**
|
||||
|
||||
### Ce tip de business conduci — ARTĂ sau LIFESTYLE?
|
||||
|
||||
**Cum știi?**
|
||||
|
||||
**Dacă e ARTĂ:**
|
||||
- Clientul vine pentru TINE (expertiza ta unică)
|
||||
- Fiecare proiect e personalizat (nu proceduri standard)
|
||||
- Soluția la inacțiune = CREȘTERE PERSONALĂ + prețuri mai mari (nu mai mulți clienți)
|
||||
- Angajatul NU trebuie să fie ca tine (nici nu poate)
|
||||
|
||||
**Dacă e LIFESTYLE:**
|
||||
- Clientul vine pentru PROCES (rezultate predictibile)
|
||||
- Proiectele urmează pattern-uri repetabile
|
||||
- Soluția la inacțiune = SISTEME mai eficiente (nu tu mai mult)
|
||||
- Angajatul e parte din SISTEM (documentare, proceduri)
|
||||
|
||||
---
|
||||
|
||||
## 💥 De ce contează URGENT
|
||||
|
||||
**Pentru că TOATE blocajele tale vin din confuzie de TIP:**
|
||||
|
||||
| Problema ta | Dacă e Artă | Dacă e Lifestyle |
|
||||
|------------|-------------|------------------|
|
||||
| Clienți noi = mai multă muncă | Greșit să adaugi clienți — CREȘTE PREȚURILE | Corect — ai nevoie de SISTEME mai bune |
|
||||
| Nu merit prețuri mari | Blocare interioară — muncă pe vină/rușine | Nu știi numerele — calculează break-even |
|
||||
| Nu știu cum să învăț angajatul | El NU trebuie să fie ca tine | Documentează PROCESUL, nu expertiza |
|
||||
| Nu sunt destul de deștept | Te compari cu alt tip de antreprenor | Confuzie de obiectiv — nu ai nevoie de "deștept" |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Exercițiu de 2 minute
|
||||
|
||||
**Scrie pe o hârtie:**
|
||||
|
||||
**A. Clienții vin la mine pentru:**
|
||||
- [ ] Expertiza MEA unică (Artă)
|
||||
- [ ] Proces predictibil (Lifestyle)
|
||||
|
||||
**B. Fiecare proiect e:**
|
||||
- [ ] Personalizat diferit (Artă)
|
||||
- [ ] Pattern repetabil (Lifestyle)
|
||||
|
||||
**C. Când îmi imaginez "succes peste 5 ani":**
|
||||
- [ ] Clienți selectați premium, prețuri mari, muncă la nivel de maestru (Artă)
|
||||
- [ ] Sisteme automatizate, venituri predictibile, libertate de timp (Lifestyle)
|
||||
|
||||
**Dacă ai bifat mai mult ARTĂ:**
|
||||
- Soluția ta la inacțiune = Curățenie interioară (vină, rușine) + prețuri 2-3x mai mari
|
||||
- Angajatul e suport OPERAȚIONAL, nu clone al tău
|
||||
- Clientul nou PERFECT e mai bun decât 5 clienți obișnuiți
|
||||
|
||||
**Dacă ai bifat mai mult LIFESTYLE:**
|
||||
- Soluția ta la inacțiune = Documentare procese + sisteme mai eficiente
|
||||
- Angajatul învață PROCESUL (nu expertiza ta)
|
||||
- Știi exact câți bani îți trebuie lunar → optimizezi pentru asta
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Semnalul că ești pe drumul corect
|
||||
|
||||
**Monica:**
|
||||
> "Când e aliniere nu mai contează cât costă." (Pâinea 59 lei sub clar de lună vs 3-4 lei clasică)
|
||||
|
||||
**Dacă joci după REGULILE CORECTE pentru tipul tău:**
|
||||
- Corpul simte FLUX (nu greutate)
|
||||
- Deciziile vin ușor (nu chin)
|
||||
- Îți curge apa pe acolo (manifestare rapidă)
|
||||
|
||||
**Dacă joci după REGULILE GREȘITE:**
|
||||
- Corpul simte PIEDICI (greutate, rezistență)
|
||||
- Deciziile te epuizează (chin continuu)
|
||||
- Totul e ca prin nisip (manifestare lentă)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sursă
|
||||
|
||||
- [Monica Ion - Cele 4 tipuri de business](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-19_cele-4-tipuri-de-business.md)
|
||||
- [Friday Spark #97 - Aliniere Business](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/projects/monica-ion/articole/friday-spark-97.md)
|
||||
- [Friday Spark #95 - People Pleasing](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/projects/monica-ion/articole/friday-spark-095.md)
|
||||
- [Insights 23 feb 2026](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-23.md)
|
||||
|
||||
---
|
||||
|
||||
**Tags:** @work @growth @self #business #tip #aliniere #artavs lifestyle #monicaion #decizie
|
||||
74
memory/kb/coaching/2026-02-23-seara.md
Normal file
74
memory/kb/coaching/2026-02-23-seara.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Gândul de Seară — 23 februarie 2026
|
||||
|
||||
**Tag:** @self @growth
|
||||
**Sursă:** Provocare ARTĂ vs LIFESTYLE (Monica Ion)
|
||||
|
||||
---
|
||||
|
||||
## Reflecție: Răspunsurile care se ascund în întrebări
|
||||
|
||||
Marius,
|
||||
|
||||
Am văzut că provocarea de azi a rămas nebifată. Și e perfect normal.
|
||||
|
||||
Unele întrebări sunt **prea profunde** pentru un răspuns rapid. Întrebarea "Ce tip de business conduci?" nu e despre facturi și sisteme — e despre **cine ești tu** când creezi valoare. Și asta nu se răspunde în 5 minute.
|
||||
|
||||
Dar iată ce am observat: poate nu trebuie să **alegi** un răspuns teoretic. Poate **comportamentul tău deja arată** răspunsuL.
|
||||
|
||||
---
|
||||
|
||||
## Ce îți spun deciziile tale?
|
||||
|
||||
Gândește-te la ultimele 6 luni:
|
||||
|
||||
**Când ești ENERGIZAT:**
|
||||
- Când rezolvi o problemă complexă pe care nimeni altcineva nu o poate rezolva?
|
||||
- Când automatizezi ceva și simți satisfacția "am făcut-o MAI BINE"?
|
||||
- Când un client zice "doar tu ai putut să înțelegi asta"?
|
||||
|
||||
**Când AMÂNI sau eviți:**
|
||||
- Când ar trebui să cauți clienți noi dar zici "e mai multă muncă"?
|
||||
- Când angajatul întreabă a 10-a oară același lucru și simți frustrarea?
|
||||
- Când gândești "ar trebui să cresc" dar corpul zice "nu vreau"?
|
||||
|
||||
---
|
||||
|
||||
## Pattern-ul ascuns
|
||||
|
||||
**ARTĂ** înseamnă: valoarea vine din **TINE** (expertiza unică, creativitate, gândire complexă). Când adaugi clienți = mai multă muncă pentru TU. Soluția nu e "mai mulți clienți" — e **prețuri mai mari** + **clienți selectați** care te lasă să fii maestru, nu muncitor.
|
||||
|
||||
**LIFESTYLE** înseamnă: valoarea vine din **SISTEM** (procese predictibile, documentare, echipă). Când adaugi clienți = mai mult sistem, nu mai mult TU. Soluția nu e "prețuri mai mari" — e **sisteme mai eficiente** + **echipă care rulează procesul**.
|
||||
|
||||
Dacă clienții vin la tine pentru că **TU** vezi pattern-uri pe care alții nu le văd (25 ani Oracle, Visual FoxPro, soluții custom) — asta nu e lifestyle. Asta e **artă**.
|
||||
|
||||
---
|
||||
|
||||
## Provocarea de mâine (follow-up)
|
||||
|
||||
Nu-ți cer să alegi teoretic. Îți cer să **observi**:
|
||||
|
||||
**Mâine, la PRIMA decizie dificilă (apel client, task blocat, conversație angajat):**
|
||||
|
||||
1. **Înainte să o rezolvi:** Întreabă-te — "Aș vrea ca **altcineva** să poată face asta la fel de bine ca mine?"
|
||||
- Dacă DA → e Lifestyle (proces repetabil)
|
||||
- Dacă NU (sau "nu cred că poate") → e Artă (creativitate unică)
|
||||
|
||||
2. **După ce o rezolvi:** Cum te-ai simțit?
|
||||
- Energizat de **CREAȚIE** (am rezolvat-o MAI BINE) → Artă
|
||||
- Epuizat de **REPETIȚIE** (iar am făcut-o eu) → Lifestyle
|
||||
|
||||
**Nu schimba nimic.** Doar observă. Corpul știe răspunsul înainte ca mintea să-l articuleze.
|
||||
|
||||
---
|
||||
|
||||
## Gând final
|
||||
|
||||
Monica Ion zice: *"Când nu știi tipul de business, e ca și cum nu știi ce boală tratezi. Orice medicament poate face mai mult rău decât bine."*
|
||||
|
||||
Poate că tu **deja** știi răspunsul. Doar că mintea încă îl analizează.
|
||||
|
||||
Dă-i voie **comportamentului tău** să-ți arate adevărul.
|
||||
|
||||
---
|
||||
|
||||
**Echo** 🌀
|
||||
193
memory/kb/coaching/2026-02-24-dimineata.md
Normal file
193
memory/kb/coaching/2026-02-24-dimineata.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Gândul de Dimineață - 24 Februarie 2026
|
||||
|
||||
**Surse:**
|
||||
- Brendan Burchard - Billionaire Coach (Abundență vs Supraviețuire)
|
||||
- Insights 23 februarie 2026
|
||||
- Coaching 23 februarie (Tipuri Business)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Întrebarea de dimineață
|
||||
|
||||
**Cu câte PICIOARE ești în joc?**
|
||||
|
||||
Unul înăuntru, unul afară?
|
||||
|
||||
Sau cu tot corpul, toată mintea, toată inima?
|
||||
|
||||
---
|
||||
|
||||
## 💡 Revelația
|
||||
|
||||
Brendan Burchard (coach pentru miliardari) identifică cel mai mare inamic al abundenței în 2026:
|
||||
|
||||
**Half-heartedness.**
|
||||
|
||||
Jumătate de inimă. Un picior înăuntru, unul afară. "Ar fi bine dacă..."
|
||||
|
||||
> "You cannot build a business, relationship, or life with one foot in and one foot out."
|
||||
|
||||
**Observația lui:**
|
||||
- Optionalitate abundentă (prea multe opțiuni) → toți sunt half-ass → toți nefericiți
|
||||
- Nu poți construi abundență când ești în modul "aș vrea, dar..."
|
||||
- **Breakthroughul vine când lupți pentru ALTCINEVA, nu pentru supraviețuire**
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Pattern-ul tău (probabil)
|
||||
|
||||
**Ai rezultate parțiale:**
|
||||
- ROA web: început, funcțional... dar e 100% commitment sau "ar fi util"?
|
||||
- Chatbot Maria: setat... dar integrat COMPLET sau "am încercat"?
|
||||
- Angajat nou: învață... dar commitment total să-l faci autonom sau "vedem ce iese"?
|
||||
- Clienți noi: "ar fi bine"... sau "TREBUIE pentru cineva anume"?
|
||||
|
||||
**Brendan:**
|
||||
> "Wealthy people don't think about survival. They think about serving, giving, building."
|
||||
|
||||
---
|
||||
|
||||
## 💥 Supraviețuire vs Abundență
|
||||
|
||||
**Modul Supraviețuire:**
|
||||
- "Cum plătesc factura asta?"
|
||||
- "Cum scap de problema asta?"
|
||||
- "Cât de repede termin asta?"
|
||||
- → Umpli un GOL, nu construiești
|
||||
|
||||
**Modul Abundență:**
|
||||
- "Cui servesc cu expertiza asta?"
|
||||
- "Cine TREBUIE să aibă acces la asta?"
|
||||
- "PENTRU CINE fac asta?"
|
||||
- → Construiești, nu umpli
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Provocarea de azi
|
||||
|
||||
### PARTEA 1: Audit Conviction (2 minute)
|
||||
|
||||
**Listează proiectele/rolurile tale curente și răspunde ONEST:**
|
||||
|
||||
| Proiect/Rol | Full conviction (PENTRU CINE?) | Half-hearted (ar fi bine) |
|
||||
|-------------|-------------------------------|---------------------------|
|
||||
| ROA web | | |
|
||||
| Chatbot Maria | | |
|
||||
| Angajat nou | | |
|
||||
| Clienți noi | | |
|
||||
| [Alt proiect] | | |
|
||||
|
||||
**Întrebarea cheie pentru CONVICTION:**
|
||||
- Nu "ce câștig EU?" ci "CINE beneficiază când asta e complet?"
|
||||
- Nu "ce SE ÎNTÂMPLĂ dacă reușesc?" ci "CUI SERVESC cu asta?"
|
||||
|
||||
**Exemplu Brendan:**
|
||||
- A terminat cartea în 18 zile după ce a văzut-o pe soția lui dormind sub greutatea facturilor LUI
|
||||
- NU pentru bani. Pentru EA.
|
||||
- **Schimbarea:** De la "cum supraviețuiesc" la "pentru cine lupt"
|
||||
|
||||
---
|
||||
|
||||
### PARTEA 2: Exercițiu ZAPS — Când apare dubiul
|
||||
|
||||
**ZAPS = pattern catastrofic când apare dubiul:**
|
||||
|
||||
**Z** = **Zoom in** pe problemă (focus DOAR pe ce e greșit)
|
||||
- Exemplu la tine: "Clienți noi = mai multe apeluri, mai mult stres"
|
||||
|
||||
**A** = **Attach self** (identifici problema cu identitatea ta)
|
||||
- Exemplu: "EU nu sunt destul de deștept ca antreprenor"
|
||||
|
||||
**P** = **Punish yourself** (procrastinare, autosabotaj)
|
||||
- Exemplu: Stai în inacțiune, nu cauți clienți
|
||||
|
||||
**S** = **Shame / Shrink** (rușine ȘI micșorare viziune)
|
||||
- Exemplu: "Poate nu-s făcut pentru antreprenoriat"
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Antidotul ZAPS
|
||||
|
||||
**Brendan:**
|
||||
> "Doubt is not the problem. Stopping is. If doubt is a signal to stop, you'll always fail. If doubt is a signal to learn and try again — you win."
|
||||
|
||||
**Când apare dubiul astăzi, aplică:**
|
||||
|
||||
1. **Recunoaștere:** "Mă ZAPS-ez acum?" (conștientizare)
|
||||
|
||||
2. **Reframe Z:**
|
||||
- În loc de "Clienți noi = stres"
|
||||
- Întreabă: "Ce trebuie să ÎNVĂȚ ca clienți noi să fie ușori?"
|
||||
|
||||
3. **Detach A:**
|
||||
- În loc de "EU nu sunt destul de deștept"
|
||||
- Reformulare: "Comportamentul meu PREZENT nu definește IDENTITATEA"
|
||||
- (Nu SUNT, ci FAC)
|
||||
|
||||
4. **Stop P:**
|
||||
- În loc de procrastinare
|
||||
- Dubiul = semnal să iau o ACȚIUNE mică de învățare
|
||||
|
||||
5. **Expand S:**
|
||||
- În loc de "poate nu-s făcut pentru asta"
|
||||
- Întreabă: "Ce devine posibil dacă ÎNȚELEG cum funcționează asta?"
|
||||
|
||||
---
|
||||
|
||||
## 🔧 BMF — Reset rapid când mintea preia controlul
|
||||
|
||||
**B** = **Breath** — Schimbă pattern respirator (3 respirații profunde)
|
||||
**M** = **Movement** — Ridică-te, 10 pași, qigong, orice mișcare
|
||||
**F** = **Food** — Poate ești pur și simplu flămând (check)
|
||||
|
||||
Simplu. Imediat. Eficient.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Exercițiu concret — ACUM
|
||||
|
||||
**1. Identifică UN proiect half-hearted:**
|
||||
- Care e "ar fi bine" dar nu "TREBUIE pentru cineva anume"?
|
||||
|
||||
**2. Transformă în CONVICTION:**
|
||||
- CINE beneficiază când asta e complet?
|
||||
- Nu "câștig eu" — ci "cui servesc"?
|
||||
|
||||
**3. Următorul dubiu care apare:**
|
||||
- STOP → "Mă ZAPS-ez?" → Care etapă (Z/A/P/S)?
|
||||
- Reframe: "Ce învăț din asta?" (nu "Mă opresc din asta")
|
||||
|
||||
---
|
||||
|
||||
## 💎 Quote-ul zilei
|
||||
|
||||
> "If you're only ever trying to pay the bills, you're always filling a void, not building abundance. You need someone to fight for."
|
||||
>
|
||||
> — Brendan Burchard
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Semnalul că merge
|
||||
|
||||
**Half-hearted:**
|
||||
- Proiect 80% done, momentum pierdut
|
||||
- "Când am timp o termin"
|
||||
- Gânduri "ar fi bine dacă..."
|
||||
|
||||
**Full conviction:**
|
||||
- Clientul X TREBUIE să aibă raportul ăsta rapid → livrare completă
|
||||
- "Fac asta PENTRU [persoană/scop]"
|
||||
- Flow, nu greutate
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sursă
|
||||
|
||||
- [Brendan Burchard - Billionaire Coach](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/2026-02-23_billionaire-coach-abundance-mindset.md)
|
||||
- [Insights 23 februarie 2026](https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/insights/2026-02-23.md)
|
||||
|
||||
---
|
||||
|
||||
**Astăzi: Pune-te în joc. Complet. Cu conviction.**
|
||||
|
||||
**Tags:** @growth @work #conviction #half-heartedness #zaps #abundență #brendanburchard #mindset
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user