Compare commits

...

131 Commits

Author SHA1 Message Date
ec23d188ec feat: youtube handler analizeaza cu Claude; nota Grantham completă
- dashboard/handlers/youtube.py: după descărcare transcriere, cheamă
  `claude -p` cu un prompt structurat care generează TL;DR + puncte cheie
  + citate + idei acționabile + secțiuni tematice în proze. Fallback la
  transcriptul brut dacă Claude eșuează.
- nota Grantham: format complet — TL;DR, puncte cheie, citate,
  idei acționabile, secțiuni tematice în proze curgătoare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:03:29 +00:00
392d1a5be2 fix: nota Grantham rescrisă în proze curgătoare cu conținut real
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:00:09 +00:00
c8be07b1f6 Merge branch 'feat/kb-navigation-index' into voice/stt-quality
# Conflicts:
#	memory/kb/index.json
2026-06-27 17:57:22 +00:00
97e34be863 fix: nota Grantham include transcriptul complet (60k chars)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:55:38 +00:00
5c9748ffb4 feat(memory): hybrid retrieval — navigation index.md + RAG hardening
Expose a navigation layer to the agent and harden RAG, after analyzing the
OKF note and testing on the real KB.

- memory_search.search(): dedupe best-chunk-per-file (a relevant note can no
  longer be buried by another file's chunks) + keyword fallback tagged
  degraded:True when Ollama is unreachable (no more hard crash).
- update_notes_index.py: emit per-folder index.md + root router; prune empty
  folders; fix latent subcategory->project bug.
- Exclude generated index.md from RAG rglob (reindex/incremental) + indexer
  scans + heartbeat freshness check (prevents self-pollution / reindex thrash).
- CLAUDE.md: reframe memory as hybrid (navigation first, RAG for fuzzy recall).
- Delete stale orphan kb/youtube/index.json; correct the OKF source note.
- Tests: dedup, keyword fallback, index.md exclusion. Plan + review in docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:52:27 +00:00
6e9dfd137c feat: youtube_subs + dashboard includ descrierea video ca index
- tools/youtube_subs.py: get_subtitles() returneaza acum (title, desc, transcript).
  Functii noi is_description_about_video() si extract_relevant_description()
  detecteaza daca descrierea contine capitole/timestamps (nu doar promotie autori)
  si curata trailing-urile promotionale inainte sa includa descrierea in output.
- dashboard/handlers/youtube.py: aceleasi functii adaugate; nota KB generata
  include acum un bloc "Descriere / Index" daca descrierea e relevanta pentru video.
- memory/kb/youtube: nota Jeremy Grantham (AI bubble, investitii, toxicitate)
  cu descrierea ca index de capitole.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:00:59 +00:00
a8d024944d chore: auto-commit from dashboard 2026-06-09 09:13:35 +00:00
55a175f78e chore: auto-commit from dashboard 2026-06-02 12:42:04 +00:00
735b282179 automatic 2026-05-29 13:35:15 +00:00
c401204fa2 fix(email): accept forwarded emails regardless of original sender
Gmail preserves the original sender when forwarding — whitelist check
was blocking all Fwd: emails not from mmarius28@gmail.com.
echo@romfast.ro is private, so any Fwd: arriving there is from Marius.
Also strip ***SPAM*** prefix from slugs for cleaner filenames.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:28:47 +00:00
0ce8a5a04d Update cron, dashboard, root +3 more (+1 ~11) 2026-05-28 20:21:28 +00:00
e79bed7afe feat(voice): unify Discord voice↔text session (squash of voice/text-unify)
Voice utterances and text messages on the same Discord channel now share
one Claude session, and Echo's voice replies are mirrored back into the
text channel. Replaces the old voice:<id> session-key split.

Changes:
- src/adapters/_text_chunks.py: new leaf module for split_message
  (used by both discord_bot and voice pipeline)
- src/router.py: drop voice: prefix from session_key; add [voice] marker;
  strip leading [speaker:/[voice] tokens from user input (anti-jailbreak);
  remove dead double-clear of voice: key
- src/claude_session.py: include personality/VOICE_MODE.md unconditionally
  (rules become per-turn-aware via [speaker:] prefix instead of session flag)
- src/voice/pipeline.py: VoiceSession splits text_channel_id +
  voice_channel_id; resolve text channel per-send (no stale refs); mirror
  Echo's reply text into the text channel after route_message returns
- src/adapters/discord_voice.py: /voice join passes both channel ids
- src/adapters/discord_bot.py: import split_message from leaf module
- personality/VOICE_MODE.md: rewrite as per-turn dynamic rules;
  add synthesis instructions for text turns after voice turns

Tests:
- tests/test_router.py: 4 new cases (plain channel_id, anti-jailbreak,
  text-adapter regression, no-double-clear)
- tests/test_pipeline_mirror.py: new — Echo reply mirror chunking,
  empty guard, mirror_enabled=False, send-raises resilience
- tests/test_voice_session_channel_ids.py: new — split-attr contract
  + metrics payload schema
- tests/test_voice_session_cleanup.py: update for new kwargs

Plan: /home/moltbot/.claude/plans/vreau-ca-tot-textul-greedy-rivest.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:24:15 +00:00
4be70440e8 feat(voice): DAVE E2E + full voice UX (squash of voice/dave-recv)
Squashed branch: voice/dave-recv → master. Closes Pas 12 (DAVE E2E) and lands
voice-mode UX polish + verbal voice control on top of the Pas 1-10 scaffolding
already on master.

## DAVE E2E receive-side decrypt (e4f3177)

Vendored fork: discord-ext-voice-recv 0.5.3a+echo.dave1. Patches the receive
pipeline to handle Discord's mandatory DAVE encryption on voice gateway v=8.
- `_maybe_dave_decrypt`: uses davey.can_passthrough(user_id) as primary gate,
  falls through to dave.decrypt for DAVE-epoch peers, drops on decrypt failure
  without killing the reader thread.
- VAD fix: silero-vad v5+ requires exactly 512 samples; our 100ms window
  (1600 samples) was silently raising ValueError → STT never fired. Now slice
  into 512-sample chunks.
- Whisper: bumped beam_size 1→5 and added RO initial_prompt.
- Tests: 11 DAVE unit tests + 2 callback integration tests + contract test
  with fork-version guard.

## Voice UX polish (d1bc77e)

- Killed the 3s "mă gândesc" filler (always collided with Claude p50 4-7s).
- Barge-in via `ttsq.clear()` at top of `on_segment_done`.
- DTX silence-flush poller (200ms tick) — Discord stops sending RTP packets
  when silent, so the inline silence-check in sink.write() never fired for
  trailing audio; background thread handles it.
- `EchoStreamingAudioSource.read()` non-blocking — old `get_frame(timeout=0.1)`
  wrecked Discord's 20ms cadence and the client interpreted bursts as
  stuttering (Marius heard "4 de minute" instead of full sentence).
- RO time expansion: 23:09 → "douăzeci și trei și nouă minute".
- Supertonic Unicode sanitize centralized in tools/tts.py.
- Whisper local_files_only=True — no HF metadata GET on each startup.
- Diagnostic logging through sink → VAD → Claude stream → TTS chain.

## Voice mode iteration (e589e48)

- `personality/VOICE_MODE.md` — voice-tailored system prompt (short, no
  markdown, no abbreviations, time without seconds, distances in
  "mii"/"milioane"); plumbed via build_system_prompt(voice_mode=True).
- Isolated voice session key `voice:<channel_id>` — voice doesn't share
  context with text adapter on the same channel; auto-applied without
  /clear ceremony. /clear drops both keys.
- Metric units + Romanian thousands (normalize.py): "384.000 km" →
  "trei sute optzeci și patru de mii de kilometri" with feminine-correct
  pluralization and "de" particle for ≥20.
- `/voice setvoice <M1-F5>` slash command with native autocomplete; swaps
  live + persists voice.default_voice to config.json.
- Verbal voice change (src/voice/voice_commands.py + 29 tests) — "schimbă
  vocea pe M5", "voce em cinci", with permissive substring fallback for
  Whisper-mangled forms like "Mâcinci"=M5 and "unul cinci"=M5. Whisper
  initial_prompt now lists voice vocabulary to bias STT toward clean
  outputs.
- Fast barge-in: VAD ≥2 consecutive windows (~200ms) on Marius's user
  while Echo has pending TTS frames → cut him off mid-sentence so user
  doesn't wait the full silence + STT cycle. Acoustic echo bleed-through
  still requires headphones (no AEC).

## Test suite

130 voice + router tests pass (test_voice_recv_dave, test_voice_session_cleanup,
test_voice_adapter_contract, test_voice_normalize, test_voice_commands,
test_router).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:00:27 +00:00
13931db953 feat(voice): Pas 7 — discord_voice.py slash group + discord_bot wiring (CONVERGENCE)
src/adapters/discord_voice.py (NEW, ~280 linii):
- /voice slash group cu subcommands: join, leave, doctor, mirror on|off,
  record on|off
- warmup_models() async — eager faster-whisper + silero-vad load la
  on_ready pe background task
- _voice_load_error guard — /voice join responds ephemeral graceful
  dacă models load fail
- _voice_sessions: dict[int, VoiceSession] keyed pe guild_id
- _get_whitelist() re-reads config la fiecare apel — runtime edits la
  voice.allowed_user_ids fără bot restart
- Double-join guard, try/except graceful pe connect/listen/play/presence
- /voice doctor surfaces _voice_load_error + libopus state ephemeral
- await interaction.response.defer(ephemeral=True) în orice voice
  command (Discord 3s timeout pattern din CLAUDE.md)

src/adapters/discord_bot.py — 3 surgical edits:
- Linia 115: intents.voice_states = True (după intents.message_content)
- Liniile 963-966: import + register_voice(tree, client) +
  tree.add_command(voice_group), după /audio body
- Liniile 1126-1130: discord_voice._models_warmup_future =
  asyncio.create_task(discord_voice.warmup_models()) la end of on_ready

Adapted la pipeline.py API actual (channel_id int nu str, kw-only args
după *, EchoVoiceSink(session, bot_user_id) signature, loop kwarg
mandatory pentru cross-thread bot.change_presence).

Smoke import OK. test_discord.py 61 pass / 4 fail (pre-existing pe
master, verificat via git stash). test_voice_session_cleanup 5/5 +
test_voice_adapter_contract 22/22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:55:57 +00:00
23666f7910 feat(voice): Pas 5 — voice/pipeline.py VoiceSession + EchoVoiceSink + cleanup
Central voice pipeline (~250 LOC + docstrings = ~430 lines):

VoiceSession (context manager + idempotent cleanup pe 5 căi):
- __enter__: acquire _lock, open JSONL (record=on)
- __exit__: calls cleanup("exit"), nu suprimă exceptions
- cleanup(reason): IDEMPOTENT, side effects o singură dată — JSONL
  flush+close (record=on) sau delete (record=off), bot presence cleared,
  voice_client.cleanup(), ttsq.stop(), cancel filler task, lock release,
  structured log la logs/voice_metrics.jsonl
- on_segment_done(speaker_id, text, no_speech_prob): mirror text channel,
  append JSONL, arm 3s filler timer, route_message cu on_text callback
  + cancel filler la first block
- last_activity_ts: time.monotonic() — caller-driven 5min auto-leave

EchoVoiceSink(session, bot_user_id):
- wants_opus() False (PCM)
- write() runs în voice_recv reader thread (threading primitives only):
  - GUARD 1: user None/id==0/id==bot_user_id → return (load-bearing
    echo prevention)
  - GUARD 2: whitelist filter (empty = allow all)
  - Buffer 20ms packets per-user → batch 100ms (5×20ms = 19200 bytes)
    → silero-vad threshold 0.5 → 800ms cumulative silence flush
  - _flush_to_stt: faster-whisper small int8 cpu_threads=4 lang=ro
    beam_size=1, no_speech_prob > 0.6 drop, schedule on_segment_done
    via run_coroutine_threadsafe pe session.loop

Module helpers (lazy thread-safe singletons): _get_whisper_model,
_get_silero_vad. Constants: FILLER_DELAY_S=3.0, SILENCE_FLUSH_MS=800,
VAD_THRESHOLD=0.5, VAD_WINDOW_MS=100, NO_SPEECH_DROP_THRESHOLD=0.6.

Decisions:
- STT runs in audio thread — acceptable la 2.25s p50 (user just stopped
  talking, no batching contention). Wrap în ThreadPoolExecutor.submit
  if perf bites later.
- Downsample 48k→16k via 3-sample averaging (no scipy dep). Whisper
  robust la mild aliasing.
- Energy-RMS VAD fallback dacă torch import fail — graceful degrade.
- router_route_message injection seam ca kwarg pentru testabilitate.
- bot.change_presence handling cross-thread via run_coroutine_threadsafe.

tests/test_voice_session_cleanup.py — 6 tests:
- voice_leave / disconnect / crash via __exit__ / auto_leave /
  user_left_channel (5 cleanup paths each verified for: JSONL state,
  presence cleared, voice_client.cleanup, ttsq.stop, lock release,
  idempotency)
- 1 robustness cross-cut (double-cleanup safety)

6/6 PASS. Regression suite 63/63 PASS (normalize + adapter + mutex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:55:57 +00:00
217da65417 feat(voice): Pas 6 — voice/tts_stream.py streaming TTS pipeline
src/voice/tts_stream.py (~280 lines):
- clause_segments(text, min_words=8): yield Romanian-aware clause chunks.
  Split la punct (./!/?;:,) cu accumulation până min_words satisfied;
  edge case text < min_words → single chunk. NU split mid-word/number/
  currency. Romanian intonație de frază se rupe la sentence break — 8+
  words minimizează seams.
- TTSQueue worker thread: text queue in → PCM frames out. Methods:
  start/stop/push_text/push_filler/clear/is_empty. normalize_for_tts()
  apply first, then clause_segments(), then Supertonic synth per chunk.
- EchoStreamingAudioSource(discord.AudioSource): read() pull from PCM
  queue, 20ms frames (3840 bytes 48kHz s16le stereo). Eliminates RTP
  gap between play() calls — single play() per session, source pulls.
- load_thinking_wav(): one-shot cache → 140 × 20ms frames (~2.8s)
  pentru filler "Stai puțin să-mi adun gândurile".
- wav_to_pcm_20ms_frames(): WAV parser + ffmpeg subprocess resample
  la 48kHz s16le stereo dacă nevoie.

Smoke test (în session): clause_segments behaviour OK, thinking.wav
loads, TTSQueue + EchoStreamingAudioSource construct clean. Integration
testing deferred la convergență (Pas 7 + Pas 11).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:44:13 +00:00
0cc01c1450 feat(voice): Pas 10 — eco doctor voice stack checks
cli.py: +101 / -0, append 9 checks după existing 15:
1. libopus loaded by discord.py (load_default fallback)
2. ffmpeg in PATH
3. Supertonic TTS reachable :7788 (5s timeout POST)
4. faster-whisper importable (no model load — too slow for doctor)
5. silero-vad importable
6. discord.ext.voice_recv importable (vendor package guard)
7-9. assets/voice/{thinking,beep_200ms,mhm}.wav exist + size thresholds

Helper _voice_doctor_checks() returns list[tuple[str, bool]] matching
doctor's reporting style. Replicates voice_setup.py logic in doctor
format (voice_setup uses ANSI colors directly, doctor uses (label, ok)
tuples — separate Option B implementation). Graceful ImportError
handling per check — never crashes the rest of eco doctor.

Exit code 1 corectly surfaces missing libopus (Discord voice silent
without it). Use `sudo apt install -y libopus0` to clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:44:13 +00:00
c93c4f822e docs(voice): Pas 9 — personality voice mode + Discord Voice section
personality/AGENTS.md — added ## Voice mode section after ## Platform
Formatting (logical fit: voice este încă un platform-specific register,
alături de Discord/WhatsApp formatting). 7 reguli aplicabile când
adapter_name == "discord-voice":
- 1-3 propoziții max
- fără markdown / fără bullet / fără linkuri
- numere/valute conversaționale ("treizeci de lei" nu "30 RON" —
  normalize.py face conversia tehnică)
- lung/structurat → "L-am scris în chat." + text mirror
- ton ca la o cafea cu Marius, nu raport corporate

personality/TOOLS.md — added ### Discord Voice section după ### Whisper:
- ce e (bot ascultă/transcrie/răspunde rostit)
- "în voce" = /voice join, presence Listening, auto-leave 5min
- latency expectations ~5s perceived, filler peste 3s
- streaming TTS per clauză (zero gap)
- limitări (1-3 propoziții, STT pe cuvinte rare/acronime)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:43:16 +00:00
3af6bcaea4 feat(voice): Pas 8 — threading.Lock per channel_id mutex + voice augment
Fix arhitectural general (beneficiu și pentru text adapters), nu doar voice.

src/claude_session.py:
- _session_locks: dict[str, threading.Lock] cu bootstrap lock pentru
  lazy creation thread-safe.
- _get_session_lock(channel_id) helper.
- send_message() body wrapped în with _get_session_lock(channel_id).
- threading.Lock (NU asyncio.Lock) — send_message e sync subprocess.run
  blocking; asyncio.Lock nu protejează cod sync rulat via to_thread.
- Per-channel granularity preserved — different channels run în paralel.
- send_message() public signature unchanged.

src/router.py:
- route_message(): dacă adapter_name == "discord-voice", prepend
  [speaker:<user_name>] prefix (Config.get("voice.user_name", "user")).
- Original text variable left untouched for downstream paths.
- Text adapters: zero behavior change.
- route_message() public signature unchanged.

tests/test_claude_session_mutex.py — 6 tests REGRESSION-CRITICAL:
- same channel serializes (concurrent → mutex serializes, no overlap)
- same channel lock identity (same dict entry per channel_id)
- different channels run in parallel (overlap MUST fire)
- 3 channels all overlap
- contested acquire blocks then proceeds (policy: blocking, not fail-fast)
- lock released on subprocess exception (no deadlock on crash)

Acquisition policy: BLOCKING acquire bound by claude --timeout (5min default)
nu fail-fast — adapters already serialize via asyncio.to_thread queue, un
non-blocking acquire ar surface transient busy errors.

Test results: 82 passed (51 existing + 31 new). 2 PRE-EXISTING failures în
TestPromptInjectionProtection (stale assertion vs current prompt text) —
out of scope, recomand ticket separat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:43:05 +00:00
a3eefbc799 feat(voice): Pas 4 — _discord_voice_adapter.py thin layer + contract test
Adapter layer peste vendored discord-ext-voice-recv. Re-exports:
VoiceReceiveClient, AudioSink, VoiceData, plus async helper
connect_voice(channel). Discord voice protocol e fragil, upstream e
hobby fork — dacă pică, swap la py-cord = doar acest fișier rescris.

Contract test (22 assertions) prinde drift la upgrade vendor:
- VoiceReceiveClient methods: connect/disconnect/listen/stop_listening/
  is_listening/stop/cleanup
- listen(sink, *, after=None) signature
- sink property (read/write)
- AudioSink methods: write/cleanup/wants_opus + write(self, user, data) arity
- VoiceData slots (packet/source/pcm) + .opus property

Critical pentru Lane PIPE downstream: write() e called from audio thread
(NOT asyncio loop) — threading primitives mandatory pentru EchoVoiceSink.

22/22 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:42:50 +00:00
a48562b2f5 feat(voice): Pas 3 — voice/normalize.py + 35 RO test cases
Pure functions pentru TTS text normalization (RO):
- strip_markdown: regex bold/italic/code/link/heading/list
- expand_numbers_ro: num2words pentru cardinals + decimal handling
  ("3.14" → "trei virgulă paisprezece", "3.05" → "trei virgulă zero
  cinci" digit-by-digit la leading zero)
- expand_currency: formă naturală RO ("12.50 RON" → "doisprezece lei
  și cincizeci de bani", "$25.99" → "douăzeci și cinci de dolari și
  nouăzeci și nouă de cenți")
- expand_symbols: %/&/@/° + whitespace collapse
- expand_abbreviations: etc./dl./dna./nr./ş.a./ş.a.m.d.
- normalize_for_tts: full pipeline + hard truncate 200 cuvinte cu
  "Restul l-am scris în chat."

Pipeline order: markdown → abbreviations → currency → numbers →
symbols → truncate. Currency BEFORE numbers — altfel "12.50 RON" se
degradează la "doisprezece virgulă cincizeci RON". Romanian "de"
particle rule: n>=20 AND (n%100 not in 1..19) → "o sută de lei",
"o sută cinci lei" (no "de"). n=1 with currency → "un dolar" /
"un leu" (article, nu cardinal).

35/35 tests pass: markdown(5), cardinals(6), decimals(4), currency
RON/USD/EUR/GBP mix(8), symbols(4), abbreviations(4), truncation(2),
edge cases empty/whitespace(2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:42:41 +00:00
af5af8133f feat(voice): Pas 2 — install voice deps, vendor discord-ext-voice-recv, setup assets
Foundation pentru Discord voice-to-voice pipeline.

- requirements.txt: faster-whisper, silero-vad, num2words, numpy, PyNaCl
- vendor/discord-ext-voice-recv/: vendored la commit ac04ea7b09 (bump version
  0.5.3a) — Discord voice protocol fragil, upstream hobby fork. Adapter layer
  in src/voice/_discord_voice_adapter.py izolează churn (swap la py-cord =
  doar acel fișier rescris). VENDOR_INFO.md documentează update procedure.
- tools/voice_setup.py: idempotent setup script — libopus check, ffmpeg
  check, Supertonic reachable, faster-whisper/silero-vad warm, assets
  generation. Exit 0 = green, 1 = needs human (currently libopus missing
  needs `sudo apt install -y libopus0`).
- assets/voice/: thinking.wav (filler "Stai puțin să-mi adun gândurile",
  ~2.8s), mhm.wav (listener noise), beep_200ms.wav (wake-up tone 880Hz).
- src/voice/__init__.py: package stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:42:27 +00:00
c6d11bdf9f chore(voice): spike STT latency benchmark + HT contention lesson
Pas 1 (BLOCKING) din Discord voice-to-voice test plan. Sweet spot empiric
pe i7-6700T: faster-whisper small int8 @ cpu_threads=4 → p50 2.25s,
p95 2.64s, mean RTF 0.46. Curba HT: 2t=3.25s → 4t=2.25s (sweet) →
6t=2.79s (regres +24% prin contention). tiny respinge — halucinează RO.

- tools/voice_bench.py: harness benchmark cu 8 sample-uri RO sintetizate
  via Supertonic API, măsoară p50/p95/RTF pentru small+tiny pe N threads.
- tools/voice_bench_results*.json: raw output 3 pass-uri (threads 2/4/6).
- tasks/voice-bench-results*.md: summary markdown per pass.
- tasks/lessons.md: HT contention rule — cpu_threads = physical cores,
  rulează sweep nu single-point pentru ML inference compute-bound.

Budget updated în plan-uri: STT p50 1.5s → 2.5s, perceived 4s → 5s p50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:52:11 +00:00
44cf0001bb chore: auto-commit from dashboard 2026-05-27 06:12:13 +00:00
574f9be5ea feat(discord): add /audio slash command with voce + text_sau_url params
Adds missing /audio slash command on Discord with:
- voce: optional choices M1-M5 / F1-F5 with descriptions
- text_sau_url: optional text or URL input
- handles __AUDIO__: response by sending WAV as file attachment

Telegram already had /audio fully implemented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 06:00:54 +00:00
0d2d5b860d chore(tts): schimbă vocea default din M1 în M2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 05:56:55 +00:00
8fe39adc01 fix(tts): trimite lang=ro explicit la Supertonic API
Parametrul `lang` era definit (DEFAULT_LANG = "ro") dar nu era inclus
in request-ul HTTP catre /v1/audio/speech. Adaugat "lang": lang in
body-ul JSON si lang="ro" explicit in _tts_synthesize().

OpenAPI-ul Supertonic confirma ca /v1/audio/speech accepta `lang`
ca parametru optional (OpenAISpeechRequest schema).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 05:47:39 +00:00
3dd2ddbd6a chore: auto-commit from dashboard 2026-05-27 05:40:22 +00:00
2a05f7cf49 chore: auto-commit from dashboard 2026-05-26 21:09:55 +00:00
ba63e22277 chore(cron): remove newsletter-test and content-discovery jobs
newsletter-test și content-discovery eliminate la cererea lui Marius.
crontab check_newsletter_cercetasi.py de asemenea șters.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-20 23:33:54 +00:00
990be00b70 chore: auto-commit from dashboard 2026-05-20 22:28:39 +00:00
8cb76e130d chore: auto-commit from dashboard 2026-05-14 22:09:33 +00:00
3570d9a625 chore(kb): notițe youtube mai, fix email tools, update newsletter/anaf-monitor
Adaugă 4 notițe YouTube (llama.cpp, Mario Zechner, bonificatie impozit,
AI scaffolding) + notă coaching grok. Actualizează index KB.
Fix email_digest și email_forward. Update newsletter cercetasi + cron jobs.
ANAF monitor hashes/snapshots/versions la zi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:02:55 +00:00
f04e033dbe fix(email): digest nu mai creează notițe KB — fetch direct IMAP
email_digest.py folosea save_unread_emails() care salva în memory/kb/emails/.
Notițele KB trebuie create DOAR de heartbeat. Acum digest-ul face fetch
direct din IMAP (ca email_forward.py), fără side effects pe KB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:17:18 +00:00
63b7fcd00e fix(email): include linkuri relevante în digest și TL;DR
Instrucțiunea era prea restrictivă (doar formulare/documente "acționabile").
Acum include orice URL relevant: articole, linkuri de citit, resurse.
Același comportament adăugat și în HEARTBEAT pentru TL;DR din KB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:14:20 +00:00
246986b5ae fix(email): afișează expeditorul și subiectul original la emailuri forwarded
La salvarea unui email forwardat, se extrage acum expeditorul original
din body și se elimină prefixul Fwd: din titlu — în loc de adresa lui Marius.
Corectat și fișierul deja salvat din 07 mai.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:12:42 +00:00
608668d8a6 fix(dashboard): replace broken Rulează Ralph on idle cards with Propose
The idle-state action called /api/projects/approve, which 404'd because
idle workspace dirs have no approved-tasks.json entry to mutate. Now the
button opens the Propose modal pre-filled with the workspace slug so the
user actually has a path forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:48:01 +00:00
2bcefe1ab4 feat(projects): approval guard + worktree-aware ralph execution
Two structural fixes that together let users manage feature-branch
work without manual intervention:

Approval guard — `/plan/start` returns 409 `already_committed` if the
project status is approved/running/complete, unless the body opts in
with `force=true`. Frontend now renders "Re-planifică" instead of
"Planifică" on approved cards and gates it behind a confirm dialog
that threads `force=true` through. Prevents an accidental click from
wiping `status=approved` and burning a fresh planning subprocess.

Worktree awareness — projects can now declare that they target a
feature branch on an existing Gitea repo, not a repo-per-slug clone.
Three optional fields added to approved-tasks.json: `repo` (default
= slug), `branch` (feature branch to create), `base_branch` (default
main). Wired through `/p` flag parser in router.py, the dashboard
Propose modal's new "Avansat" section, and the night-execute prompt
which clones {repo} and creates {branch} from {base_branch} before
running ralph.

CLAUDE.md updated with both flows + the new schema fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:27:14 +00:00
a5cab9677a chore: untrack runtime state files
approved-tasks.json, dashboard/status.json, anaf-monitor/monitor.log
are auto-modified by background processes (heartbeat, cron jobs, ANAF
monitor). Untracking them stops the noisy "auto-commit from dashboard"
churn. Files stay on disk; readers (router._load_approved_tasks etc.)
already handle missing files by returning empty defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:48:55 +00:00
f4880a2a18 chore: auto-state + new KB notes
State files updated by dashboard/heartbeat/cron jobs, plus new KB
captures (samsung firmware todo, scout song reel, weekly youtube notes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:47:16 +00:00
8432fe3150 feat(planning): full chat history + auto-advance phases
Three fixes that together restore the planning UX:

- Dashboard reopen showed only a 500-char truncated excerpt of the last
  assistant message. Backend now reads the Claude session JSONL directly
  and returns full per-turn history; frontend iterates and renders all
  bubbles, falling back to last_text_excerpt when the JSONL is missing.
- Phases never advanced because the agent ran /plan-* skills inline as
  tool calls and the marker protocol was loose. Tightened the planning
  prompt (mandatory PHASE_STATUS marker on the last line of every turn,
  ban on inline phase invocation), and the frontend now auto-calls
  /plan/advance when phase_ready=true.
- The phase strip never showed visual state because data-phase values
  ("office-hours") didn't match orchestrator phase names ("/office-hours").
  Added normalizePhase + cleanup of PHASE_STATUS markers from rendered
  bubbles.

Also bumps eco.py session-content truncation from 2k to 20k so /eco
session views aren't cut mid-response either.

Bumps last_text_excerpt fallback in planning_session.py from 500 to
50_000 so even when the JSONL is unavailable, the bubble isn't sliced
mid-word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:47:10 +00:00
d0faeed181 chore: auto-commit from dashboard 2026-04-30 17:01:55 +00:00
e3c18f15ed chore: auto-commit from dashboard 2026-04-29 20:13:54 +00:00
176dc01aa6 chore: auto-commit from dashboard 2026-04-29 16:04:28 +00:00
6d1d4bfeb5 fix(files): support ?file= param as login-safe alternative to #hash
Hash fragments never reach the server so they're lost during login
redirects. ?file= survives the ?next= flow; #hash still works for
direct access when already logged in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:16:32 +00:00
77df09974c fix(auth): restore /echo prefix after proxy strips it from next param
The reverse proxy strips /echo/ before Python, so next=/workspace.html.
Both the JS redirect and the server-side already-logged-in path now
prepend /echo to produce a valid public URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:11:22 +00:00
38259f3cfd fix(auth): redirect to original URL after login
Pass current path as ?next= when bouncing unauthenticated requests
to /echo/login; after successful auth, JS reads and validates the
param (must start with /echo/, not /echo/login) before redirecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:38:27 +00:00
b08f039917 chore(dashboard): remove unused local Inter fonts and tokens.css
Fonturile inter-*.woff2 și tokens.css nu mai sunt referențiate —
Inter se încarcă din Google Fonts, tokens.css a fost înlocuit
de professional-theme.css în romfast-website (fișier greșit în repo).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:44:25 +00:00
fb7ca74ca1 fix(service): add PATH to echo-taskboard so claude CLI is found
Systemd user units get a minimal PATH that omits ~/.local/bin where
the claude binary lives, causing plan/respond to 500 on every call.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-28 11:08:00 +00:00
8594f98bff fix(dashboard): resolve planning 404 for sessions started outside dashboard
_resolve_planning_key searches all active sessions by slug regardless of
adapter, so respond/finalize/cancel/advance work even when planning was
initiated from Discord or Telegram.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-28 10:57:34 +00:00
1462f98ae9 chore: auto-commit from dashboard 2026-04-28 10:46:30 +00:00
5e930ade02 feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat
Merges workspace.html + ralph.html into a single unified project hub with:
- Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict)
- 9-state project badge system (running-ralph/manual, planning, approved,
  pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix
- SSE realtime + polling fallback, version-based optimistic concurrency (If-Match)
- Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume)
- Propose modal (Variant B: inline Plan-with-Echo checkbox)
- 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar)
- Inter font self-hosted + shared tokens.css design system + DESIGN.md
- src/jsonlock.py (flock helper, sidecar .lock for stable inode)
- src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh)
- 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28
- No emoji anywhere (enforced by test_dashboard_no_emoji.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:26:19 +00:00
e771479d67 chore: auto-commit from dashboard 2026-04-28 05:29:52 +00:00
2830bf48f2 fix(dashboard): ralph.html URL prefix /echo/api/ralph (was /api/ralph → 502)
Tailscale Serve mapează /echo/* → 127.0.0.1:8088 (dashboard) și / →
:18789 (alt backend). Browser-ul calling /api/ralph/status (relative cu
absolute path la root domain) ajungea la 18789 care nu are endpoint Ralph
→ 502 Bad Gateway.

Fix: toate cele 6 URL-uri (5x fetch + 1x EventSource) folosesc acum prefix
/echo/api/ralph/* pentru a respecta routing-ul tailscale. Pattern consistent
cu workspace.html și index.html (verificat manual).

Endpoints atinse: /status, /<slug>/log, /<slug>/prd, /<slug>/stop,
/<slug>/rollback, /stream (SSE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 05:18:01 +00:00
44c9bb4e61 docs(claude): document instrumentation + realtime extras (post-merge)
- ralph_usage.py + usage.jsonl tracking
- /api/ralph/{usage,stream,<slug>/rollback} endpoints
- ralph.html realtime via EventSource (fallback polling)
- WhatsApp text-keyword shortcuts (aprob/stop/stare)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:14:46 +00:00
03d875974b Merge branch 'ralph/dashboard-realtime' — SSE realtime + story rollback
Server-Sent Events (TODO P3):
- GET /api/ralph/stream — signature-based change detection (poll FS 2s, emit
  doar la diff), heartbeat 30s, X-Accel-Buffering:no
- HTTPServer → ThreadingHTTPServer (altfel SSE blochează toate endpoint-urile)
- ralph.html: EventSource cu fallback permanent la polling 5s când CLOSED.
  Badge: 🟢 Live / ⏱ Polling / Offline

Story rollback (TODO P3):
- POST /api/ralph/<slug>/rollback — git revert --no-edit HEAD; fallback
  git reset --hard HEAD~1 doar la conflict
- Decrementează passes pe ultima story complete; clears failed/blocked/retries
  (atomic temp+rename)
- Slug strict regex ^[A-Za-z0-9_-]{1,64}$ + reject path traversal explicit
- Buton ↩️ pe card-uri running; confirm dialog înainte de execuție
- Response: {success, message, reverted_commit, story_reverted, method}

Tests: 39/39 pe test_dashboard_ralph_endpoint (era 19; +20 cazuri noi).

# Conflicts:
#	dashboard/api.py
#	dashboard/handlers/ralph.py
2026-04-26 19:14:17 +00:00
84f304f7be Merge branch 'ralph/instrumentation' — rate limit budget + WhatsApp keywords
Rate limit budget tracking (TODO P2):
- tools/ralph_usage.py — pure functions extract/parse/aggregate; CLI subcomenzi
  append/summarize. Atomic write JSONL.
- tools/ralph/ralph.sh: după fiecare claude -p, append usage entry la
  workspace/<slug>/scripts/ralph/usage.jsonl (best-effort)
- dashboard/handlers/ralph.py: GET /api/ralph/usage[?days=N] cross-project
  aggregation cu today_cost, today_runs, by_project, by_day

WhatsApp text-keyword commands (TODO P3):
- src/router.py: helper _translate_whatsapp_text — `aprob <slug>` → `/a <slug>`,
  `stop <slug>` → `/k <slug>`, `stare`/`stare <slug>` → `/l`/`/l <slug>`. Aplicat
  DOAR pe adapter whatsapp în _try_ralph_dispatch (Discord/TG nu sunt afectate).
  Propose intentionally NOT covered (descrierea fragilă).

Tests: 53 noi (28 ralph_usage + 21 whatsapp_keywords + 4 dashboard endpoint extend)
+ 0 regressions pe modulele atinse.
2026-04-26 19:12:43 +00:00
3c9322ba93 chore: live planning state — romfast-website (Marius testing W2)
approved-tasks.json mutat de start_planning_session cu status='planning'.
Sesiune activă: 14d2d96d-d4eb-4472-9b07-4a869909c564.

Confirmare empirică că flow-ul Discord/Telegram → modal/ForceReply →
PlanningOrchestrator funcționează end-to-end pe production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:12:31 +00:00
6d56356ada feat(dashboard): integrate Ralph nav link + add e2e planning walkthrough test
dashboard/api.py: adaug link "Ralph" (lucide bot icon) în NAV_HTML între
Workspace și KB. Pagina ralph.html se injectează corect cu nav-ul (verificat
live via curl pe :8088/ralph.html).

tests/test_e2e_planning_walkthrough.py (nou): 4 teste integration care
simulează scripted exact ce face un user pe Discord:
- click Planifică pe game-library cu UI scope → 4 faze (incl design-review)
- /office-hours → ceo → eng → design → final-plan.md stub scris pe disk
- "Dau drumul" → status approved + final_plan_path în approved-tasks.json
- description fără UI keywords → 3 faze (skip design)
- /cancel mid-planning → status revert pending, state cleared
- mesaj fără planning state → cade pe Claude main chat (NU orchestrator)

Subprocess `claude -p` mock-uit; testează tot wire-up-ul router → orchestrator
→ session și schema approved-tasks.json. Nu consumă credite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:35 +00:00
ff9b9a0d1d feat(dashboard): SSE realtime + story rollback button
Replaces 5s polling on /echo/ralph.html with EventSource streaming and adds
a rollback control for the running Ralph cards.

Server (dashboard/handlers/ralph.py):
- /api/ralph/stream — Server-Sent Events. Emits `event: status` whenever a
  signature over the projects' state changes (poll filesystem at 2s); emits
  `event: heartbeat` every 30s to keep proxies happy. Disables proxy
  buffering via X-Accel-Buffering:no.
- /api/ralph/<slug>/rollback (POST) — runs `git revert --no-edit HEAD` in
  the project; falls back to `git reset --hard HEAD~1` only if revert
  reports conflict. After rolling back the commit, decrements `passes` on
  the last user story marked complete in prd.json (atomic temp+rename
  write, same pattern as ralph_dag.py). Returns
  `{success, message, reverted_commit, story_reverted, method}`.
- _ralph_validate_slug tightened to a strict regex (alphanum + dash +
  underscore, ≤64 chars) plus explicit ../, /, \ rejection. All previously
  accepted slugs still pass; URL-encoded traversal and shell metachars
  now blocked before the filesystem is touched.
- _ralph_collect_status / _ralph_signature factored out of
  handle_ralph_status so the SSE loop can reuse them and detect changes
  cheaply.

Server (dashboard/api.py):
- HTTPServer → ThreadingHTTPServer with daemon_threads=True. SSE is a
  long-lived response; without threading a single client would block all
  other dashboard endpoints.
- /api/ralph/stream (GET) and /api/ralph/<slug>/rollback (POST) wired
  into the dispatch.

Client (dashboard/ralph.html):
- EventSource('/api/ralph/stream') with permanent fallback to 5s polling
  when readyState=CLOSED (no server, CORS blocked, browser without SSE).
- Indicator badge: 🟢 Live (SSE), ⏱ Polling (fallback), Offline.
- Rollback button (undo-2 icon) on running cards; native confirm() with
  message: "Asta va da git revert HEAD pe <slug> și va decrementa ultima
  story trecută. Continui?"

Tests (tests/test_dashboard_ralph_endpoint.py, +20 cases):
- Strict slug validator: underscore allowed, >64 rejected, special chars
  / backslash / URL-encoded traversal rejected.
- _ralph_collect_status + _ralph_signature: stable when nothing changes,
  flips when project added or `passes` toggles.
- Rollback: invalid slug → 400, non-git project → 400, real two-commit
  repo revert succeeds and decrements last passing story (US-002 goes
  passes:false while US-001 stays passes:true), no-passing-stories case
  succeeds with story_reverted=None, response shape contract, atomic
  helper leaves no .tmp file behind.
- API routing smoke: confirms ThreadingHTTPServer + stream + rollback
  references present in dashboard/api.py.

39/39 tests pass on tests/test_dashboard_ralph_endpoint.py. Pre-existing
failures in test_dashboard_constants.py::test_base_dir_is_echo_core (the
worktree dir is `echo-core-realtime`, not `echo-core`) and
test_dashboard_unified_index.py::test_index_has_all_panels are unrelated
to this change and reproduced on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:07:13 +00:00
3e7818286b feat(ralph): rate limit budget tracking + whatsapp text-keywords
Task #1 — Rate limit budget tracking MVP:
- tools/ralph_usage.py: pure functions (extract_usage_entry, parse_usage_jsonl,
  aggregate_by_day/_project, filter_by_days, summarize) + CLI append/summarize
  subcommands. Atomic write via temp+rename.
- tools/ralph/ralph.sh: după fiecare claude -p, append usage entry
  derivat din JSON envelope la <project>/scripts/ralph/usage.jsonl. Best-effort,
  niciodată blochează rularea (|| true).
- dashboard/handlers/ralph.py: GET /api/ralph/usage[?days=N] aggregează cross-
  project și returnează {today_cost, today_runs, by_project, by_day, ...}.

Task #2 — WhatsApp text-keyword commands:
- src/router.py: helper _translate_whatsapp_text mapează "aprob"/"stop <slug>"/
  "stare [<slug>]" → /a, /k, /l. Apelat DOAR pe adapter whatsapp în
  _try_ralph_dispatch (Discord/TG nu sunt afectate). NU acoperim propose
  intentionat — descrierea liberă e prea fragilă pentru parsing text-only.

Tests: 49 noi (test_ralph_usage 28 + test_whatsapp_keywords 21) + 4 noi în
test_dashboard_ralph_endpoint pentru /api/ralph/usage. Toate trec; regression
suite (test_router, test_router_planning, test_dashboard_ralph_endpoint,
test_whatsapp) — 90/90 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:05:50 +00:00
dedeedf024 fix(ralph): "Planifică" deschide modal/ForceReply când descrierea lipsește
Înainte: click pe 🧠 Planifică (Discord/Telegram) sau /plan <slug> fără descriere
pe un proiect din workspace fără entry în approved-tasks.json → mesaj eroare
"Adaugă mai întâi cu /p <slug> <descriere>" și user-ul trebuia să facă două
operații.

Acum:
- Discord button "Planifică" cu descriere goală → deschide RalphPlanModal cu
  TextInput pentru descriere; on_submit pornește direct start_planning_session
- Discord /plan <slug> fără description param și fără entry → același modal
  (response.send_modal ÎNAINTE de defer — Discord constraint)
- Telegram callback "Planifică" cu descriere goală → set state
  STEP_INPUT_DESCRIPTION_THEN_PLAN + ForceReply; handle_message detectează
  step și pornește planning cu textul user-ului
- ralph_flow.py: nou STEP_INPUT_DESCRIPTION_THEN_PLAN (alături de cel existent
  pentru propose-only)

start_planning_session deja auto-creează entry în approved-tasks.json dacă
proiectul lipsește, deci flow-ul e end-to-end: workspace → click → descriere
→ planning agent activ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:51:09 +00:00
bf9380f2ad docs(claude): consolidate Ralph + planning agent documentation post W1+W2+W3
Update secțiunea Ralph cu:
- Două căi de aprobare (direct /a sau /plan conversational)
- Comenzi noi: /plan, /cancel; UX interactiv pe /l (Views/InlineKeyboardMarkup)
- Schema approved-tasks.json extinsă (planning_session_id, final_plan_path)
- Smart gates dispatcher pe story.tags (W3)
- DAG-aware execution + retry guard + rate limit detection
- Dashboard live (/echo/ralph.html, /api/ralph/status)
- Story status: passes/retries/blocked în prd.json

Adăugat în "Fișiere cheie": planning_session.py, planning_orchestrator.py,
ralph_dag.py, ralph_flow.py, discord_views.py, ralph.html, handlers/ralph.py,
prompts/planning_agent.md, tasks/spike-planning-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:44:30 +00:00
4b494eb2f2 Merge branch 'ralph/ralph-qc' — W3 smart gates + DAG + dashboard live
Restructurare Ralph:
- tools/ralph_prd_generator.py — citește final-plan.md (de la W2 PlanningOrchestrator);
  prd.json schema extins cu acceptanceCriteria[], tags[], dependsOn[]
- tools/ralph/prompt.md — smart gates dispatcher pe story.tags (refactor→simplify,
  ui→qa+screenshot, vercel→push+gh checks, db→schema diff, default→/review)
- tools/ralph_dag.py — pure functions Python (infer_tags, force_include_tags,
  topological_eligible) + CLI subcommands chemate din ralph.sh
- tools/ralph/ralph.sh — DAG-aware story selection, 3-retry guard, rate limit
  detection (sleep 30min + 1 retry → mark failed: rate_limited)

Dashboard live:
- dashboard/handlers/ralph.py — /api/ralph/status, /<slug>/log, /<slug>/prd, /<slug>/stop
- dashboard/ralph.html — UI cards per project, polling 5s, status badges, ETA
- atomic prd.json writes (temp + rename) anti-coruption mid-write

Tests: 72 pass (test_smart_gates 30, test_dag_execution 22, test_dashboard_ralph_endpoint 20)
— 0 regressions.
2026-04-26 18:41:57 +00:00
36a38a1e26 Merge branch 'ralph/ralph-planning-agent' — W2 conversational planning agent
PlanningSession + PlanningOrchestrator pentru flow-ul interactiv
feature idea → plan aprobat → execuție Ralph. Fresh subprocess per skill
phase (office-hours → ceo → eng → design optional pe ui-scope detection),
coordinare prin disk artifacts gstack.

Schema approved-tasks.json extinsă cu planning_session_id + final_plan_path.
Adaptoare Discord/Telegram primesc /plan, /cancel, butoane Planifică/Continuă/
Dau drumul.

Spike Step 0 PASS confirmat empiric: claude -p '/skill' funcțional + AskUser
Question serializată ca text + --resume round-trip. Constrânt prin
--max-turns=20 cu retry pe error_max_turns.

Tests: 75 pass (test_planning_session, test_planning_orchestrator,
test_router_planning) — 0 regressions.
2026-04-26 18:41:15 +00:00
deb86c705f chore: kb auto-add — playlist-transe-meditatii (live mutation)
Salvat de Marius via dashboard în timp ce W2/W3 worktrees rulau în paralel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:40:23 +00:00
51e56af557 feat(ralph): conversational planning agent (W2)
Echo Core devine planning agent: poartă o conversație multi-fază cu Marius
folosind skill-urile gstack (/office-hours → /plan-ceo-review →
/plan-eng-review → /plan-design-review opt) și produce final-plan.md în
~/workspace/<slug>/scripts/ralph/, gata să fie consumat de Ralph PRD
generator (W3) noaptea.

Decizii arhitecturale (din eng review + spike findings):
- PlanningSession ca clasă SEPARATĂ de chat-ul main (NU mode=string param)
  — separation explicit. claude_session.py rămâne strict pentru chat;
  planning trăiește în src/planning_session.py + src/planning_orchestrator.py.
  Inheritance literală nu se aplică (claude_session.py expune funcții
  module-level, nu o clasă) — separation e satisfacută prin module distinct.
- Fresh subprocess PER skill phase, NU single resumed session — phase-urile
  coordinează via disk artifacts (gstack convention în
  ~/.gstack/projects/<slug>/). Avoids context window growth.
- --max-turns 20 default + retry pe error_max_turns la --max-turns 30.
  Spike a arătat că prompt-uri complexe pot exploda turn budget-ul.
- approved-tasks.json schema extins cu planning_session_id + final_plan_path
  (Status flow: pending → planning → approved → running → complete).
- State separat în sessions/planning.json (NU active.json), keyed pe
  (adapter, channel_id) pentru re-resume la restart echo-core.

Trigger-e:
- Discord: slash command /plan <slug> [descriere] cu autocomplete pe pending,
  buton "🧠 Planifică" în RalphProjectView, și /cancel slash command.
- Telegram: /plan + /cancel commands, plus buton "🧠 Planifică" în
  ralph project keyboard.
- Router: state-aware routing — dacă chat-ul e în planning, mesajele plain
  trec la PlanningOrchestrator.respond() prin --resume; /cancel revine la
  status pending; /advance / "Continuă faza" advance fază nouă (fresh
  subprocess); /finalize sau "Dau drumul" promote la status approved.

Discord defer pattern: toate butoanele noi (PlanningActiveView,
PlanningFinalView, "🧠 Planifică") apelează await
interaction.response.defer(ephemeral=True) ÎNAINTE de orice IO — evită
"Interaction failed" pe IO >3s.

UX strings warm + colaborativ (per design review): "🧠 Pornesc planning
pentru ...", "Răspunde aici", "Continuă faza", "Dau drumul tonight",
"Anulează" — niciun "Submit/Approve/Cancel" generic.

Tests: 23 noi (test_planning_session, test_planning_orchestrator,
test_router_planning) — toate pass. Mock pe _run_claude pentru a evita
subprocess Claude real în CI.

Files new:
  prompts/planning_agent.md
  src/planning_session.py
  src/planning_orchestrator.py
  tests/test_planning_session.py
  tests/test_planning_orchestrator.py
  tests/test_router_planning.py

Files modified:
  src/claude_session.py        — _run_claude(cwd=...) optional + surface subtype/is_error
  src/router.py                — state-aware routing, start_planning_session, planning_advance/approve/cancel, _ralph_propose schema cu planning_session_id + final_plan_path
  src/adapters/discord_bot.py  — /plan + /cancel slash commands; planning views imported
  src/adapters/discord_views.py — PlanningActiveView, PlanningFinalView, "Planifică" button în RalphProjectView, _split_chunks helper
  src/adapters/telegram_bot.py — /plan + /cancel handlers, callback_ralph extins cu plan/planadvance/plancancel/planapprove, planning keyboards

Status testelor pe modulele atinse: 75 passed, 0 failed
(test_claude_session security_section preexistent — neatins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:38:51 +00:00
655ed3ae09 feat(ralph): smart gates + DAG + dashboard live (W3)
Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de
5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate
limit detection, plus dashboard live cu polling 5s.

Changes:
- tools/ralph_prd_generator.py: parametru optional final_plan_path; când e
  furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories
  cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat
  păstrat — fără final_plan_path, fallback la heuristic-ul vechi.
- tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries,
  failed, blocked, failureReason, requiresDesignReview).
- tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) +
  dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default).
- tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression
  (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru
  migrations sau .sql; force vercel dacă există vercel.json) + topological
  sort cu blocked propagation + atomic prd.json updates.
- tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry
  counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry),
  CLI subcommands prin tools/ralph_dag.py helper.
- dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd
  + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill.
- dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s,
  drawer pentru log/PRD viewer, status colors (--status-running/blocked/
  failed/complete declarate inline), Lucide icons cu aria-labels.
- dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop).
- tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint).

Note arhitecturale:
- Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min)
- Tag validation rulează POST-iter pe diff git pentru anti-silent-regression
- Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:36:35 +00:00
e06a79d98c Merge branch 'ralph/ralph-ux-conv' — W1 interactive UX
Bring in interactive layer for Ralph commands: Discord Views/Modal,
Telegram InlineKeyboardMarkup + callback_ralph multi-step, ralph_flow
state management, WhatsApp text-only fallback with redirect hint.

Spike Step 0 PASS validated; W2 (planning agent) and W3 (Ralph QC +
dashboard live) follow in subsequent worktrees.
2026-04-26 18:18:06 +00:00
b95395ec2c chore: scheduler runtime state + spike findings
- cron/jobs.json: heartbeat last_run / next_run actualizat de scheduler-ul live
- tasks/spike-planning-findings.md: validare empirică Spike Step 0 pentru
  planning agent subprocess (claude -p + skills gstack + --resume round-trip)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:17:53 +00:00
86384b38e3 feat(ralph): interactive UX layer pe Discord și Telegram (W1)
Adaugă straturile interactive peste slash commands flat:

**Discord (`src/adapters/discord_views.py`):**
- `RalphRootView` — listă proiecte workspace cu emoji status + Refresh + Close
- `RalphProjectView` — Propose / Vezi PRD / Aprobă tonight / Status / Stop / Înapoi
- `RalphProposeModal` — TextInput pentru descriere feature
- Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button
  callback cu I/O (eng review concern #2 — "Discord 3s timeout")
- `/p slug` autocomplete din `~/workspace/`
- `/l` afișează `RalphRootView` ephemeral

**Telegram (`src/adapters/telegram_bot.py`):**
- `cmd_ralph_l` (fără arg) trimite `InlineKeyboardMarkup` cu workspace + active
- `callback_ralph` cu pattern `^ralph:` rutează: project, menu, refresh, close,
  propose, prd, status, approve, stop
- Pentru "Propose feature" → set ralph_flow state cu step=input_description
  + `ForceReply()`; `handle_message` detectează state și rutează la `_ralph_propose`
- Pasează `adapter_name="telegram"` la `route_message`

**State management (`src/ralph_flow.py`):**
- Atomic JSON peste `sessions/ralph_flow.json` (pattern reusat din claude_session)
- Schema per (adapter, chat, user): `{step, project?, expires_at, ...}`
- TTL 10 min default; `cleanup_expired()` și auto-drop la `get_state` pe expirate

**Router (`src/router.py`):**
- `route_message` primește `adapter_name` keyword arg
- `_maybe_whatsapp_redirect` adaugă "💡 Pentru meniu interactiv folosește
  Discord sau Telegram" la mesajele de usage când adapter_name="whatsapp"
- WhatsApp `_handle_chat` pasează `adapter_name="whatsapp"`

**Tests:**
- `test_ralph_flow.py` — 10 teste (round-trip, isolation, expiry, atomic write)
- `test_router.py::TestRalphDispatch` — 3 teste (whatsapp redirect, discord
  no-redirect, usage message)

Foundation pentru W2 (planning agent — STEP_IN_PLANNING reservat).

Spike Step 0 PASS: skill subprocess + AskUserQuestion→text serialization
confirmat empiric (vezi tasks/spike-planning-findings.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:14:24 +00:00
094c6be5a9 feat(ralph): unified slash commands /p /a /l /k cu legacy aliases
Restructurează comenzile Ralph într-un dispatcher unificat (_try_ralph_dispatch)
care suportă atât comenzile noi scurte (/p /a /l /k) cât și aliasurile legacy
(!propose !approve !status !stop). Pe Discord adaugă slash commands native cu
autocomplete dinamic pentru pending (/a) și running (/k). Pe Telegram apar în
meniul /. WhatsApp le parsează ca text plain.

Activează cron jobs morning-report (08:30) și evening-report (21:00) și adaugă
night-execute (23:00) pentru execuția autonomă a proiectelor aprobate.

Foundation pentru W1 din planul "Echo Core conversational planning agent".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:46:52 +00:00
479fcc4356 boris claude 2026-04-26 16:07:27 +00:00
b0535695f4 chore: auto-commit from dashboard 2026-04-26 15:56:52 +00:00
5745621e9b chore: auto-commit from dashboard 2026-04-26 15:54:58 +00:00
145e1eb3ab docs(claude): document Ralph autonomous execution system
Add full Ralph section to CLAUDE.md: flow diagram, !approve/!status/!stop
commands, file paths, status lifecycle, workspace projects list, and
safety rules (no core files, echo-core self-improve only on dedicated branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:52:22 +00:00
53c348f331 fix(ralph): fix gitea clone URL with token auth, clone all workspace repos
- Use GITEA_TOKEN from dashboard/.env for git clone in night-execute
- Fix remote URLs on existing workspace repos to include token
- Clone all 8 romfast projects to ~/workspace/: roa2web, btgo-playwright,
  space-booking, romfast-website, game-library, wol (+ gomag-vending, vending_data_intelligence_report)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:50:54 +00:00
90c2a90b5e feat(ralph): add autonomous project execution system
- router.py: add !approve, !status, !stop, !propose commands for project lifecycle management
- approved-tasks.json: coordination schema for evening→night→morning pipeline
- tools/ralph/: ralph.sh loop, prompt.md, prd-template.json
- cron/jobs.json: enable morning-report, evening-report, night-execute (23:00 opus)

Evening-report proposes features to approved-tasks.json as 'pending'; Marius
approves via !approve; night-execute launches ralph.sh per project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:20:52 +00:00
bee409d164 docs(kb): update infrastructure with HA, corosync tuning, OOM alerting
- Clone romfastsql repo local pe /home/moltbot/workspace/romfastsql/
- Fix: LXC 171 e pe pvemini, nu pveelite
- Adaug secțiuni lipsă: HA groups, corosync token tuning (post-incident 2026-04-20)
- Diagnostic tools: rasdaemon, netconsole, kdump-tools
- OOM alerting, mail notifications, swap pveelite

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-26 12:06:29 +00:00
e4674b5dda chore: auto-commit from dashboard 2026-04-26 08:06:52 +00:00
0bfa652b31 fix(heartbeat): suppress git-only alerts when rest is ok
Uncommitted files alone are not an actionable heartbeat alert.
Only send a message if there are other findings besides git status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:04:34 +00:00
ad681c7a73 fix(dashboard): align swipe-nav order with menu
Swipe stânga/dreapta urmează acum ordinea tab-urilor: Dashboard → Workspace → KB → Habits → Files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:19:07 +00:00
74d98553cc chore: auto-commit from dashboard 2026-04-25 22:10:07 +00:00
d22ce49d76 docs(kb): sync infrastructure with romfastsql proxmox config
LXC 171 mutat pe pveelite (nu pvemini), RAM 4GB (nu 16GB).
LXC 110 disk 8GB (nu 30GB), SSH user moltbot@.
Adăugat VM 302 (oracle-test, 10.0.20.130).
VM 201 extins cu detalii IIS, domenii, Win-ACME, ZFS replication.
VM 109 extins cu Oracle 19c, schedule backup RMAN.
Proxmox VE 8.4.14, storage cluster documentat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:05:09 +00:00
c146d68498 fix(dashboard): remove broken grup-sprijin nav link
Pagina cerea un index.json inexistent și nu mai este necesară.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 21:51:03 +00:00
512aa5cd06 fix(dashboard): update gitea repo references from clawd to echo-core
Referințele vechi ~/clawd și gitea.romfast.ro/romfast/clawd rămase
din migrarea OpenClaw au fost corectate în index.html și files.html.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 21:44:58 +00:00
f885d75528 chore: auto-commit from dashboard 2026-04-25 21:42:42 +00:00
1fbd624195 chore(kb): add memory/kb to git tracking
memory/* was fully ignored; now only memory/kb/ is tracked
so notes, coaching sessions, insights, and project docs are
versioned while embeddings and sqlite databases stay untracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:35:41 +00:00
e513c7fbf6 chore: auto-commit from dashboard 2026-04-25 08:19:40 +00:00
f9a091133a chore: auto-commit from dashboard 2026-04-24 16:31:39 +00:00
abadff4ea8 chore: auto-commit from dashboard 2026-04-24 10:34:19 +00:00
d3196b0717 chore(cron): silence anaf-monitor Discord notifications
Schimbă report_on din "changes" în "never" — datele ajung deja
în dashboard/status.json via update_dashboard_status(), Discord
nu mai primește notificări duplicate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:22:03 +00:00
1b2b37a6bb chore: auto-commit from dashboard 2026-04-23 21:24:43 +00:00
277a43b81f chore(cron): shift heartbeat window to 09-23 Bucharest time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:05:01 +00:00
04d49e7ea3 chore(cron): shift heartbeat window to 09-23 Bucharest time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:04:15 +00:00
537bab465c refactor(main): remove unused Python heartbeat in favor of cron job
Heartbeat is now handled exclusively by the Claude-based cron job
(heartbeat-2h in jobs.json), which is more flexible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:02:23 +00:00
0c02f0de50 fix(scheduler): suppress channel send when result is HEARTBEAT_OK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:59:11 +00:00
b9a5f733c2 chore: auto-commit from dashboard 2026-04-23 09:44:20 +00:00
42797c0bbb chore: auto-commit from dashboard 2026-04-22 20:54:11 +00:00
bfc2283e6f chore: auto-commit from dashboard 2026-04-22 11:05:14 +00:00
51af0918a4 feat(email): send attachments as WhatsApp documents, fix forward sender
- Add /send-document endpoint to WhatsApp bridge (base64 document send)
- save_email_as_note() now saves attachment files to disk alongside note
- email_digest: extract original sender for Fwd: emails so header shows
  the real author, not the forwarder; send attachment files after summary
- email_forward: send attachment files as documents after text parts
- Add extract_original_sender() and save_email_attachment_files() helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:50:40 +00:00
417de65069 fix(email): use original sender for forwarded emails in digest
Digest was attributing forwarded emails to the person who forwarded
them. Now Claude is instructed to identify the original sender from
the forwarded headers and ignore the forwarder entirely. Also drops
pleasantries/apologies from the summary — facts only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:48:11 +00:00
c2455e6245 improve(email): switch digest prompt to factual briefing style
Previous prompt produced narrative, personal-tone summaries. New prompt
enforces third-person, journalistic style: who sent what to whom first,
then concrete facts, dates, and actions — no interpretation or filler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:43:58 +00:00
56f6c0df01 feat(email): show attachments in digest and forward commands
Add get_email_attachments() helper that extracts filenames from MIME
parts. Email notes now include an Atașamente section; forwarded emails
show attachment names in the WhatsApp header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:41:21 +00:00
eb693a2e71 improve(email): rewrite digest prompt for context-aware summaries
Rigid bullet schema worked for event emails but stripped all
narrative context from argumentative/organizational messages.
New prompt adapts structure to email type and prioritizes
completeness over brevity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:33:34 +00:00
30678e6abf fix(email): send WhatsApp notification when no new emails found
Previously digest and forward commands silently exited when inbox
was empty, leaving the user with no feedback after the initial
"processing..." confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:29:58 +00:00
2dd5aee9a7 chore: auto-commit from dashboard 2026-04-21 13:56:53 +00:00
a5d054d16f docs(migration): record post-migration state and concrete rollback values
Migration executed 2026-04-21 10:04 UTC. Playbook now carries the actual
SHAs, backup paths, stripped credentials inventory, verification evidence,
and a rollback block with filled-in values for this specific cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:28:53 +00:00
9bc5c3a3a2 chore: auto-commit from dashboard 2026-04-21 10:23:21 +00:00
d741541e23 test(dashboard): cover constants, git helper, cron endpoint, files sandbox 2026-04-21 07:22:48 +00:00
fa7c0fd1c6 docs(claude): reflect in-repo memory and dashboard layout after openclaw consolidation 2026-04-21 07:19:48 +00:00
bb917e0b33 feat(dashboard): add echo-taskboard.service template (systemd user) 2026-04-21 07:19:23 +00:00
0ac0f3b907 feat(dashboard): consolidate /api/git-commit into /api/eco/git-commit 2026-04-21 07:17:11 +00:00
e0abe5cdfc feat(dashboard): drop /api/agents and /api/activity endpoints 2026-04-21 07:16:20 +00:00
bee21594f5 test(cron): validate jobs.json schema per kind
Loads cron/jobs.json and asserts: unique names, valid cron expressions
(APScheduler parseable), bool enabled field; kind:"shell" entries must
have non-empty channel, non-empty command list of strings, valid
report_on, and timeout within [1, 3600] when present; claude entries
must have non-empty prompt, valid model, list-typed allowed_tools.
Sanity-checks that shell commands reference existing scripts in the
repo and that no imported claude prompt still points at /home/moltbot/clawd/.
2026-04-21 07:15:00 +00:00
dd8f40774f test(migrations): cover import script translation, skip list, and prompt rewrite
18 tests: --dry-run safety, UTC -> Bucharest hour-shift vs. already-tagged
Bucharest passthrough, antfarm/night-execute/YouTube: skip list behavior,
cd ~/clawd and absolute /home/moltbot/clawd/ rewrites, clawd-archive /
clawdbot negative-match guard, duplicate-name preserving existing entry,
--skip-disabled / --skip / --channel flags, non-cron schedule safe-skip,
translate_job enabled/model field preservation.
2026-04-21 07:14:51 +00:00
df8ccc694b docs: add MIGRATION-PLAYBOOK.md for manual cutover steps
Manual sequence Marius runs AFTER the PR merges. Pre-flight (tests,
backups), stop-services, ANAF live-state copy, dashboard migration,
memory inversion (clawd/memory -> echo-core/memory), systemd, crontab,
OpenClaw decommission, verification, and rollback path. Flagged at the
top as human-only -- no AI agent should auto-execute these steps.
2026-04-21 07:14:44 +00:00
84ab27a6b5 feat(scripts): add update_crontab.sh for post-migration crontab fix
Idempotent sed-based rewrite of any crontab line that references
/home/moltbot/clawd/tools/backup_config.sh so it points at the
echo-core copy. Safe to re-run; prints a single status line either way.
2026-04-21 07:14:38 +00:00
55e34afd59 docs(personality): update AGENTS for post-consolidation paths
Grep across personality/ for clawd|openclaw|clawdbot found no hits after
Lane A's earlier sweep. Single remaining operational reference to the
now-decommissioned night-execute cron has been softened to a generic
'add to approved-tasks.md' note.
2026-04-21 07:14:33 +00:00
5678138cc5 feat(config): add echo-work and echo-sprijin channel aliases
Imported claude jobs default channel to echo-work; grup-sprijin-5feb
and grup-sprijin-pregatire route to echo-sprijin. Existing echo-core
channel is preserved.
2026-04-21 07:14:28 +00:00
9fce04f212 feat(cron): import morning/evening/coaching/weekly claude jobs (disabled)
Runs tools/migrations/import_openclaw_jobs_2026-04.py against the real
openclaw jobs.json with --skip daily-morning-checks,archive-tasks,
monica-ion-blog,diagnostic-platou-financiar.

Imports 13 claude jobs: morning/evening report + coaching, exercise-snack-1/2/3,
weekly-planning-sun, content-discovery, provocare-reminder, grup-sprijin-5feb,
grup-sprijin-pregatire, heartbeat-2h. All import DISABLED except heartbeat-2h
(preserving its openclaw enabled flag). Cron schedules shifted UTC -> Bucharest.
clawd -> echo-core path rewrites applied.

heartbeat-2h imported with an empty openclaw prompt; filled with a minimal
status-check prompt so scheduler doesn't error on execution.
2026-04-21 07:14:23 +00:00
e964777f69 feat(cron): populate jobs.json with decomposed ANAF + security + archive jobs
Adds 5 kind:"shell" jobs (anaf-monitor, security-audit-daily,
kb-index-refresh, archive-tasks-daily, backup-config) and the new
insights-extract claude job (disabled placeholder). All cron schedules
are Europe/Bucharest local time. Decomposes openclaw's daily-morning-checks
mega-prompt per the Issue 15 eng-review decision.
2026-04-21 07:13:59 +00:00
5f87545b66 feat(migrations): add one-shot import_openclaw_jobs_2026-04 script
Audit-trail tool that translates OpenClaw's nested jobs.json schema
(schedule.expr with optional tz, payload.message, agentId, state) into
echo-core's flat schema. UTC -> Europe/Bucharest cron conversion with
DST-aware offset; Bucharest-tagged source expressions pass through
unchanged. Rewrites `cd ~/clawd` / `/home/moltbot/clawd/` -> echo-core
without matching `clawd-archive` or `clawdbot` substrings.

Built-in skip list covers night-execute and antfarm/feature-dev/*; YouTube:
prefix is auto-skipped. --dry-run, --skip-disabled, --skip, --channel,
--source, --target flags. Duplicate job names in target are skipped with
a warning; existing entries are preserved.
2026-04-21 07:13:50 +00:00
67d10c4c9a feat(dashboard): rewrite /api/cron for echo-core flat schema 2026-04-21 07:12:09 +00:00
b00d9d6fbd refactor(dashboard): split api.py into handler modules 2026-04-21 07:11:41 +00:00
af444d7066 test(anaf): assert monitor_v2 emits GSTACK-CRON marker as last stdout line
Four checks:
- The script file exists at the expected path.
- The source contains the marker print statement (fast regression guard).
- Running the script against an empty config produces a matching marker
  (^GSTACK-CRON: changes=\d+$) with changes=0.
- The marker is the last non-empty line of stdout so tailers can parse it.

The runtime test copies the script into a tmp cwd so that the script's
SCRIPT_DIR-relative state files (hashes.json, versions.json, snapshots/,
monitor.log) don't pollute the repo.
2026-04-21 07:05:46 +00:00
c82dbc5654 feat(anaf): emit GSTACK-CRON marker and exit 0 on successful run
The Echo-Core scheduler's report_on='changes' contract parses
^GSTACK-CRON: changes=\d+$ from stdout to decide whether to forward
the run's output to a channel. monitor_v2.py now prints that marker
as its final stdout line with num_changes from the current run.

Also switches the success return value from len(all_changes) to 0.
Previously, any run that detected changes (N>0) exited with a non-zero
status, which the scheduler treats as an error (always forwarded,
ignoring report_on). Exit code now signals only fatal errors; the
marker carries the change count.
2026-04-21 07:05:37 +00:00
b3ed653bb3 test(scheduler): cover shell-kind validation, execution, timezone, backward-compat
Adds four new test groups to tests/test_scheduler.py:

- TestTimezone: asserts AsyncIOScheduler is constructed with Europe/Bucharest.
- TestShellKind: 16 cases covering add_shell_job validation (duplicate name
  across claude/shell, invalid cron, empty/non-list/non-string command,
  bad report_on, bad timeout bounds/type, empty channel, custom report_on
  and timeout pass-through).
- TestShellExecute: 14 cases covering the report_on contract:
  - exit 0 + marker N>0 → forwards stdout
  - exit 0 + marker N==0 → silent
  - exit 0 + no marker   → silent + warning
  - report_on=always and =never variants
  - non-zero exit reports stderr even when report_on='never'
  - TimeoutExpired and launch exceptions report '[cron:X] Error: ...'
  - per-job timeout passed to subprocess.run; default 300 when None
  - subprocess.run receives the job's command list verbatim
  - stdout trimmed to 1500 ch; stderr trimmed to 500 ch
- TestBackwardCompat: a jobs.json entry without a 'kind' field dispatches
  to _execute_claude_job (never to _execute_shell_job); the existing Claude
  add_job/run_job round-trip still works with the old CLI invocation.
- TestMarkerRegex: parametrised positive/negative cases for _MARKER_RE.
2026-04-21 07:05:29 +00:00
e747491b85 feat(scheduler): add kind:"shell" jobs with Bucharest tz and GSTACK-CRON marker
- AsyncIOScheduler now runs in Europe/Bucharest so cron strings in jobs.json
  match local wall-clock time.
- New add_shell_job() validates name, cron, command list, channel, report_on
  (always|changes|never), and optional timeout (1..3600s). Existing add_job()
  stays untouched for the Claude path.
- _execute_job dispatches on job['kind'] (default 'claude'); legacy jobs
  without the field still route to the Claude executor. Refactored the
  Claude path into _execute_claude_job; new _execute_shell_job runs
  subprocess with _safe_env + PROJECT_ROOT cwd.
- Shell semantics: non-zero exit always forwards stderr (trimmed to 500 ch)
  as '[cron:NAME] exit CODE: STDERR' regardless of report_on. On exit 0,
  'always' forwards stdout (trimmed to 1500 ch), 'never' stays silent, and
  'changes' parses the GSTACK-CRON marker (^GSTACK-CRON: changes=\d+$) and
  forwards stdout only when N>0; missing/malformed marker logs a warning
  and stays silent.
- Timeout honoured per-job (falls back to JOB_TIMEOUT=300s).
2026-04-21 07:05:19 +00:00
ca9167d129 refactor(dashboard): normalize paths with constants block for echo-core 2026-04-21 07:02:56 +00:00
cd07e43533 feat(dashboard): copy from clawd 2026-04-21 07:01:30 +00:00
752 changed files with 140033 additions and 925 deletions

10
.gitignore vendored
View File

@@ -6,7 +6,9 @@ __pycache__/
*.egg-info/ *.egg-info/
sessions/ sessions/
logs/ logs/
memory memory/*
!memory/kb/
memory/kb/*.sqlite
*.sqlite *.sqlite
.env .env
*.secret *.secret
@@ -21,3 +23,9 @@ credentials/
*.pid *.pid
memory.bak/ memory.bak/
.use_openrouter .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
View File

@@ -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.** 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. 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 - `HEARTBEAT.md` — verificări periodice
- `TOOLS.md` — unelte disponibile - `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 ```bash
# Tests # Tests
source .venv/bin/activate && pytest tests/ source .venv/bin/activate && pytest tests/
pytest tests/test_router.py::test_clear_command -v pytest tests/test_router.py::test_clear_command -v
# Start # Pornire
systemctl --user start echo-core # systemd systemctl --user start echo-core # systemd
source .venv/bin/activate && python3 src/main.py # manual source .venv/bin/activate && python3 src/main.py # manual
@@ -34,56 +108,200 @@ systemctl --user start echo-whatsapp-bridge
eco status eco status
eco doctor eco doctor
# Dependencies # Dependențe
source .venv/bin/activate && pip install -r requirements.txt 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`): **Adaptoare** (concurente, `asyncio.gather()` în `src/main.py`):
- **Discord** (`src/adapters/discord_bot.py`) — slash commands, 2000 char split - **Discord** (`src/adapters/discord_bot.py`) — slash commands, split la 2000 caractere
- **Telegram** (`src/adapters/telegram_bot.py`) — commands + inline keyboards, 4096 char split - **Telegram** (`src/adapters/telegram_bot.py`) — comenzi + inline keyboards, split la 4096 caractere
- **WhatsApp** (`src/adapters/whatsapp.py`) — polls Baileys bridge at `http://127.0.0.1:8098`, 4096 char split - **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, ...}` **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 | ## Dashboard — Note arhitecturale
|------|------|
| `src/main.py` | Entry point — adapters + scheduler + heartbeat | **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.
| `src/router.py` | Commands vs Claude messages |
| `src/claude_session.py` | Claude CLI wrapper with `--resume` | **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).
| `src/credential_store.py` | Keyring secrets |
| `cli.py` | CLI diagnostics (eco) | **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`.
| `config.json` | Runtime config |
| `bridge/whatsapp/index.js` | Baileys + Express bridge, port 8098 | **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 | | `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 ## 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` - `/office-hours`
- `/plan-ceo-review` - `/plan-ceo-review`
- `/plan-eng-review` - `/plan-eng-review`

244
MIGRATION-PLAYBOOK.md Normal file
View 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: 510 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:0210: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
View 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
View File

BIN
assets/voice/beep_200ms.wav Normal file

Binary file not shown.

BIN
assets/voice/mhm.wav Normal file

Binary file not shown.

BIN
assets/voice/thinking.wav Normal file

Binary file not shown.

View File

@@ -113,7 +113,7 @@ async function startConnection() {
// --- Express API --- // --- Express API ---
const app = express(); const app = express();
app.use(express.json()); app.use(express.json({ limit: '50mb' }));
app.get('/status', (_req, res) => { app.get('/status', (_req, res) => {
res.json({ 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) => { app.post('/react', async (req, res) => {
const { to, id, emoji, fromMe, participant } = req.body || {}; const { to, id, emoji, fromMe, participant } = req.body || {};

101
cli.py
View File

@@ -114,6 +114,104 @@ def _load_sessions_file() -> dict:
return {} 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): def cmd_doctor(args):
"""Run diagnostic checks.""" """Run diagnostic checks."""
import re import re
@@ -227,6 +325,9 @@ def cmd_doctor(args):
else: else:
checks.append(("WhatsApp bridge (optional)", True)) checks.append(("WhatsApp bridge (optional)", True))
# ---- Voice stack checks (Pas 10) ----
checks.extend(_voice_doctor_checks())
# Print results # Print results
all_pass = True all_pass = True
for label, passed in checks: for label, passed in checks:

View File

@@ -11,6 +11,18 @@
"echo-core": { "echo-core": {
"id": "1471916752119009432", "id": "1471916752119009432",
"default_model": "sonnet" "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": {}, "telegram_channels": {},
@@ -86,9 +98,20 @@
"Bash(scp *10.0.20.*)", "Bash(scp *10.0.20.*)",
"Bash(rsync *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": { "ollama": {
"url": "http://10.0.20.161:11434" "url": "http://10.0.20.161:11434"
}, },
"voice": {
"allowed_user_ids": [
"949388626146517022"
],
"user_name": "Marius",
"default_voice": "F1",
"auto_leave_minutes": 5
},
"paths": { "paths": {
"personality": "personality/", "personality": "personality/",
"tools": "tools/", "tools": "tools/",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{ {
"last_sent": 14, "last_sent": 21,
"year": 2026, "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
View 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
View 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()

View 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"
}
]
}

View 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"
}
]
}

View 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
View 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
View 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', '')

View 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
View 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

File diff suppressed because it is too large Load Diff

471
dashboard/grup-sprijin.html Normal file
View 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

File diff suppressed because it is too large Load Diff

131
dashboard/habits.json Normal file
View 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
View 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)

View 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.
"""

View 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
View 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

View 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
View 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
View 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
View 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)

View 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
View 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)

File diff suppressed because it is too large Load Diff

615
dashboard/handlers/ralph.py Normal file
View 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)

View 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)

View 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

File diff suppressed because it is too large Load Diff

69
dashboard/issues.json Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
/home/moltbot/echo-core/memory

1
dashboard/notes-data Symbolic link
View File

@@ -0,0 +1 @@
/home/moltbot/echo-core/memory/kb

1295
dashboard/notes.html Normal file

File diff suppressed because it is too large Load Diff

123
dashboard/swipe-nav.js Normal file
View 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();
}
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)

View 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)

View 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
View 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
View File

@@ -0,0 +1 @@
/home/moltbot/videos

2404
dashboard/workspace.html Normal file

File diff suppressed because it is too large Load Diff

1
dashboard/youtube-notes Symbolic link
View File

@@ -0,0 +1 @@
/home/moltbot/echo-core/memory/kb/youtube

113
docs/okf-navigation-plan.md Normal file
View 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).

View 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*

View 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

View 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
View 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ă

View File

@@ -0,0 +1,10 @@
{
"defaultDomains": ["health"],
"defaultTypes": ["coaching"],
"defaultTags": [],
"inferTypeFromFilename": true,
"filenameTypeMap": {
"dimineata": "coaching",
"seara": "reflectie"
}
}

View 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.

View 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.

View 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.

View 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*

View 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.

View 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)

View 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.

View 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.

View 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]*

View 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.

View 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.* 🌙

View 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.

View 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ă.

View 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".

View 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

View 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?

View 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

View 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

View 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

View 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

View 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)*

View 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)*

View 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)

View 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)

View 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*

View 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ă)

View 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*

View 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*

View 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

View 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

View 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

View 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*

View 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

View 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)

View 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

View 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** 🌀

View 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