Compare commits

...

172 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
4e78ef7219 claude gstack 2026-04-21 05:47:37 +00:00
929d2e9c81 docs(tools): include Tailscale link to saved YouTube notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 05:45:15 +00:00
000b406c8d feat(newsletter): scan forward for multiple issues and re-enable cron
Loop through consecutive newsletter numbers until one is missing, so
backlog gets delivered in a single run. Use httpx for 404 check and
point to absolute claude binary path for cron. Enable job in config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 05:45:10 +00:00
9314d63aa0 feat(email): add digest and forward commands to WhatsApp
Digest summarizes unread emails via Claude CLI; forward sends raw
content (split to 4096 chars). Wired as /email digest and
/email forward slash commands, plus instant per-guild sync on ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 05:45:02 +00:00
9d447b9ff1 fix(newsletter): use follow_redirects=False to avoid false positive on 404 redirect
Beehiiv redirects non-existent newsletters to /?404=... with HTTP 302.
With follow_redirects=True, the final 200 was misread as "newsletter exists".
Fix: disable redirect following so only a direct HTTP 200 = newsletter real.
Also reset state back to last_sent=13 (real).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 17:06:00 +00:00
5fafc29dc1 chore: auto-commit from dashboard 2026-04-02 19:43:01 +00:00
d9450ce70d chore: auto-commit from dashboard 2026-04-02 17:43:13 +00:00
006123a63b chore: auto-commit from dashboard 2026-03-03 20:17:07 +00:00
19e253ec43 feat(heartbeat): save emails to KB + fix memory symlink access
- heartbeat saves unread whitelisted emails via email_process --save --json
- fix: add --add-dir so Claude CLI subprocess can access memory/ symlink
- email_check/process: use BODY.PEEK[] to avoid marking emails as read
- email_process: simplify credential loading via credential_store only
- config: heartbeat interval 30→120min, quiet hours end 08→07

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 12:10:53 +00:00
08c330a371 corectii 2026-02-19 14:09:12 +00:00
95bd651377 openrouter 2026-02-17 09:30:35 +00:00
MoltBot Service
e8492c3fa9 chore: auto-commit from dashboard 2026-02-16 10:02:52 +00:00
MoltBot Service
fd9d962ad2 chore: auto-commit from dashboard 2026-02-15 23:23:29 +00:00
MoltBot Service
8ce7ea3bd6 refactor(heartbeat): move kb/embeddings reindex messages to log only
KB and embeddings reindex status messages were being sent to Discord
as heartbeat results. These are internal housekeeping — now logged
instead of surfaced to the user.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:52:28 +00:00
MoltBot Service
207b39f957 chore: update allowed_tools syntax + gitignore memory.bak
- Switch Bash permission patterns from space to colon separator
- Add memory.bak/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:14:53 +00:00
MoltBot Service
b3c06c0238 refactor(session,pdf): simplify git perms + rich text PDF
- claude_session: replace 10 individual git command patterns with single Bash(git *) wildcard
- generate_pdf: add italic/bold-oblique font loading and render_rich_text() for inline bold/italic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:05:14 +00:00
MoltBot Service
91707c5841 refactor(memory): replace memory dir with symlink to clawd/memory 2026-02-15 21:00:50 +00:00
MoltBot Service
c8ce94611b feat: add 19 fast commands (no-LLM) + incremental embeddings indexing
Fast commands for git, email, calendar, notes, search, reminders, and
diagnostics — all execute instantly without Claude CLI. Incremental
embeddings indexing in heartbeat (1h cooldown) + inline indexing after
/note, /jurnal, /email save. Fix Ollama URL (localhost → 10.0.20.161),
fix email_process.py KB path (kb/ → memory/kb/).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:10:44 +00:00
MoltBot Service
8b76a2dbf7 fix(heartbeat): replace repeated calendar reminders with daily summary + dedup 2026-02-15 12:03:32 +00:00
MoltBot Service
f8ff971627 refactor(heartbeat): smart calendar with daily summary and dedup reminders
Calendar no longer bypasses quiet hours. First run after quiet hours
sends full daily summary, subsequent runs only remind for next event
within 45 min with deduplication. Calendar cooldown set to 30 min.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:09:59 +00:00
MoltBot Service
9c1f9f94e7 refactor(heartbeat): config-driven checks, channel delivery, remove hardcoded values
Heartbeat system overhaul:
- Fix email/calendar checks to parse JSON output correctly
- Add per-check cooldowns and quiet hours config
- Send findings to Discord channel instead of just logging
- Auto-reindex KB when stale files detected
- Claude CLI called only if HEARTBEAT.md has extra instructions
- All settings configurable via config.json heartbeat section

Move hardcoded values to config.json:
- allowed_tools list (claude_session.py)
- Ollama URL/model (memory_search.py now reads ollama.url from config)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:46:04 +00:00
MoltBot Service
ad31b25af3 fix(tools): add bare git command patterns to allowedTools
Git commands without arguments (git push, git status, git diff, etc.) were not matched by the existing wildcard patterns. Added bare variants and git stash support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:00:03 +00:00
MoltBot Service
be3cd0200b docs(grup-sprijin): add fisa pentru sesiunea din 19 februarie
Tema: Umbra - iarna din suflet

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:58:30 +00:00
MoltBot Service
0468f2ac77 docs(personality): add self-correction and tool usage rules
SOUL.md: don't retract excessively when information is correct.
TOOLS.md: verify tools before claiming unavailable, offer alternatives.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:30:21 +00:00
MoltBot Service
88d14da902 fix(tools): migrate email credentials to keyring, remove hardcoded password
Email tools now use credential_store (keyring) as primary source
with env/.env as fallback. Removes plaintext password from email_check.py.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:30:14 +00:00
MoltBot Service
5928077646 cleanup: remove clawd/openclaw references, fix permissions, add architecture docs
- Replace all ~/clawd and ~/.clawdbot paths with ~/echo-core equivalents
  in tools (git_commit, ralph_prd_generator, backup_config, lead-gen)
- Update personality files: TOOLS.md repo/paths, AGENTS.md security audit cmd
- Migrate HANDOFF.md architectural decisions to docs/architecture.md
- Tighten credentials/ dir to 700, add to .gitignore
- Add .claude/ and *.pid to .gitignore
- Various adapter, router, and session improvements from prior work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:44:13 +00:00
MoltBot Service
d585c85081 fix: capture all intermediate text blocks from Claude tool-use responses
Switch from --output-format json to --output-format stream-json --verbose
so that _run_claude() parses all assistant text blocks (not just the final
result field). Discord/Telegram/WhatsApp now receive every intermediate
message Claude writes between tool calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:41:56 +00:00
MoltBot Service
74ba70cd42 add Echo identity to CLAUDE.md, add --allowedTools with security restrictions
CLAUDE.md rewritten to clearly establish Echo's identity and role.
claude_session.py now passes --allowedTools to Claude CLI in both
start_session() and resume_session(), with explicit tool whitelist:
- File tools (Read/Edit/Write/Glob/Grep) + WebFetch/WebSearch (read-only)
- Bash restricted by command prefix (git, python, npm, docker, systemctl)
- SSH/SCP/rsync limited to local network (10.0.20.*)
- curl/wget excluded to prevent data exfiltration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:01:49 +00:00
MoltBot Service
21d55cbc6a add eco CLI symlink to setup wizard, document all CLI commands
setup.sh now installs eco → ~/.local/bin/eco (symlink to cli.py).
README.md updated with full eco command reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 06:48:22 +00:00
MoltBot Service
576f0ddac2 add CLAUDE.md for Claude Code context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 06:46:08 +00:00
MoltBot Service
d30e1c6573 add README.md for Gitea repo page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 06:43:57 +00:00
MoltBot Service
f9ffd9d623 add interactive setup wizard for Echo Core onboarding
10-step bash wizard (setup.sh) that guides through: prerequisites check,
venv setup, bot identity, Discord/Telegram/WhatsApp bridge configuration,
config.json merge, systemd service installation, and health checks.
Idempotent — safe to re-run, preserves existing config and secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 06:33:19 +00:00
MoltBot Service
9b661b5f07 update HANDOFF.md with systemd integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:42:11 +00:00
MoltBot Service
6454f0f83c install Echo Core as systemd service, update CLI for systemctl
- Created echo-core.service and echo-whatsapp-bridge.service (user units)
- CLI status/doctor now use systemctl --user show instead of PID file
- CLI restart uses kill+start pattern for reliability
- Added echo stop command
- CLI shebang uses venv python directly for keyring support
- Updated tests to mock _get_service_status instead of PID file
- 440 tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:41:56 +00:00
MoltBot Service
624eb095f1 fix WhatsApp group chat support and self-message handling
Bridge: allow fromMe messages in groups, include participant field in
message queue, bind to 0.0.0.0 for network access, QR served as HTML.

Adapter: process registered group messages (route to Claude), extract
participant for user identification, fix unbound 'phone' variable.

Tested end-to-end: WhatsApp group chat with Claude working. 442 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:22 +00:00
MoltBot Service
80502b7931 stage-13: WhatsApp bridge with Baileys + Python adapter
Node.js bridge (bridge/whatsapp/): Baileys client with Express HTTP API
on localhost:8098 — QR code linking, message queue, reconnection logic.

Python adapter (src/adapters/whatsapp.py): polls bridge every 2s, routes
through router.py, separate whatsapp.owner/admins auth, security logging.

Integrated in main.py alongside Discord + Telegram via asyncio.gather.
CLI: echo whatsapp status/qr. 442 tests pass (32 new, zero failures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:41:16 +00:00
MoltBot Service
2d8e56d44c stage-12: Telegram bot adapter
- New src/adapters/telegram_bot.py: full Telegram adapter with python-telegram-bot v22
  - Commands: /start, /help, /clear, /status, /model, /register
  - Inline keyboards for model selection
  - Message routing through existing router.py
  - Private chat: admin-only access
  - Group chat: responds to @mentions and replies to bot
  - Security logging for unauthorized access attempts
  - Message splitting for 4096 char limit
- Updated main.py: runs Discord + Telegram bots concurrently
  - Telegram is optional (gracefully skipped if no telegram_token)
- Updated requirements.txt: added python-telegram-bot>=21.0
- Updated config.json: added telegram_channels section
- Updated cli.py doctor: telegram token check (optional)
- 37 new tests (410 total, zero failures)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:55:04 +00:00
MoltBot Service
d1bb67abc1 stage-11: security hardening
- Prompt injection protection: external messages wrapped in [EXTERNAL CONTENT]
  markers, system prompt instructs Claude to never follow external instructions
- Invocation logging: all Claude CLI calls logged with channel, model, duration,
  token counts to echo-core.invoke logger
- Security logging: separate echo-core.security logger for unauthorized access
  attempts (DMs from non-admins, unauthorized admin/owner commands)
- Security log routed to logs/security.log in addition to main log
- Extended echo doctor: Claude CLI functional check, config.json secret scan,
  .gitignore completeness, file permissions, Ollama reachability, bot process
- Subprocess env stripping logged at debug level

373 tests pass (10 new security tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:01:31 +00:00
MoltBot Service
85c72e4b3d rename secrets.py to credential_store.py, enhance /status, add usage tracking
- Rename src/secrets.py → src/credential_store.py (avoid stdlib conflict)
- Enhanced /status command: uptime, tokens, cost, context window usage
- Session metadata now tracks input/output tokens, cost, duration
- _safe_env() changed from allowlist to blocklist approach
- Better Claude CLI error logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:54:59 +00:00
MoltBot Service
0ecfa630eb stage-10: memory search with Ollama embeddings + SQLite
Semantic search over memory/*.md files using all-minilm embeddings.
Adds /search Discord command and `echo memory search/reindex` CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:49:57 +00:00
MoltBot Service
0bc4b8cb3e stage-9: heartbeat system with periodic checks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:40:39 +00:00
515 changed files with 103958 additions and 5566 deletions

16
.gitignore vendored
View File

@@ -6,10 +6,26 @@ __pycache__/
*.egg-info/
sessions/
logs/
memory/*
!memory/kb/
memory/kb/*.sqlite
*.sqlite
.env
*.secret
.DS_Store
*.swp
bridge/whatsapp/node_modules/
bridge/whatsapp/auth/
.vscode/
.idea/
credentials/
.claude/
*.pid
memory.bak/
.use_openrouter
.gstack/
# Runtime state — auto-modified by dashboard/cron/heartbeat
approved-tasks.json
dashboard/status.json
tools/anaf-monitor/monitor.log

1
.use_openrouter_no Normal file
View File

@@ -0,0 +1 @@
# OpenRouter mode enabled

337
CLAUDE.md Normal file
View File

@@ -0,0 +1,337 @@
# Echo Core
**Tu ești Echo Core** — asistent personal AI al lui Marius. Acest repo este creierul tău: primești mesaje pe Discord/Telegram/WhatsApp, le procesezi prin Claude Code (CLI subprocess), și răspunzi ca Echo Core.
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.**
## Cum funcționează
Mesajele ajung la tine prin adaptoare (Discord, Telegram, WhatsApp) → `router.py``claude_session.py` → Claude CLI subprocess → răspuns trimis înapoi.
Personalitatea ta se construiește din `personality/*.md`, concatenate în ordine:
- `IDENTITY.md` — cine ești
- `SOUL.md` — principii, ton, granițe
- `USER.md` — despre Marius
- `AGENTS.md` — reguli operaționale, model selection, securitate
- `HEARTBEAT.md` — verificări periodice
- `TOOLS.md` — unelte disponibile
## Principii de Workflow
> **Aplicabilitate:** aceste principii se aplică pentru **modificări de cod** în acest repo sau în proiectele Ralph. Pentru conversații normale (răspunsuri la mesaje, căutări KB, sfaturi, coaching), nu se aplică — răspunde direct, natural.
### 1. Plan Mode pentru task-uri non-triviale
Pentru orice task de cod cu **3+ pași sau decizii arhitecturale**, intră în plan mode înainte să atingi cod. Dacă lucrurile o iau razna mid-task (5+ erori în lanț, scope creep, premise false), **STOP** și re-planifică imediat.
Folosește skill-urile gstack pentru review:
- `/plan-eng-review` — arhitectură, edge cases, performance
- `/plan-ceo-review` — scope, ambiție, 10-star product
- `/plan-design-review` — UI/UX înainte de implementare
- `/autoplan` — toate trei automat, cu approval gate la final
### 2. Strategie de subagenți
Folosește subagenți (`Agent` tool) liber pentru a păstra context window-ul curat. Offload research, exploration, parallel analysis. **Un singur task per subagent** — nu suprasolicita.
- `Explore` — căutări codebase
- `general-purpose` — research multi-step
- `Plan` — design de implementare
### 3. Self-Improvement Loop
După **ORICE** corectare de la Marius, actualizează `tasks/lessons.md` cu pattern-ul învățat. Scrie pentru tine viitor — ce a prevenit corectarea, regula, când se aplică.
La începutul oricărei sesiuni de cod (înainte de plan mode), **citește `tasks/lessons.md`** și aplică lecțiile relevante. Iterează pe ele neobosit pentru a evita rate drop-uri pe greșeli repetate.
Ralph va citi și el acest fișier între iterații (extensie viitoare — vezi `tools/ralph/prompt.md`).
### 4. Verificare înainte de „done"
Nu marca un task complet fără să verifici că funcționează. Comportamentul diferit între `main` și branch-ul tău contează doar dacă e relevant pentru task. Întreabă-te mereu: **„Ar aproba un staff engineer asta?"**
Folosește din gstack:
- `/qa` — test + fix loop iterativ
- `/qa-only` — doar raport de bug-uri
- `/review` — pre-merge diff review
- `/devex-review` — DX live audit
- `/ship` — full pipeline (tests + CHANGELOG + PR)
### 5. Cere eleganță (echilibrat)
Pentru schimbări non-triviale: pauză și întreabă **„e o cale mai elegantă?"** Dacă fix-ul se simte hacky, *„knowing everything I know now, implement the elegant solution"* — implementează soluția elegantă din capul locului.
**Skip pentru fixes simple, schimbări obvii** — nu over-engineer. Provoacă-ți munca înainte să o prezinți.
Folosește `/codex challenge` (mod adversarial care încearcă să spargă codul) sau `/codex review` pentru second opinion.
### 6. Bug fixing autonom
Când Marius dă un bug report: **just fix it**. Fără hand-holding. Indică logs, errors, failing tests — apoi rezolvă-le. Zero context switching cerut de la user.
Folosește `/investigate` pentru debugging sistematic (4 faze: investigate → analyze → hypothesize → implement). **Iron Law: fără fix fără root cause.**
Ralph face exact asta noaptea, autonom, pe proiectele aprobate.
## Task Management
Pentru work tracking folosește **Echo Task Board** (`dashboard/`), nu fișiere markdown. Endpoints în `dashboard/handlers/`.
1. **Plan First** — task-uri cu checkboxes în plan mode
2. **Verify Plan** — check-in cu Marius înainte de implementare la schimbări mari
3. **Track Progress** — marchează task-urile complete pe măsură ce le faci
4. **Explain Changes** — high-level summary la fiecare pas
5. **Document Results** — la final, secțiune review în PR sau în `tasks/<task>.md`
6. **Capture Lessons** — la corectări, update `tasks/lessons.md` (vezi principiul 3)
## Core Principles
- **Simplicitate înainte de toate** — fă cele mai simple schimbări posibile. Impact minim, cod minimal.
- **Zero lene** — root causes, nu temporary fixes. Standard de senior developer.
- **Impact minim** — atinge doar ce e necesar. Fără side effects la features noi.
## Comenzi
```bash
# Tests
source .venv/bin/activate && pytest tests/
pytest tests/test_router.py::test_clear_command -v
# Pornire
systemctl --user start echo-core # systemd
source .venv/bin/activate && python3 src/main.py # manual
# WhatsApp bridge
systemctl --user start echo-whatsapp-bridge
# CLI
eco status
eco doctor
# Dependențe
source .venv/bin/activate && pip install -r requirements.txt
```
## Arhitectură
**Flow:** Adapter → `router.py``claude_session.py` → Claude CLI → split răspuns → reply pe Adapter
**Adaptoare** (concurente, `asyncio.gather()` în `src/main.py`):
- **Discord** (`src/adapters/discord_bot.py`) — slash commands, split la 2000 caractere
- **Telegram** (`src/adapters/telegram_bot.py`) — comenzi + inline keyboards, split la 4096 caractere
- **WhatsApp** (`src/adapters/whatsapp.py`) — polling Baileys bridge la `http://127.0.0.1:8098`, split la 4096 caractere
**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, ...}`
**Credențiale** (`src/credential_store.py`): Keyring de sistem, serviciu `"echo-core"`. Niciodată secrete ca argumente CLI.
**Config** (`src/config.py`): `config.json` cu dot-notation. Namespaces: `channels`, `telegram_channels`, `whatsapp_channels`.
**Scheduler** (`src/scheduler.py`): APScheduler + `cron/jobs.json`, sesiuni izolate.
**Heartbeat** (`src/heartbeat.py`): Verificări email, calendar, KB, git. Ore tăcere 23-08.
**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/`.
**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).
*Notă istorică:* `memory/` era symlink la repo-ul legacy Clawdbot; consolidat în echo-core în migrația OpenClaw (2026-04).
**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`.
## Dashboard — Note arhitecturale
**Cookie auth:** dashboard folosește httpOnly cookie `dashboard=...`; SameSite=Strict; Path=/echo/. EventSource SSE trimite cookie-ul automat. `DASHBOARD_TOKEN` din `dashboard/.env` — setează o dată, restart service. Resetare: schimbă valoarea din .env + restart.
**jsonlock helper (`src/jsonlock.py`):** folosește `read_locked(path)` / `write_locked(path, mutator)` pentru orice scriere la `approved-tasks.json`, `sessions/*.json`. Lock pe sidecar `<path>.lock` (inode stabil chiar și după os.replace). Ordine canonică lock-uri: alfabetic după filename. Re-entrant (threading.local refcount).
**Slug convention:** slug-urile proiectelor validează cu regex `^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$` — permit hifene ȘI underscore. Validare centralizată în `dashboard/handlers/_validators.py`.
**Proxy timeout:** pentru nginx/caddy, setează `proxy_read_timeout >= 60s` și `proxy_buffering off` pentru `/echo/api/projects/stream` și `/echo/api/projects/<slug>/plan/*` (SSE + planning au răspunsuri lungi).
**Planning fragmentation (known limit):** sesiunile de planning pornite din Discord/Telegram nu se fuzionează cu cele din dashboard. Dashboard afișează sesiunea cea mai recentă per slug indiferent de adapter. P3 follow-up.
## Ralph — Execuție autonomă de proiecte
Sistem de implementare autonomă care rulează noaptea. Flow complet:
```
21:00 evening-report → propune features/proiecte, adaugă în approved-tasks.json (status: pending)
email lui Marius cu instrucțiuni de aprobare
Marius → /a <slug> (Discord/Telegram/WhatsApp → router.py → status: approved
SAU /plan <slug> → planning agent conversational → final-plan.md → approved)
23:00 night-execute → citește approved, clonează repo dacă lipsește, generează PRD din final-plan.md,
lansează ralph.sh; actualizează approved-tasks.json (running, pid: PID)
08:30 morning-report → citește approved-tasks.json + prd.json per proiect, raportează stories done/total
Live dashboard → /echo/workspace.html — cards per proiect cu status, iter, ETA, log, stop; realtime SSE
```
**Două căi de aprobare**:
- **Direct**: `/a <slug>` — pentru proiecte simple unde descrierea e suficientă
- **Conversational** (W2 — `/plan <slug>` SAU buton "Planifică" pe `/l`): Echo poartă o conversație multi-fază prin skills gstack (`/office-hours``/plan-ceo-review``/plan-eng-review` → opțional `/plan-design-review` dacă tags include "ui"), produce `~/workspace/<slug>/scripts/ralph/final-plan.md` și prezintă rezumat cu butonul "✅ Dau drumul tonight". `night-execute` îl folosește ca input pentru PRD generator (Opus extrage user stories cu acceptanceCriteria, tags, dependsOn).
**Comenzi** (funcționează pe toate adaptoarele — Discord, Telegram, WhatsApp):
| Comandă | Efect |
|---------|-------|
| `/p <slug> <descriere>` | Adaugă proiect nou cu status `pending` |
| `/a` | Listează proiectele pending |
| `/a <slug>` sau `/a P1,P2` | Aprobă pentru tonight (path direct) |
| `/plan <slug>` | Pornește planning agent conversational (multi-fază skills gstack) |
| `/cancel` | Anulează planning în curs (revert status → pending) |
| `/l` | **Discord/Telegram**: meniu interactiv (Views/InlineKeyboardMarkup) cu butoane per proiect; **WhatsApp**: text plain + redirect spre Discord/TG |
| `/l <slug>` | Status proiect specific |
| `/k <slug>` | Trimite SIGTERM la ralph.sh PID |
**UX interactiv** (Discord/Telegram):
- `/l` deschide `RalphRootView` (Discord) / InlineKeyboardMarkup (Telegram) cu butoane per workspace project
- Click pe proiect → submeniu cu acțiuni: Propune feature (modal/ForceReply), 🧠 Planifică (W2), 👁 Vezi PRD, 📊 Status, ✅ Aprobă tonight, 🛑 Stop, 🔙 Înapoi
- La sfârșitul planning: butoane ✅ Dau drumul tonight / ✏️ Mai gândim / 🛑 Anulează
- State per `(adapter, channel)` în `sessions/ralph_flow.json` și `sessions/planning.json` (TTL 10min/60min)
Pe **Discord**: slash commands native cu autocomplete dinamic: `/p <tab>` listează workspace, `/a <tab>` pending, `/k <tab>` running. Modal cu `TextInput` pentru descriere. Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button callback cu I/O (Discord 3s timeout).
Pe **Telegram**: `callback_ralph` cu pattern `^ralph:` rutează acțiuni; `ForceReply` pentru input text descriere.
Pe **WhatsApp**: text-only — meniu redirect la Discord/Telegram. **Text-keyword shortcuts**: `aprob <slug>``/a <slug>`, `stop <slug>``/k <slug>`, `stare`/`stare <slug>``/l`/`/l <slug>` (case-insensitive, doar pe WhatsApp; Discord/Telegram nu sunt afectate). `propose` intentionally NOT covered — descrierea fragilă.
**Aliasuri legacy** (funcționează încă pentru backwards compat): `!propose`, `!approve`, `!status`, `!stop`.
**Fișiere cheie Ralph:**
| Path | Rol |
|------|-----|
| `approved-tasks.json` | Coordonare între cron jobs + UX. Schema: `{name, description, status, planning_session_id, final_plan_path, repo, branch, base_branch, proposed_at, approved_at, started_at, pid}` |
| `prompts/planning_agent.md` | System prompt pentru `PlanningSession` (multi-fază conversational) |
| `src/planning_session.py` | Wrapper subprocess `claude -p` cu working dir = `~/workspace/<slug>/`, `--add-dir` skills gstack + project artifacts. `--max-turns=20` cu retry pe `error_max_turns` |
| `src/planning_orchestrator.py` | Coordonează fazele: fresh subprocess per skill phase; coordinează prin disk artifacts gstack convention; tag detection ui-scope |
| `sessions/planning.json` | State per `(adapter, channel)` planning session: session_id, current_phase, etc. — pentru re-resume la restart |
| `tools/ralph/ralph.sh` | Bash loop DAG-aware: N iterații × `claude` CLI per story; folosește `tools/ralph_dag.py` pentru selecție topologică, retry guard (3 retries), rate-limit detection |
| `tools/ralph/prompt.md` | Smart gates dispatcher pe `story.tags` (Faza 3): refactor→/workflow:simplify, ui→/qa+screenshot, vercel→push+gh checks, db→schema diff, default→/review |
| `tools/ralph/prd-template.json` | Template prd.json: stories cu `acceptanceCriteria[]`, `tags[]`, `dependsOn[]`, `passes`, `retries` |
| `tools/ralph_prd_generator.py` | Generează prd.json. Cu `final_plan_path` (de la PlanningOrchestrator) → Opus extrage stories cu acceptance criteria. Fără → backwards-compat description-only |
| `tools/ralph_dag.py` | Pure functions Python (testabile): `infer_tags_from_paths`, `force_include_tags`, `topological_eligible`, `mark_failed`, blocked propagation iterativă. CLI subcommands chemate din ralph.sh (`infer-tags`, `next-story`, `mark-failed`, `incr-retry`) |
| `tools/ralph_usage.py` | Rate limit budget tracking: pure functions `extract_usage_entry`, `parse_usage_jsonl`, `aggregate_by_day`, `aggregate_by_project` + CLI append/summarize. Atomic write JSONL |
| `~/workspace/<name>/scripts/ralph/usage.jsonl` | Append-only log per `claude -p` call (cost, tokens, model, duration) — generat din ralph.sh, agregat de `/api/ralph/usage` |
| `~/workspace/<name>/scripts/ralph/final-plan.md` | Output planning agent — citit de PRD generator |
| `~/workspace/<name>/scripts/ralph/prd.json` | PRD per proiect cu schema extinsă |
| `~/workspace/<name>/scripts/ralph/logs/` | Loguri ralph.sh per rulare |
| `dashboard/handlers/ralph.py` | Endpoints `/api/ralph/status`, `/<slug>/log`, `/<slug>/prd`, `/<slug>/stop`, `/<slug>/rollback`, `/usage[?days=N]`, `/stream` (SSE) |
| `dashboard/handlers/projects.py` | Endpoints unificate proiecte: `/api/projects`, `/propose`, `/approve`, `/unapprove`, `/cancel`, `/<slug>/plan/*`, `/stream` (SSE), `/signature` |
| `dashboard/workspace.html` | Hub unificat proiecte — cards status/iter/ETA, log, prd, stop/rollback. Realtime SSE cu fallback polling 5s. Înlocuiește ralph.html (care face 302 redirect aici) |
| `dashboard/.env` | `GITEA_TOKEN` pentru clone HTTPS la `gitea.romfast.ro`; `DASHBOARD_TOKEN` pentru cookie auth |
**Status flow:** `pending` → (`planning` →) `approved``running``complete` / `failed` / `stopped` / `blocked` (DAG)
**Story status (în prd.json):** `passes:false` + `retries:N``passes:true` SAU `failed:rate_limited|max_retries`
**Workspace proiecte** (`~/workspace/`): roa2web, gomag-vending, vending_data_intelligence_report, btgo-playwright, space-booking, romfast-website, game-library, wol, romfastsql
**Reguli importante:**
- Ralph NU modifică niciodată `src/router.py`, `src/claude_session.py` sau alte fișiere core din echo-core
- Self-improvement echo-core NUMAI pe branch `ralph/echo-improve`, niciodată pe master
- Clone-urile folosesc `GITEA_TOKEN` din `dashboard/.env`: `https://moltbot:${TOKEN}@gitea.romfast.ro/romfast/<name>.git`
### Features pe repo-uri existente (worktree-aware)
Slug-ul proiectului nu trebuie să corespundă cu un repo Gitea. Pentru o feature pe un repo existent (ex: `roa2web-telegram-bonuri` ca feature pe `roa2web`), folosește câmpurile opționale `repo`, `branch`, `base_branch`:
- **`repo`** — numele repo-ului Gitea de clonat (default: slug-ul proiectului).
- **`branch`** — feature branch nou care va fi creat după clone (default: niciunul, ralph lucrează pe HEAD-ul default).
- **`base_branch`** — branch-ul de la care porneste `branch` (default: `main`).
Cum le setezi:
- **CLI/chat:** `/p <slug> --repo <name> --branch <feature> [--base-branch <name>] <descriere>` (parser în `_ralph_propose` la `src/router.py`).
- **Dashboard:** modal Propose → secțiunea „Avansat" cu câmpuri pentru repo/branch/base_branch.
Night-execute (`cron/jobs.json`) detectează câmpurile și clonează `repo` în `~/workspace/<slug>/`, apoi `git checkout -b <branch> <base_branch>` dacă `branch` e setat. Dacă clone-ul eșuează (repo inexistent), proiectul e marcat `failed` fără să mai pornească ralph.
### Approval guard — protejare împotriva re-planning accidental
`/plan/start` (POST `/api/projects/<slug>/plan/start`) refuză cu 409 `already_committed` dacă proiectul e deja `approved`/`running`/`complete`. Pentru a re-iniția planning-ul intenționat:
- **Dashboard:** butonul „Re-planifică" pe cards aprobate cere confirm explicit înainte să trimită `force=true` în body.
- **API direct:** trimite `{"force": true, "description": "..."}` în body-ul de la `/plan/start`.
Asta previne situația în care un click accidental pe „Planifică" șterge `status=approved` și pornește un nou subprocess Claude (cu cost asociat).
## Convenție import-uri
Import-uri absolute via `sys.path.insert(0, PROJECT_ROOT)`: `from src.config import ...`, `from src.adapters.discord_bot import ...`. Fără import-uri circulare.
## Fișiere cheie
| Path | Rol |
|------|-----|
| `src/main.py` | Entry point — adaptoare + scheduler + heartbeat |
| `src/router.py` | Comenzi vs mesaje Claude |
| `src/claude_session.py` | Wrapper Claude CLI cu `--resume` |
| `src/credential_store.py` | Secrete keyring |
| `cli.py` | Diagnostice CLI (eco) |
| `config.json` | Config runtime |
| `bridge/whatsapp/index.js` | Bridge Baileys + Express, port 8098 |
| `personality/*.md` | System prompt — cine ești |
| `memory/` | 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
Folosește skill-ul `/browse` din gstack pentru orice navigare web. Nu folosi tool-uri `mcp__claude-in-chrome__*`.
Skill-uri disponibile:
- `/office-hours`
- `/plan-ceo-review`
- `/plan-eng-review`
- `/plan-design-review`
- `/design-consultation`
- `/design-shotgun`
- `/design-html`
- `/review`
- `/ship`
- `/land-and-deploy`
- `/canary`
- `/benchmark`
- `/browse`
- `/connect-chrome`
- `/qa`
- `/qa-only`
- `/design-review`
- `/setup-browser-cookies`
- `/setup-deploy`
- `/retro`
- `/investigate`
- `/document-release`
- `/codex`
- `/cso`
- `/autoplan`
- `/plan-devex-review`
- `/devex-review`
- `/careful`
- `/freeze`
- `/guard`
- `/unfreeze`
- `/gstack-upgrade`
- `/learn`

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.

146
README.md Normal file
View File

@@ -0,0 +1,146 @@
# Echo Core
AI-powered personal assistant bot with Discord, Telegram, and WhatsApp bridges. Uses Claude Code CLI for conversation, with persistent sessions, cron scheduling, semantic memory search, and heartbeat monitoring.
## Quick Start
```bash
# Interactive setup wizard (recommended for first install)
bash setup.sh
```
The wizard handles prerequisites, virtual environment, bridge tokens, config, and systemd services in 10 guided steps.
### Manual Setup
```bash
# 1. Create venv and install dependencies
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# 2. Store Discord token in keyring
./cli.py secrets set discord_token
# 3. Edit config.json (bot name, owner ID, channels)
# 4. Start
systemctl --user start echo-core
```
## Architecture
```
┌─────────────┐
│ Claude CLI │
└──────┬──────┘
┌──────┴──────┐
│ Router │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────┴─────┐ ┌───┴───┐ ┌──────┴──────┐
│ Discord │ │Telegram│ │ WhatsApp │
│ (d.py) │ │(ptb) │ │(Baileys+py) │
└────────────┘ └────────┘ └─────────────┘
```
- **Discord**: slash commands via discord.py
- **Telegram**: commands + inline keyboards via python-telegram-bot
- **WhatsApp**: Node.js Baileys bridge + Python polling adapter
- All three run concurrently in the same asyncio event loop
## Key Components
| Component | Description |
|-----------|-------------|
| `src/main.py` | Entry point — starts all adapters + scheduler + heartbeat |
| `src/router.py` | Routes messages to Claude or handles commands |
| `src/claude_session.py` | Claude Code CLI wrapper with `--resume` sessions |
| `src/credential_store.py` | Keyring-based secrets manager |
| `src/scheduler.py` | APScheduler cron jobs |
| `src/heartbeat.py` | Periodic health checks |
| `src/memory_search.py` | Ollama embeddings + SQLite semantic search |
| `cli.py` | CLI tool — status, doctor, logs, secrets, cron, etc. |
| `setup.sh` | Interactive 10-step setup wizard |
| `bridge/whatsapp/` | Node.js WhatsApp bridge (Baileys + Express) |
## CLI Usage
The setup wizard installs `eco` as a global command (`~/.local/bin/eco`):
```bash
eco status # Bot online/offline, uptime
eco doctor # Full diagnostic check
eco restart # Restart the service
eco restart --bridge # Restart bot + WhatsApp bridge
eco stop # Stop the service
eco logs # Tail echo-core.log (last 20 lines)
eco logs 50 # Last 50 lines
eco secrets list # Show stored credentials
eco secrets set <name> # Store a secret in keyring
eco secrets test # Check required secrets
eco sessions list # Active Claude sessions
eco sessions clear # Clear all sessions
eco channel list # Registered Discord channels
eco cron list # Show scheduled jobs
eco cron run <name> # Force-run a cron job
eco memory search "<query>" # Semantic search in memory
eco memory reindex # Rebuild search index
eco heartbeat # Run health checks
eco whatsapp status # WhatsApp bridge connection
eco whatsapp qr # QR code pairing instructions
eco send <alias> <message> # Send message via router
```
## Configuration
`config.json` — runtime configuration:
```json
{
"bot": {
"name": "Echo",
"default_model": "opus",
"owner": "DISCORD_USER_ID",
"admins": ["TELEGRAM_USER_ID"]
},
"channels": { },
"telegram_channels": { },
"whatsapp": {
"enabled": true,
"bridge_url": "http://127.0.0.1:8098",
"owner": "PHONE_NUMBER"
},
"whatsapp_channels": { }
}
```
Secrets (Discord/Telegram tokens) are stored in the system keyring, not in config files.
## Services
Echo Core runs as systemd user services:
```bash
systemctl --user start echo-core # Start bot
systemctl --user start echo-whatsapp-bridge # Start WA bridge
systemctl --user status echo-core # Check status
journalctl --user -u echo-core -f # Follow logs
```
## Requirements
- Python 3.12+
- Claude Code CLI
- Node.js 22+ (only for WhatsApp bridge)
## Tests
```bash
source .venv/bin/activate
pytest tests/
```
440 tests, zero failures.

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.

2
bridge/whatsapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
auth/

265
bridge/whatsapp/index.js Normal file
View File

@@ -0,0 +1,265 @@
// NOTE: auth/ directory is in .gitignore — do not commit session data
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
const express = require('express');
const pino = require('pino');
const QRCode = require('qrcode');
const path = require('path');
const PORT = 8098;
const HOST = '0.0.0.0';
const AUTH_DIR = path.join(__dirname, 'auth');
const MAX_RECONNECT_ATTEMPTS = 5;
const logger = pino({ level: 'warn' });
let sock = null;
let connected = false;
let phoneNumber = null;
let currentQR = null;
let currentPairingCode = null;
let reconnectAttempts = 0;
let messageQueue = [];
let shuttingDown = false;
// --- WhatsApp connection ---
async function startConnection() {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
defaultQueryTimeoutMs: 60000,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
try {
currentQR = await QRCode.toDataURL(qr);
console.log('[whatsapp] QR code generated — scan with WhatsApp to link');
} catch (err) {
console.error('[whatsapp] Failed to generate QR code:', err.message);
}
}
if (connection === 'open') {
connected = true;
currentQR = null;
reconnectAttempts = 0;
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null;
console.log(`[whatsapp] Connected as ${phoneNumber}`);
}
if (connection === 'close') {
connected = false;
phoneNumber = null;
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
if (shouldReconnect && !shuttingDown) {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
console.log(`[whatsapp] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
setTimeout(startConnection, delay);
} else {
console.error(`[whatsapp] Max reconnect attempts reached (${MAX_RECONNECT_ATTEMPTS})`);
}
} else if (statusCode === DisconnectReason.loggedOut) {
console.log('[whatsapp] Logged out — delete auth/ and restart to re-link');
}
}
});
sock.ev.on('messages.upsert', ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
// Skip status broadcasts
if (msg.key.remoteJid === 'status@broadcast') continue;
// Skip own messages in private chats (allow in groups for self-chat)
const isGroup = msg.key.remoteJid.endsWith('@g.us');
if (msg.key.fromMe && !isGroup) continue;
// Only text messages
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
if (!text) continue;
messageQueue.push({
from: msg.key.remoteJid,
participant: msg.key.participant || null,
pushName: msg.pushName || null,
text,
timestamp: msg.messageTimestamp,
id: msg.key.id,
isGroup,
fromMe: msg.key.fromMe || false,
});
console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${text.substring(0, 80)}`);
}
});
}
// --- Express API ---
const app = express();
app.use(express.json({ limit: '50mb' }));
app.get('/status', (_req, res) => {
res.json({
connected,
phone: phoneNumber,
qr: connected ? null : currentQR,
});
});
app.post('/pair', async (req, res) => {
if (connected) {
return res.json({ error: 'already connected' });
}
const { phone } = req.body || {};
if (!phone) {
return res.status(400).json({ error: 'missing "phone" in body' });
}
if (!sock) {
return res.status(503).json({ error: 'socket not ready yet, try again in a few seconds' });
}
try {
const code = await sock.requestPairingCode(phone.replace(/\D/g, ''));
currentPairingCode = code;
console.log(`[whatsapp] Pairing code for ${phone}: ${code}`);
res.json({ ok: true, code });
} catch (err) {
console.error('[whatsapp] Pairing code error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.get('/pair-code', (_req, res) => {
if (connected) return res.json({ error: 'already connected' });
if (!currentPairingCode) return res.json({ error: 'no pairing code yet — POST /pair first' });
res.json({ code: currentPairingCode });
});
app.get('/qr', (_req, res) => {
if (connected) {
return res.json({ error: 'already connected' });
}
if (!currentQR) {
return res.json({ error: 'no QR code available yet' });
}
// Return as HTML page with QR image for easy scanning
const html = `<!DOCTYPE html>
<html><head><title>WhatsApp QR</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#111;flex-direction:column;font-family:sans-serif;color:#fff}
img{width:400px;height:400px;border-radius:12px}p{margin-top:16px;opacity:.6}</style></head>
<body><img src="${currentQR}" alt="QR Code"/><p>Scan with WhatsApp &rarr; Linked Devices</p></body></html>`;
res.type('html').send(html);
});
app.post('/send', async (req, res) => {
const { to, text } = req.body || {};
if (!to || !text) {
return res.status(400).json({ ok: false, error: 'missing "to" or "text" in body' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const result = await sock.sendMessage(to, { text });
res.json({ ok: true, id: result.key.id });
} catch (err) {
console.error('[whatsapp] Send failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/send-document', async (req, res) => {
const { to, filename, mimetype, data_base64, caption } = req.body || {};
if (!to || !filename || !data_base64) {
return res.status(400).json({ ok: false, error: 'missing "to", "filename", or "data_base64"' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const buffer = Buffer.from(data_base64, 'base64');
const result = await sock.sendMessage(to, {
document: buffer,
fileName: filename,
mimetype: mimetype || 'application/octet-stream',
caption: caption || '',
});
res.json({ ok: true, id: result.key.id });
} catch (err) {
console.error('[whatsapp] Send document failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/react', async (req, res) => {
const { to, id, emoji, fromMe, participant } = req.body || {};
if (!to || !id || emoji == null) {
return res.status(400).json({ ok: false, error: 'missing "to", "id", or "emoji" in body' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const key = { remoteJid: to, id, fromMe: fromMe || false };
if (participant) key.participant = participant;
await sock.sendMessage(to, { react: { text: emoji, key } });
res.json({ ok: true });
} catch (err) {
console.error('[whatsapp] React failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.get('/messages', (_req, res) => {
const messages = messageQueue.splice(0);
res.json({ messages });
});
// --- Startup ---
const server = app.listen(PORT, HOST, () => {
console.log(`[whatsapp] Bridge API listening on http://${HOST}:${PORT}`);
startConnection().catch((err) => {
console.error('[whatsapp] Failed to start connection:', err.message);
});
});
// --- Graceful shutdown ---
function shutdown(signal) {
console.log(`[whatsapp] Received ${signal}, shutting down...`);
shuttingDown = true;
if (sock) {
sock.end(undefined);
}
server.close(() => {
console.log('[whatsapp] HTTP server closed');
process.exit(0);
});
// Force exit after 5s
setTimeout(() => process.exit(1), 5000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

2519
bridge/whatsapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "echo-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for Echo Core using Baileys",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"express": "^4.21.0",
"pino": "^9.6.0",
"qrcode": "^1.5.4"
}
}

538
cli.py
View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/home/moltbot/echo-core/.venv/bin/python3
"""Echo Core CLI tool."""
import argparse
@@ -15,7 +15,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.secrets import set_secret, get_secret, list_secrets, delete_secret, check_secrets
from src.credential_store import set_secret, get_secret, list_secrets, delete_secret, check_secrets
PID_FILE = PROJECT_ROOT / "echo-core.pid"
LOG_FILE = PROJECT_ROOT / "logs" / "echo-core.log"
@@ -27,38 +27,72 @@ CONFIG_FILE = PROJECT_ROOT / "config.json"
# Subcommand handlers
# ---------------------------------------------------------------------------
SERVICE_NAME = "echo-core.service"
BRIDGE_SERVICE_NAME = "echo-whatsapp-bridge.service"
def _systemctl(*cmd_args) -> tuple[int, str]:
"""Run systemctl --user and return (returncode, stdout)."""
import subprocess
result = subprocess.run(
["systemctl", "--user", *cmd_args],
capture_output=True, text=True, timeout=30,
)
return result.returncode, result.stdout.strip()
def _get_service_status(service: str) -> dict:
"""Get service ActiveState, SubState, MainPID, and ActiveEnterTimestamp."""
import subprocess
result = subprocess.run(
["systemctl", "--user", "show", service,
"--property=ActiveState,SubState,MainPID,ActiveEnterTimestamp"],
capture_output=True, text=True, timeout=30,
)
info = {}
for line in result.stdout.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
info[k] = v
return info
def cmd_status(args):
"""Show bot status: online/offline, uptime, active sessions."""
# Check PID file
if not PID_FILE.exists():
print("Status: OFFLINE (no PID file)")
_print_session_count()
return
# Echo Core service
info = _get_service_status(SERVICE_NAME)
active = info.get("ActiveState", "unknown")
pid = info.get("MainPID", "0")
ts = info.get("ActiveEnterTimestamp", "")
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
print("Status: OFFLINE (invalid PID file)")
_print_session_count()
return
if active == "active":
# Parse uptime from ActiveEnterTimestamp
uptime_str = ""
if ts:
try:
started = datetime.strptime(ts.strip(), "%a %Y-%m-%d %H:%M:%S %Z")
started = started.replace(tzinfo=timezone.utc)
uptime = datetime.now(timezone.utc) - started
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
uptime_str = f"{hours}h {minutes}m {seconds}s"
except (ValueError, OSError):
uptime_str = "?"
print(f"Echo Core: ONLINE (PID {pid})")
if uptime_str:
print(f"Uptime: {uptime_str}")
else:
print(f"Echo Core: OFFLINE ({active})")
# Check if process is alive
try:
os.kill(pid, 0)
except OSError:
print(f"Status: OFFLINE (PID {pid} not running)")
_print_session_count()
return
# WhatsApp bridge service
bridge_info = _get_service_status(BRIDGE_SERVICE_NAME)
bridge_active = bridge_info.get("ActiveState", "unknown")
bridge_pid = bridge_info.get("MainPID", "0")
if bridge_active == "active":
print(f"WA Bridge: ONLINE (PID {bridge_pid})")
else:
print(f"WA Bridge: OFFLINE ({bridge_active})")
# Process alive — calculate uptime from PID file mtime
mtime = PID_FILE.stat().st_mtime
started = datetime.fromtimestamp(mtime, tz=timezone.utc)
uptime = datetime.now(timezone.utc) - started
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
print(f"Status: ONLINE (PID {pid})")
print(f"Uptime: {hours}h {minutes}m {seconds}s")
_print_session_count()
@@ -80,13 +114,114 @@ def _load_sessions_file() -> dict:
return {}
def _voice_doctor_checks() -> list[tuple[str, bool]]:
"""Voice-stack health checks (Pas 10).
Mirrors the logic in tools/voice_setup.py but returns (label, ok) tuples
so they integrate with cmd_doctor's PASS/FAIL output. All checks degrade
gracefully — ImportError on optional voice deps is reported as FAIL, never
raised, so the rest of `eco doctor` is unaffected.
"""
import importlib.util
import json as _json
import urllib.error
import urllib.request
results: list[tuple[str, bool]] = []
# 1. libopus0 loaded by discord.py
try:
import discord
if not discord.opus.is_loaded():
try:
discord.opus._load_default()
except Exception:
pass
results.append(("libopus loaded (discord.py)", discord.opus.is_loaded()))
except ImportError:
results.append(("libopus loaded (discord.py)", False))
except Exception:
results.append(("libopus loaded (discord.py)", False))
# 2. ffmpeg in PATH
results.append(("ffmpeg in PATH", shutil.which("ffmpeg") is not None))
# 3. Supertonic TTS reachable at http://127.0.0.1:7788/
supertonic_url = "http://127.0.0.1:7788/v1/audio/speech"
supertonic_ok = False
try:
payload = _json.dumps({
"model": "supertonic-3",
"input": "test",
"voice": "M2",
"response_format": "wav",
"lang": "ro",
}).encode("utf-8")
req = urllib.request.Request(
supertonic_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
supertonic_ok = resp.status == 200
except (urllib.error.URLError, ConnectionError, OSError):
supertonic_ok = False
except Exception:
supertonic_ok = False
results.append(("Supertonic TTS reachable at :7788", supertonic_ok))
# 4. faster-whisper importable (don't load model — too slow)
results.append((
"faster-whisper importable",
importlib.util.find_spec("faster_whisper") is not None,
))
# 5. silero-vad importable
results.append((
"silero-vad importable",
importlib.util.find_spec("silero_vad") is not None,
))
# 6. discord.ext.voice_recv importable (vendor package)
voice_recv_ok = False
try:
voice_recv_ok = importlib.util.find_spec("discord.ext.voice_recv") is not None
except (ImportError, ValueError, ModuleNotFoundError):
voice_recv_ok = False
except Exception:
voice_recv_ok = False
results.append(("discord.ext.voice_recv importable", voice_recv_ok))
# 7-9. Voice assets present and non-trivial size
voice_assets = [
("assets/voice/thinking.wav", 1024),
("assets/voice/beep_200ms.wav", 512),
("assets/voice/mhm.wav", 512),
]
for rel_path, min_bytes in voice_assets:
path = PROJECT_ROOT / rel_path
ok = False
try:
ok = path.exists() and path.stat().st_size > min_bytes
except OSError:
ok = False
label = f"{rel_path} (>{min_bytes}B)"
results.append((label, ok))
return results
def cmd_doctor(args):
"""Run diagnostic checks."""
import re
import subprocess
checks = []
# 1. Discord token present
token = get_secret("discord_token")
checks.append(("Discord token", bool(token)))
checks.append(("Discord token in keyring", bool(token)))
# 2. Keyring working
try:
@@ -96,9 +231,17 @@ def cmd_doctor(args):
except Exception:
checks.append(("Keyring accessible", False))
# 3. Claude CLI found
# 3. Claude CLI found and functional
claude_found = shutil.which("claude") is not None
checks.append(("Claude CLI found", claude_found))
if claude_found:
try:
result = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=10,
)
checks.append(("Claude CLI functional", result.returncode == 0))
except Exception:
checks.append(("Claude CLI functional", False))
# 4. Disk space (warn if <1GB free)
try:
@@ -108,11 +251,19 @@ def cmd_doctor(args):
except OSError:
checks.append(("Disk space", False))
# 5. config.json valid
# 5. config.json valid + no tokens/secrets in plain text
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
json.load(f)
config_text = f.read()
json.loads(config_text)
checks.append(("config.json valid", True))
# Scan for token-like patterns
token_patterns = re.compile(
r'(sk-[a-zA-Z0-9]{20,}|xoxb-|xoxp-|ghp_|gho_|discord.*token.*["\']:\s*["\'][A-Za-z0-9._-]{20,})',
re.IGNORECASE,
)
has_tokens = bool(token_patterns.search(config_text))
checks.append(("config.json no plain text secrets", not has_tokens))
except (FileNotFoundError, json.JSONDecodeError, OSError):
checks.append(("config.json valid", False))
@@ -127,6 +278,56 @@ def cmd_doctor(args):
except OSError:
checks.append(("Logs dir writable", False))
# 7. .gitignore correct (must contain key entries)
gitignore = PROJECT_ROOT / ".gitignore"
required_gitignore = {"sessions/", "logs/", ".env", "*.sqlite"}
try:
gi_text = gitignore.read_text(encoding="utf-8")
gi_lines = {l.strip() for l in gi_text.splitlines()}
missing = required_gitignore - gi_lines
checks.append((".gitignore complete", len(missing) == 0))
if missing:
print(f" (missing from .gitignore: {', '.join(sorted(missing))})")
except FileNotFoundError:
checks.append((".gitignore exists", False))
# 8. File permissions: sessions/ and config.json not world-readable
for sensitive in [PROJECT_ROOT / "sessions", CONFIG_FILE]:
if sensitive.exists():
mode = sensitive.stat().st_mode
world_read = mode & 0o004
checks.append((f"{sensitive.name} not world-readable", not world_read))
# 9. Ollama reachable
try:
import urllib.request
req = urllib.request.urlopen("http://10.0.20.161:11434/api/tags", timeout=5)
checks.append(("Ollama reachable", req.status == 200))
except Exception:
checks.append(("Ollama reachable", False))
# 10. Telegram token (optional)
tg_token = get_secret("telegram_token")
if tg_token:
checks.append(("Telegram token in keyring", True))
else:
checks.append(("Telegram token (optional)", True)) # not required
# 11. Echo Core service running
info = _get_service_status(SERVICE_NAME)
checks.append(("Echo Core service running", info.get("ActiveState") == "active"))
# 12. WhatsApp bridge service running (optional)
bridge_info = _get_service_status(BRIDGE_SERVICE_NAME)
bridge_active = bridge_info.get("ActiveState") == "active"
if bridge_active:
checks.append(("WhatsApp bridge running", True))
else:
checks.append(("WhatsApp bridge (optional)", True))
# ---- Voice stack checks (Pas 10) ----
checks.extend(_voice_doctor_checks())
# Print results
all_pass = True
for label, passed in checks:
@@ -144,26 +345,41 @@ def cmd_doctor(args):
def cmd_restart(args):
"""Restart the bot by sending SIGTERM to the running process."""
if not PID_FILE.exists():
print("Error: no PID file found (bot not running?)")
"""Restart the bot via systemctl (kill + start)."""
import time
# Also restart bridge if requested
if getattr(args, "bridge", False):
print("Restarting WhatsApp bridge...")
_systemctl("kill", BRIDGE_SERVICE_NAME)
time.sleep(2)
_systemctl("start", BRIDGE_SERVICE_NAME)
print("Restarting Echo Core...")
_systemctl("restart", SERVICE_NAME)
time.sleep(3)
info = _get_service_status(SERVICE_NAME)
if info.get("ActiveState") == "active":
print(f"Echo Core restarted (PID {info.get('MainPID')}).")
elif info.get("ActiveState") == "activating":
print("Echo Core starting...")
else:
print(f"Warning: Echo Core status is {info.get('ActiveState')}")
sys.exit(1)
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
print("Error: invalid PID file")
sys.exit(1)
# Check process alive
try:
os.kill(pid, 0)
except OSError:
print(f"Error: process {pid} is not running")
sys.exit(1)
os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to PID {pid}")
def cmd_stop(args):
"""Stop the bot via systemctl."""
print("Stopping Echo Core...")
_systemctl("stop", "--no-block", SERVICE_NAME)
import time
time.sleep(2)
info = _get_service_status(SERVICE_NAME)
if info.get("ActiveState") in ("inactive", "deactivating"):
print("Echo Core stopped.")
else:
print(f"Echo Core status: {info.get('ActiveState')}")
def cmd_logs(args):
@@ -405,6 +621,183 @@ def _cron_disable(name: str):
print(f"Job '{name}' not found.")
def cmd_memory(args):
"""Handle memory subcommand."""
if args.memory_action == "search":
_memory_search(args.query)
elif args.memory_action == "reindex":
_memory_reindex()
def _memory_search(query: str):
"""Search memory and print results."""
from src.memory_search import search
try:
results = search(query)
except ConnectionError as e:
print(f"Error: {e}")
sys.exit(1)
if not results:
print("No results found (index may be empty — run `echo memory reindex`).")
return
for i, r in enumerate(results, 1):
score = r["score"]
print(f"\n--- Result {i} (score: {score:.3f}) ---")
print(f"File: {r['file']}")
preview = r["chunk"][:200]
if len(r["chunk"]) > 200:
preview += "..."
print(preview)
def _memory_reindex():
"""Rebuild memory search index."""
from src.memory_search import reindex
print("Reindexing memory files...")
try:
stats = reindex()
except ConnectionError as e:
print(f"Error: {e}")
sys.exit(1)
print(f"Done. Indexed {stats['files']} files, {stats['chunks']} chunks.")
def cmd_heartbeat(args):
"""Run heartbeat health checks."""
from src.heartbeat import run_heartbeat
print(run_heartbeat())
def cmd_whatsapp(args):
"""Handle whatsapp subcommand."""
if args.whatsapp_action == "status":
_whatsapp_status()
elif args.whatsapp_action == "qr":
_whatsapp_qr()
def cmd_openrouter(args):
"""Handle openrouter subcommand."""
semaphore = PROJECT_ROOT / ".use_openrouter"
if args.openrouter_action == "on":
env_file = Path.home() / ".claude-env.sh"
if not env_file.exists():
print(f"Error: {env_file} not found")
sys.exit(1)
# Verify required vars exist in file
text = env_file.read_text()
if "ANTHROPIC_BASE_URL" not in text or "OPENROUTER_API_KEY" not in text:
print(f"Warning: {env_file} may be missing ANTHROPIC_BASE_URL or OPENROUTER_API_KEY")
semaphore.write_text("# OpenRouter mode enabled\n")
print("OpenRouter mode: ENABLED")
print("Restart echo-core for changes to take effect:")
print(" systemctl --user restart echo-core")
elif args.openrouter_action == "off":
if semaphore.exists():
semaphore.unlink()
print("OpenRouter mode: DISABLED")
print("Restart echo-core to use Anthropic API:")
print(" systemctl --user restart echo-core")
else:
print("OpenRouter mode: already disabled")
elif args.openrouter_action == "status":
status = "ENABLED" if semaphore.exists() else "DISABLED"
print(f"OpenRouter mode: {status}")
if semaphore.exists():
print(f"Semafor: {semaphore}")
env_file = Path.home() / ".claude-env.sh"
if env_file.exists():
# Show which vars will be loaded
print(f"Env file: {env_file}")
text = env_file.read_text()
for line in text.splitlines():
if line.strip().startswith("export ") and not line.strip().startswith("#"):
var_name = line.strip()[7:].split("=")[0] if "=" in line else "?"
if any(x in var_name for x in ["ANTHROPIC", "OPENROUTER"]):
print(f" {var_name}=***")
def _whatsapp_status():
"""Check WhatsApp bridge connection status."""
import urllib.request
import urllib.error
cfg_file = CONFIG_FILE
bridge_url = "http://127.0.0.1:8098"
try:
text = cfg_file.read_text(encoding="utf-8")
cfg = json.loads(text)
bridge_url = cfg.get("whatsapp", {}).get("bridge_url", bridge_url)
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
try:
req = urllib.request.urlopen(f"{bridge_url}/status", timeout=5)
data = json.loads(req.read().decode())
except (urllib.error.URLError, OSError) as e:
print(f"Bridge not reachable at {bridge_url}")
print(f" Error: {e}")
return
connected = data.get("connected", False)
phone = data.get("phone", "unknown")
has_qr = data.get("qr", False)
if connected:
print(f"Status: CONNECTED")
print(f"Phone: {phone}")
elif has_qr:
print(f"Status: WAITING FOR QR SCAN")
print(f"Run 'echo whatsapp qr' for QR code instructions.")
else:
print(f"Status: DISCONNECTED")
print(f"Start the bridge and scan the QR code to connect.")
def _whatsapp_qr():
"""Show QR code instructions from the bridge."""
import urllib.request
import urllib.error
cfg_file = CONFIG_FILE
bridge_url = "http://127.0.0.1:8098"
try:
text = cfg_file.read_text(encoding="utf-8")
cfg = json.loads(text)
bridge_url = cfg.get("whatsapp", {}).get("bridge_url", bridge_url)
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
try:
req = urllib.request.urlopen(f"{bridge_url}/qr", timeout=5)
data = json.loads(req.read().decode())
except (urllib.error.URLError, OSError) as e:
print(f"Bridge not reachable at {bridge_url}")
print(f" Error: {e}")
return
qr = data.get("qr")
if not qr:
if data.get("connected"):
print("Already connected — no QR code needed.")
else:
print("No QR code available yet. Wait for the bridge to initialize.")
return
print("QR code is available at the bridge.")
print(f"Open {bridge_url}/qr in a browser to scan,")
print("or check the bridge terminal output for the QR code.")
def cmd_secrets(args):
"""Handle secrets subcommand."""
if args.secrets_action == "set":
@@ -462,7 +855,12 @@ def main():
sub.add_parser("doctor", help="Run diagnostic checks")
# restart
sub.add_parser("restart", help="Restart the bot (send SIGTERM)")
restart_parser = sub.add_parser("restart", help="Restart the bot via systemctl")
restart_parser.add_argument("--bridge", action="store_true",
help="Also restart WhatsApp bridge")
# stop
sub.add_parser("stop", help="Stop the bot via systemctl")
# logs
logs_parser = sub.add_parser("logs", help="Show recent log lines")
@@ -509,6 +907,18 @@ def main():
secrets_sub.add_parser("test", help="Check required secrets")
# memory
memory_parser = sub.add_parser("memory", help="Memory search commands")
memory_sub = memory_parser.add_subparsers(dest="memory_action")
memory_search_p = memory_sub.add_parser("search", help="Search memory files")
memory_search_p.add_argument("query", help="Search query text")
memory_sub.add_parser("reindex", help="Rebuild memory search index")
# heartbeat
sub.add_parser("heartbeat", help="Run heartbeat health checks")
# cron
cron_parser = sub.add_parser("cron", help="Manage scheduled jobs")
cron_sub = cron_parser.add_subparsers(dest="cron_action")
@@ -535,6 +945,21 @@ def main():
cron_disable_p = cron_sub.add_parser("disable", help="Disable a job")
cron_disable_p.add_argument("name", help="Job name")
# whatsapp
whatsapp_parser = sub.add_parser("whatsapp", help="WhatsApp bridge commands")
whatsapp_sub = whatsapp_parser.add_subparsers(dest="whatsapp_action")
whatsapp_sub.add_parser("status", help="Check bridge connection status")
whatsapp_sub.add_parser("qr", help="Show QR code instructions")
# openrouter
openrouter_parser = sub.add_parser("openrouter", help="Toggle OpenRouter mode")
openrouter_sub = openrouter_parser.add_subparsers(dest="openrouter_action")
openrouter_sub.add_parser("on", help="Enable OpenRouter mode")
openrouter_sub.add_parser("off", help="Disable OpenRouter mode")
openrouter_sub.add_parser("status", help="Check OpenRouter status")
# Parse and dispatch
args = parser.parse_args()
@@ -546,6 +971,7 @@ def main():
"status": cmd_status,
"doctor": cmd_doctor,
"restart": cmd_restart,
"stop": cmd_stop,
"logs": cmd_logs,
"sessions": lambda a: (
cmd_sessions(a) if a.sessions_action else (sessions_parser.print_help() or sys.exit(0))
@@ -554,12 +980,22 @@ def main():
cmd_channel(a) if a.channel_action else (channel_parser.print_help() or sys.exit(0))
),
"send": cmd_send,
"memory": lambda a: (
cmd_memory(a) if a.memory_action else (memory_parser.print_help() or sys.exit(0))
),
"heartbeat": cmd_heartbeat,
"cron": lambda a: (
cmd_cron(a) if a.cron_action else (cron_parser.print_help() or sys.exit(0))
),
"secrets": lambda a: (
cmd_secrets(a) if a.secrets_action else (secrets_parser.print_help() or sys.exit(0))
),
"whatsapp": lambda a: (
cmd_whatsapp(a) if a.whatsapp_action else (whatsapp_parser.print_help() or sys.exit(0))
),
"openrouter": lambda a: (
cmd_openrouter(a) if a.openrouter_action else (openrouter_parser.print_help() or sys.exit(0))
),
}
handler = dispatch.get(args.command)

View File

@@ -2,16 +2,115 @@
"bot": {
"name": "Echo",
"default_model": "sonnet",
"owner": null,
"owner": "949388626146517022",
"admins": [
"5040014994"
]
},
"channels": {
"echo-core": {
"id": "1471916752119009432",
"default_model": "sonnet"
},
"echo-work": {
"id": "1466726254312030259",
"default_model": "sonnet"
},
"echo-sprijin": {
"id": "1466739361503772864",
"default_model": "sonnet"
},
"echo-self": {
"id": "1466739112747864175",
"default_model": "sonnet"
}
},
"telegram_channels": {},
"whatsapp": {
"enabled": true,
"bridge_url": "http://127.0.0.1:8098",
"owner": "40723197939",
"admins": []
},
"channels": {},
"whatsapp_channels": {
"echo-test": {
"id": "120363424350922235@g.us",
"default_model": "sonnet"
}
},
"heartbeat": {
"enabled": false,
"interval_minutes": 120,
"channel": "echo-core",
"model": "haiku",
"quiet_hours": [
23,
7
],
"checks": {
"email": true,
"calendar": true,
"kb_index": true,
"git": false
},
"cooldowns": {
"email": 1800,
"calendar": 1800,
"kb_index": 14400,
"git": 14400
}
},
"newsletter_cercetasi": {
"enabled": true,
"interval_minutes": 30
"cron": "0 17 * * 4,5,1",
"channel": "echo-core"
},
"allowed_tools": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"Bash(python3 *)",
"Bash(.venv/bin/python3 *)",
"Bash(pip *)",
"Bash(pytest *)",
"Bash(git *)",
"Bash(npm *)",
"Bash(node *)",
"Bash(npx *)",
"Bash(systemctl --user *)",
"Bash(trash *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(chmod *)",
"Bash(docker *)",
"Bash(docker-compose *)",
"Bash(docker compose *)",
"Bash(ssh *@10.0.20.*)",
"Bash(ssh root@10.0.20.*)",
"Bash(ssh echo@10.0.20.*)",
"Bash(scp *10.0.20.*)",
"Bash(rsync *10.0.20.*)"
],
"discord": {
"email_webhook_url": "https://discord.com/api/webhooks/1496421990846697583/OM8z1eBsJC6-UB9-Zi5RkHP23NNv9UrEznRMx4Y3wSWOFmLazPoi-8_iEKMp0Qgsqr-m"
},
"ollama": {
"url": "http://localhost:11434"
"url": "http://10.0.20.161:11434"
},
"voice": {
"allowed_user_ids": [
"949388626146517022"
],
"user_name": "Marius",
"default_voice": "F1",
"auto_leave_minutes": 5
},
"paths": {
"personality": "personality/",

View File

@@ -1 +1,278 @@
[]
[
{
"name": "discord-test",
"cron": "0 18 2 4 *",
"channel": "echo-core",
"model": "haiku",
"prompt": "Răspunde doar cu textul: Test Discord cron job — funcționează!",
"allowed_tools": [],
"enabled": false,
"last_run": "2026-04-02T18:09:42.851876+00:00",
"last_status": "ok",
"next_run": null
},
{
"name": "anaf-monitor",
"kind": "shell",
"cron": "0 10,16 * * 1-5",
"channel": "echo-work",
"command": [
"python3",
"tools/anaf-monitor/monitor_v2.py"
],
"report_on": "changes",
"timeout": 120,
"enabled": true,
"last_run": "2026-06-06T16:00:00.002312+00:00",
"last_status": "ok",
"next_run": "2026-06-09T10:00:00+00:00"
},
{
"name": "security-audit-daily",
"kind": "shell",
"cron": "0 3 * * *",
"channel": "echo-work",
"command": [
"python3",
"tools/security_audit.py"
],
"report_on": "changes",
"timeout": 180,
"enabled": true,
"last_run": "2026-06-09T03:00:00.002688+00:00",
"last_status": "error",
"next_run": "2026-06-10T03:00:00+00:00"
},
{
"name": "kb-index-refresh",
"kind": "shell",
"cron": "30 3 * * *",
"channel": "echo-work",
"command": [
"python3",
"tools/update_notes_index.py"
],
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": "2026-06-09T03:30:00.002397+00:00",
"last_status": "ok",
"next_run": "2026-06-10T03:30:00+00:00"
},
{
"name": "archive-tasks-daily",
"kind": "shell",
"cron": "0 3 * * *",
"channel": "echo-work",
"command": [
"python3",
"dashboard/archive_tasks.py"
],
"report_on": "changes",
"timeout": 60,
"enabled": true,
"last_run": "2026-06-09T03:00:00.002281+00:00",
"last_status": "ok",
"next_run": "2026-06-10T03:00:00+00:00"
},
{
"name": "backup-config",
"kind": "shell",
"cron": "0 2 * * *",
"channel": "echo-work",
"command": [
"bash",
"tools/backup_config.sh"
],
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": "2026-06-09T02:00:00.002899+00:00",
"last_status": "ok",
"next_run": "2026-06-10T02:00:00+00:00"
},
{
"name": "insights-extract",
"cron": "0 4 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "PLACEHOLDER — Marius will write the full prompt. Intent: extract daily insights from chat history (Discord, Telegram, WhatsApp) and save to memory/kb/insights/YYYY-MM-DD.md. Runs after content-discovery (03:00) so insights can incorporate discovered content proposals.",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "weekly-planning-sun",
"cron": "0 22 * * 0",
"channel": "echo-work",
"model": "sonnet",
"prompt": "WEEKLY PLANNING - duminică seara\n\n## CALENDAR SĂPTĂMÂNA URMATOARE\nVerifică calendarul:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py week\n```\n\nȘi verifică travel reminders:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py travel\n```\n\n## TRIMITE PE DISCORD #echo-work\nchannel=discord, target=1466726254312030259\n\nFormat:\n[⚡ Echo] **Săptămâna începe mâine!**\n\n📅 **CALENDAR:**\n- Luni: [evenimente]\n- Marți: [evenimente]\n- ... (toate zilele cu evenimente)\n\n🚂 **TRAVEL:**\nDacă sunt evenimente NLP/București:\n- ⚠️ [Event] pe [dată] - Ai bilete și cazare?\n- Link CFR: https://bilete.cfrcalatori.ro/\n- Link cazare: booking.com sau unde stă de obicei",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "grup-sprijin-5feb",
"cron": "0 18 5 2 *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Reminder: Azi la 18:00 ai întâlnirea grupului de sprijin cu liderii de la cercetași.\n\nTrimite pe Discord #echo-sprijin (message tool):\nchannel=discord, target=1466739361503772864\n\nMesaj:\n[⭕ Echo] *Azi la 18:00 - Grup de sprijin!*\n\nAi pregătit ceva sau vrei idei de exerciții/întrebări?\n\nFișier: /home/moltbot/echo-core/memory/kb/projects/grup-sprijin/README.md\n\nDupă întâlnire: întreabă cum a fost și notează în fișier.",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "grup-sprijin-pregatire",
"cron": "0 18 3 2 *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Pregătire fișă grup sprijin - joi 5 februarie.\n\nTrimite pe Discord #echo-sprijin (message tool):\nchannel=discord, target=1466739361503772864\n\nMesaj:\n[⭕ Echo] *Întâlnirea de grup e joi!*\n\nHai să pregătim fișa:\n\n1. Ce temă vrei să abordezi de data asta?\n2. Uită-te la exerciții: https://moltbot.tailf7372d.ts.net/echo/grup-sprijin.html - care 1-2 vrei să folosim?\n3. Ai ceva nou de adăugat din săptămâna asta?\n\nDupă ce îmi spui, fac fișa.",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "content-discovery",
"cron": "0 3 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "JOB NOAPTE (02:00) - Content Discovery proactiv.\n\n## SCOP\nCaută video-uri/articole/bloguri relevante DE CALITATE pentru Marius și generează propuneri în format insight.\n\n## PAȘI:\n\n### 1. Citește contextul\n- read: USER.md (interese, provocări)\n- read: memory/YYYY-MM-DD.md (note recente, teme)\n\n### 2. Generează 3-4 queries de căutare\nBazat pe:\n- 60% teme recente (din note zilnice)\n- 40% interese bază (NLP, coaching, productivitate, sănătate)\n\n### 3. Caută conținut de CALITATE\n\n**YouTube (1-2 video-uri):**\n- web_search: 'site:youtube.com [query]'\n- Preferă: <20 min, autori cunoscuți/credibili\n- Evită: clickbait, shorts fără substanță\n\n**Articole/Bloguri (1-2 surse):**\n- web_search: '[query] blog article'\n- Criterii OBLIGATORII pentru a fi inclus:\n ✅ Autor cu credibilitate (expert în domeniu, publicații recunoscute)\n ✅ Conținut profund (nu listicle superficiale)\n ✅ Relevanță directă cu provocările/interesele lui Maris\n ✅ Perspective practice (nu doar teorie)\n \n- Surse de încredere (exemple):\n * Medium (autori verificați cu track record)\n * Bloguri experți NLP/coaching/productivitate\n * HBR, Psychology Today, Scientific American (când e relevant)\n * Bloguri personale ale practițienilor (cu substanță, nu marketing)\n \n- EVITĂ:\n ❌ Listicle generice (\"10 tips for...\")\n ❌ Conținut SEO fără substanță\n ❌ Articole de marketing/vânzare\n ❌ Surse necredibile sau fără autor identificabil\n\n### 4. Verifică calitatea înainte de a propune\nPentru fiecare articol/blog găsit:\n- Citește abstract/primele paragrafe cu web_fetch\n- Întreabă-te: \"Are insight-uri practice pentru Marius?\"\n- Dacă răspuns = NU → nu-l include\n\n### 5. Adaugă în insights ca propuneri\nScrie în memory/kb/insights/YYYY-MM-DD.md (data de MÂINE):\n\n```markdown\n## 🔍 Content Discovery\n\n### [ ] 🎬 **Titlu Video** (💡 nice / 📌 important)\n\n**De ce:** Explicație scurtă - cum se leagă de interesele/provocările lui Marius\n\n**Acțiune:** Procesează video și extrage note\n\n**Link:** https://youtube.com/watch?v=...\n\n---\n\n### [ ] 📄 **Titlu Articol - Autor** (💡 nice / 📌 important)\n\n**De ce:** Explicație - ce insight-uri practice oferă\n\n**Credibilitate:** [Cine e autorul + de ce e relevant]\n\n**Acțiune:** Citește și extrage în kb/articole/\n\n**Link:** https://...\n```\n\n### 6. NU trimite mesaj\nRaportul de dimineață va propune automat.\n\n## REGULI:\n- Max 3-4 propuneri per noapte (1-2 video + 1-2 articole)\n- Prioritate: **CALITATE > CANTITATE**\n- Evită duplicate (verifică memory/kb/ pentru ce e deja procesat)\n- Fii variat - nu repeta aceiași autori zilnic\n- **FILTRARE STRICTĂ:** Doar conținut cu greutate, nu orice link",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "provocare-reminder",
"cron": "0 13 * * 1-5",
"channel": "echo-work",
"model": "sonnet",
"prompt": "REMINDER PROVOCARE - la prânz\n\n1. Citește provocarea: read memory/provocare-azi.md\n\n2. Trimite pe Discord #echo-self (target=1466739112747864175):\n\n[⭕ Echo] **Reminder: Provocarea de azi**\n\n[conținutul provocării]\n\nAi făcut progres? Sau măcar un pas mic?\n\n3. NU trimite pe email (doar Discord)",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "morning-report",
"cron": "30 8 * * *",
"channel": "echo-work",
"model": "sonnet",
"enabled": false,
"prompt": "RAPORT DIMINEAȚĂ - trimite pe EMAIL (Gmail: mmarius28@gmail.com)\n\n## CALENDAR\nVerifică calendarul:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py today\npython3 tools/calendar_check.py travel\npython3 tools/calendar_check.py week\n```\n\n## CITEȘTE CONTEXT\n- USER.md pentru programul lui Marius (luni-joi 15-16 liber)\n- memory/kb/insights/ pentru propuneri (ultimele 3 zile)\n- /home/moltbot/echo-core/approved-tasks.json pentru status proiecte/features (câmpurile: name, status, started_at, pid)\n\n## FORMAT EMAIL HTML\n- Font: 16px text, 18px titluri\n- Culori: albastru (#dbeafe) DONE, gri (#f3f4f6) PROGRAMAT, verde (#d1fae5) PROJECTS\n- Link-uri vizibile\n\n## STRUCTURA RAPORT\n\n### 1. CALENDAR\n- 📅 **AZI:** [evenimente]\n- 📅 **MÂINE:** [evenimente]\n- 📅 **PESTE 2 ZILE:** [dacă e GRUP, NLP, meeting mare]\n- 🚂 **TRAVEL:** Reminders bilete+cazare\n\n### 2. PROIECTE/FEATURES NOAPTEA 💻\n\nCitesc /home/moltbot/echo-core/approved-tasks.json și raportez ce s-a realizat:\n(statusuri: pending, approved, running, complete, failed, stopped)\nPentru stories done/total: citesc /home/moltbot/workspace/{name}/scripts/ralph/prd.json\n\n**Format pentru fiecare proiect/feature [x]:**\n\n```html\n<div style=\"background: #d1fae5; padding: 15px; margin: 10px 0; border-radius: 8px;\">\n <h3>✅ P1 - Nume Proiect</h3>\n \n <p><strong>Status:</strong> X/Y stories complete</p>\n \n <p><strong>Stories realizate:</strong></p>\n <ul>\n <li>✅ US-001: Titlu story - implementat cu succes</li>\n <li>✅ US-002: Titlu story - quality checks pass</li>\n <li>🔄 US-003: Titlu story - în progres (blocat pe dependency)</li>\n </ul>\n \n <p><strong>Link:</strong> <a href=\"https://gitea.romfast.ro/romfast/PROJECT-NAME\">gitea.romfast.ro/romfast/PROJECT-NAME</a></p>\n \n <p><strong>Learnings:</strong> [din progress.txt - ce patterns am descoperit]</p>\n \n <p><strong>Next steps:</strong> [ce rămâne de făcut]</p>\n</div>\n```\n\n**Dacă NU s-au executat proiecte/features:**\n- Sari peste această secțiune\n\n### 3. STATUS GENERAL\n- Ce s-a făcut ieri (joburi, taskuri)\n- Git status ~/clawd\n- Joburi executate (YouTube, insights, etc.)\n\n### 4. PROPUNERI CU ZI ȘI ORĂ!\n\n**OBLIGATORIU:** Fiecare propunere TU+EU sau FAC TU trebuie să aibă ZI și ORĂ concrete!\n\nCategorii:\n- 🤖 **FAC EU** (0 efort) - execut singur\n- 🤝 **TU+EU** (eu pregătesc) - cu zi/oră!\n- 👤 **FAC TU** (template gata) - cu zi/oră!\n\nExemplu:\n- **A1 - Sesiune Dizolvare Vină** 🤝 TU+EU\n 📅 **Marți 3 feb, 15:00-15:30**\n Context + link sursă\n\nReguli programare:\n- Luni-Joi 15:00-16:00 = slot liber\n- Vineri-Duminică = NLP, evită\n- Verifică calendar să nu fie ocupat\n\n### 5. INSIGHTS DISPONIBILE\n\nListează insights-uri [ ] nepropuse încă (format scurt).\n\n### 6. CUM RĂSPUNZI\n- DA = aprob toate (cu zilele/orele propuse)\n- 1 pentru A1,A2 = execut ACUM\n- 2 pentru A3 = programez noapte\n- 3 pentru A5 = skip\n- Alt orar = \"A1 miercuri nu marți\"\n\n## TRIMITERE\npython3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Raport Dimineata DATA\" \"HTML_CONTENT\"\n\nNU trimite pe Discord - doar email.",
"allowed_tools": [],
"last_run": "2026-05-14T08:30:00.001601+00:00",
"last_status": "ok",
"next_run": "2026-05-15T08:30:00+00:00"
},
{
"name": "evening-report",
"cron": "0 21 * * *",
"channel": "echo-work",
"model": "sonnet",
"enabled": false,
"prompt": "RAPORT SEARĂ - trimite pe EMAIL (Gmail: mmarius28@gmail.com)\n\n## CALENDAR\nVerifică ce ai mâine și săptămâna:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py today\npython3 tools/calendar_check.py week\n```\n\n## CITEȘTE CONTEXT\n- USER.md pentru programul lui Marius (luni-joi 15-16 liber, vineri-dum NLP)\n- memory/kb/insights/YYYY-MM-DD.md pentru propuneri insights\n- memory/kb/youtube/ și memory/kb/articole/ pentru inspirație proiecte\n- /home/moltbot/echo-core/approved-tasks.json pentru status proiecte existente (câmpurile: name, status, proposed_at)\n\n## FORMAT EMAIL HTML\n- Font: 16px text, 18px titluri\n- Culori: albastru (#dbeafe) DONE, gri (#f3f4f6) PROGRAMAT, verde (#d1fae5) PROJECTS\n- Link-uri vizibile\n\n## STRUCTURA RAPORT\n\n### 1. MÂINE\n- 📅 Evenimente calendar\n- 🚂 Travel reminders\n\n### 2. STATUS\n- Ce s-a făcut azi\n- Git status\n\n### 3. PROPUNERI CU ZI ȘI ORĂ!\n\n**OBLIGATORIU:** Fiecare propunere TU+EU sau FAC TU trebuie să aibă ZI și ORĂ concrete!\n\nReguli programare:\n- Luni-Joi 15:00-16:00 = slot liber\n- Vineri-Duminică = NLP, evită\n- Verifică calendar să nu fie ocupat\n- Sesiuni scurte: 15-30 min\n\n### 4. PROGRAME/PROIECTE PRACTICE 💻\n\n**CONTEXT OBLIGATORIU - citește înainte de a propune:**\n\n**Proiecte existente (PRIORITARE pentru features):**\n- **roa2web** (gitea.romfast.ro/romfast/roa2web) - FastAPI+Vue.js+Telegram bot\n - Are deja: balanță, facturi, trezorerie\n - Lipsesc: validări declarații ANAF, facturare valută/taxare inversă, notificări\n - Rapoarte ROA noi → FEATURE în roa2web, NU proiect separat!\n- **Chatbot Maria** (Flowise pe LXC 104, ngrok → romfast.ro/chatbot_maria.html)\n - Document store: XML, MD | Groq gratuit + Ollama embeddings + FAISS\n - Problema: răspunsuri nu sunt suficient de bune\n - Angajatul nou poate menține documentația (scrie TXT, trebuie converter)\n - Clientii îl accesează din programele ROA direct\n\n**Întrebări frecvente clienți (surse de proiecte):**\n- Erori validare declarații ANAF (D406, D394, D100 etc.)\n- Cum facturez în valută cu taxare inversă?\n- Probleme la instalări, inițializări firme noi, configurări\n\n**Reguli propuneri (80/20 STRICT):**\n- Impact mare pentru Marius → apoi pentru clienți ERP ROA\n- Inspirat din discovery (YouTube, articole, insights procesate)\n- Features roa2web > proiecte noi (integrare în existent)\n- Proiecte independente doar dacă NU se potrivesc în roa2web/Flowise\n\n**A. FEATURES PROIECTE EXISTENTE (2-3, PRIORITAR):**\n\nFormat:\n```\n### ⚡ F1 - Feature pentru [roa2web/chatbot]\n**Ce face:** Descriere scurtă\n**De ce:** Ce problemă rezolvă (ex: \"clienții întreabă X de 5 ori/săptămână\")\n**Complexitate:** S/M/L\n**Proiect:** roa2web / chatbot-maria\n```\n\n**B. PROIECTE NOI (max 1, doar dacă nu se integrează în existente):**\n\nFormat:\n```\n### 💻 P1 - Nume Proiect\n**De ce:** Cum se leagă de nevoile lui Marius/clienți\n**Impact:** Pentru Marius + pentru clienți\n**Efort:** Ore/zile realist\n**Stack:** Simplu (80/20)\n**Sursă:** [Link nota KB]\n```\n\n**NU propune:**\n- Proiecte complexe fără beneficiu clar\n- Proiecte duplicat cu ce există deja\n- Rapoarte ROA ca proiect separat (→ feature roa2web)\n\n### 5. INSIGHTS DISPONIBILE\nListează insights-uri [ ] nepropuse încă (format scurt).\n\n### 6. CUM RĂSPUNZI\n- DA = aprob toate (cu zilele/orele propuse)\n- 1 pentru A1,A2 = execut ACUM\n- 2 pentru A3 = programez noapte\n- 3 pentru A5 = skip\n- **F pentru F1,F3** = implementează features (joburi noapte)\n- **P pentru P1** = creează proiect nou (job noapte)\n- Alt orar = \"A1 miercuri nu marți\"\n\n## IMPLEMENTARE PROIECTE APROBATE\n\nCând propui features (F) sau proiecte (P), adaugă-le automat în /home/moltbot/echo-core/approved-tasks.json cu status 'pending':\n```bash\npython3 -c \"\nimport json, datetime\nf = open('/home/moltbot/echo-core/approved-tasks.json')\ndata = json.load(f); f.close()\ndata['projects'].append({'name': 'SLUG-PROIECT', 'description': 'DESCRIERE', 'status': 'pending', 'proposed_at': datetime.datetime.utcnow().isoformat(), 'approved_at': None, 'started_at': None, 'pid': None})\ndata['last_updated'] = datetime.datetime.utcnow().isoformat()\nopen('/home/moltbot/echo-core/approved-tasks.json', 'w').write(json.dumps(data, indent=2))\n\"\n```\n\nÎn email, arată lui Marius comanda de aprobare:\n`!approve SLUG-PROIECT` (trimite pe Discord/Telegram la Echo)\n\nNight-execute (23:00) va:\n - genera PRD cu ralph_prd_generator.py dacă nu există prd.json\n - lansa ralph.sh 15 iterații pentru fiecare proiect aprobat\n\n## TRIMITERE\npython3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Raport Seara DATA\" \"HTML_CONTENT\"\n\nNU trimite pe Discord - doar email.",
"allowed_tools": [],
"last_run": "2026-05-14T21:00:00.003039+00:00",
"last_status": "ok",
"next_run": "2026-05-15T21:00:00+00:00"
},
{
"name": "morning-coaching",
"cron": "0 10 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "COACHING DIMINEAȚĂ pentru Marius.\n\n## VERIFICĂ ÎNTÂI DACĂ S-A TRIMIS DEJA\n```bash\nls ~/clawd/memory/kb/coaching/ | grep \"$(date +%Y-%m-%d)\"\n```\n\nDacă EXISTĂ fișier cu data de azi (ex: 2026-02-03-dimineata.md):\n→ Răspunde doar: \"Coaching deja trimis azi. HEARTBEAT_OK\"\n→ NU crea alt fișier, NU trimite pe Discord, NU trimite email\n\nDacă NU există:\n→ Continuă cu pașii de mai jos\n\n## PAȘI\n\n1. Verifică ce ai trimis recent:\n exec ls -la /home/moltbot/echo-core/memory/kb/coaching/ | tail -7\n\n2. Inspiră-te din memory/kb/youtube/ și memory/kb/insights/\n\n3. Crează mesajul de coaching (inspirațional + provocare)\n\n4. Trimite pe Discord #echo-self:\n channel=discord, target=1466739112747864175\n Format: [⭕ Echo] **GÂNDUL DE DIMINEAȚĂ** + conținut\n\n5. Trimite pe EMAIL (Gmail):\n python3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Gandul de dimineata\" \"[MESAJUL - text sau HTML simplu]\"\n\n6. Salvează în memory/kb/coaching/YYYY-MM-DD-dimineata.md\n\n7. Salvează provocarea în memory/provocare-azi.md\n\n8. ADAUGĂ PROVOCAREA În TODO'S:\n Citește dashboard/todos.json, adaugă item nou cu structura:\n {\n \"id\": \"prov-YYYY-MM-DD\",\n \"text\": \"Provocare: [TEXT SCURT - max 100 caractere]\",\n \"context\": \"[EXPLICAȚIE: de ce e important, cum să fac pas cu pas]\",\n \"example\": \"[EXEMPLU CONCRET din viața lui Marius - situație reală]\",\n \"domain\": \"self\",\n \"dueDate\": \"YYYY-MM-DD\",\n \"done\": false,\n \"doneAt\": null,\n \"source\": \"[Autor - Titlu video/articol]\",\n \"sourceUrl\": \"https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/youtube/[fisier].md\",\n \"createdAt\": \"[now ISO]\"\n }\n Salvează dashboard/todos.json\n\n9. Update index:\n python3 /home/moltbot/echo-core/tools/update_notes_index.py\n\n## IMPORTANT\n- VERIFICĂ ÎNTÂI DACĂ EXISTĂ DEJA FIȘIER CU DATA DE AZI!\n- Context-ul explică DE CE și CUM\n- Exemplul e CONCRET, din viața lui Marius (clienți, angajat, etc.)\n- Citește USER.md pentru context despre Marius\n\nFii creativ!",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "evening-coaching",
"cron": "0 22 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "COACHING SEARĂ pentru Marius.\n\n## PAȘI\n\n1. Verifică ce ai trimis:\n exec ls -la /home/moltbot/echo-core/memory/kb/coaching/ | tail -7\n\n2. Verifică provocarea de azi:\n read memory/provocare-azi.md\n\n3. Verifică dacă a fost bifată în Todo's:\n read dashboard/todos.json\n Caută \"prov-YYYY-MM-DD\" (azi) și vezi dacă \"done\": true\n\n4. Inspiră-te din memory/kb/youtube/ și memory/kb/insights/\n\n5. Crează reflecție + follow-up provocare:\n - Dacă a bifat: felicită-l, întreabă cum a fost\n - Dacă nu a bifat: întreabă ce l-a blocat, fără judecată\n\n6. Trimite pe Discord #echo-self:\n channel=discord, target=1466739112747864175\n Format: [⭕ Echo] **GÂNDUL DE SEARĂ** + reflecție\n\n7. Trimite pe EMAIL (Gmail):\n python3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Gandul de seara\" \"[MESAJUL - text sau HTML simplu]\"\n\n8. Salvează în memory/kb/coaching/YYYY-MM-DD-seara.md\n\n9. Update index:\n python3 /home/moltbot/echo-core/tools/update_notes_index.py\n\n## IMPORTANT\n- Verifică Todo's pentru a ști dacă a făcut provocarea\n- Fii empatic, nu critic\n- Citește USER.md pentru context\n\nFii creativ!",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "exercise-snack-1",
"cron": "30 9 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Trimite exercițiul pe Discord #echo-self:\n\nmessage tool: action=send, channel=discord, target=1466739112747864175\n\n⏰ Exercise Snack #1 (3 min)\n\n• 10 squats\n• 5 push-ups\n• 30 sec plank\n\nGata? Reacționează cu ✅",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "exercise-snack-2",
"cron": "30 13 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Trimite exercițiul pe Discord #echo-self:\n\nmessage tool: action=send, channel=discord, target=1466739112747864175\n\n⏰ Exercise Snack #2 (3 min)\n\n• 20 step-ups pe scaun\n• 20 high knees\n\nGata? Reacționează cu ✅",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "exercise-snack-3",
"cron": "30 17 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Trimite exercițiul pe Discord #echo-self:\n\nmessage tool: action=send, channel=discord, target=1466739112747864175\n\n⏰ Exercise Snack #3 (3 min)\n\n• 15 squats\n• 10 lunges\n• Marș pe loc 1 min\n\nGata? Reacționează cu ✅",
"allowed_tools": [],
"enabled": false,
"last_run": null,
"last_status": null,
"next_run": null
},
{
"name": "heartbeat-2h",
"cron": "0 6-18/2 * * *",
"channel": "echo-work",
"model": "sonnet",
"prompt": "Heartbeat check. Rulează src/heartbeat.py printr-un scurt raport de status.\nDacă nu e nimic de raportat (email=0, calendar nu are evenimente <2h, kb ok), răspunde doar cu HEARTBEAT_OK și oprește-te — nu trimite mesaj.\nDacă e ceva: raport scurt pe Discord #echo-work.",
"allowed_tools": [],
"enabled": true,
"last_run": "2026-06-09T08:00:00.001362+00:00",
"last_status": "ok",
"next_run": "2026-06-09T10:00:00+00:00"
},
{
"name": "night-execute",
"cron": "0 23 * * *",
"channel": "echo-work",
"model": "opus",
"enabled": true,
"prompt": "NIGHT-EXECUTE - Implementare autonoma proiecte aprobate\n\n## PASUL 1: Citeste proiectele aprobate\n\nCiteste /home/moltbot/echo-core/approved-tasks.json\nSelecteaza proiectele cu status='approved'\nDaca nu sunt proiecte aprobate: raporteaza pe Discord si opreste-te.\n\n## PASUL 2: Pentru fiecare proiect aprobat\n\nPentru un proiect cu schema extinsa (campuri optionale {repo, branch, base_branch}):\n - {name} = slug-ul proiectului (cheia 'name' din JSON)\n - {repo} = numele repo-ului Gitea (default = {name} daca nu e setat)\n - {branch} = feature branch nou (None inseamna 'lucreaza pe HEAD-ul default al repo-ului')\n - {base_branch} = branch-ul de la care porneste {branch} (default 'main')\n\n1. Verifica daca workspace-ul exista: /home/moltbot/workspace/{name}\n - Daca NU exista:\n TOKEN=$(grep GITEA_TOKEN /home/moltbot/echo-core/dashboard/.env | cut -d= -f2)\n git clone https://moltbot:${TOKEN}@gitea.romfast.ro/romfast/{repo}.git /home/moltbot/workspace/{name}\n # NOTA: cloneaza {repo}, nu {name}, ca sa suporte features pe repo-uri existente\n # (ex: slug='roa2web-bonuri', repo='roa2web')\n cd /home/moltbot/workspace/{name}\n # Daca {branch} e setat: creeaza branch nou de la {base_branch}\n if [ -n \"{branch}\" ]; then\n git fetch origin {base_branch:-main}\n git checkout {base_branch:-main}\n git checkout -b {branch} 2>/dev/null || git checkout {branch}\n fi\n - Daca EXISTA workspace-ul si {branch} e setat: asigura-te ca esti pe {branch}:\n cd /home/moltbot/workspace/{name}\n git checkout {branch} 2>/dev/null || git checkout -b {branch} {base_branch:-main}\n\n2. Verifica daca prd.json exista: /home/moltbot/workspace/{name}/scripts/ralph/prd.json\n - Daca nu: ruleaza generatorul PRD:\n source .venv/bin/activate\n python3 tools/ralph_prd_generator.py \"{name}\" \"{description}\" /home/moltbot/workspace\n\n3. Lanseaza Ralph loop:\n cd /home/moltbot/workspace/{name}\n chmod +x scripts/ralph/ralph.sh\n mkdir -p scripts/ralph/logs\n nohup ./scripts/ralph/ralph.sh 15 > scripts/ralph/logs/ralph-$(date +%Y%m%d).log 2>&1 &\n echo $! > scripts/ralph/.ralph.pid\n\n4. Actualizeaza approved-tasks.json:\n - status: 'running'\n - started_at: timestamp curent\n - pid: PID din .ralph.pid\n\n## PASUL 3: Raport Discord\n\nTrimite pe echo-work:\n- Cate proiecte au pornit\n- PID-urile lor\n- Pentru cele cu {branch} setat, mentioneaza branch-ul activ\n- 'morning-report va raporta progresul la 08:30'\n\n## REGULI IMPORTANTE\n\n- Nu modifica niciodata src/router.py, src/claude_session.py sau alte fisiere core echo-core prin Ralph\n- echo-core self-improvement NUMAI pe branch ralph/echo-improve, nu pe master\n- Daca ralph.sh esueaza: log in approved-tasks.json (status: failed, error: mesaj)\n- Daca git clone esueaza (repo inexistent): log status='failed' cu mesajul, NU continua cu PRD/ralph\n- Delay 5 secunde intre proiecte pentru a evita rate limiting\n",
"allowed_tools": [
"Bash",
"Read",
"Write"
],
"last_run": "2026-06-08T23:00:00.001531+00:00",
"last_status": "ok",
"next_run": "2026-06-09T23:00:00+00:00"
}
]

File diff suppressed because one or more lines are too long

View File

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

61
docs/architecture.md Normal file
View File

@@ -0,0 +1,61 @@
# Echo Core — Architecture & Decisions
## Development History
| Stage | Commit | Description |
|-------|--------|-------------|
| 1 | f2973aa | Project Bootstrap — structura, git, venv |
| 2 | 010580b | Secrets Manager — keyring, CLI `eco secrets set/list/test` |
| 3 | 339866b | Claude CLI Wrapper — start/resume/clear sessions cu `claude --resume` |
| 4 | 6cd155b | Discord Bot Minimal — online, /ping, /channel add, /admin add, /setup |
| 5 | a1a6ca9 | Discord + Claude Chat — conversatii complete, typing indicator, message split |
| 6 | 5bdceff | Model Selection — /model opus/sonnet/haiku, default per canal |
| 7 | 09d3de0 | CLI Tool — eco status/doctor/restart/logs/sessions/channel/send |
| 8 | 24a4d87 | Cron Scheduler — APScheduler, /cron add/list/run/enable/disable |
| 9 | 0bc4b8c | Heartbeat — verificari periodice (email, calendar, kb index, git) |
| 10 | 0ecfa63 | Memory Search — Ollama all-minilm embeddings + SQLite semantic search |
| 10.5 | 85c72e4 | Rename secrets.py, enhanced /status, usage tracking |
| 11 | d1bb67a | Security Hardening — prompt injection, invocation/security logging, extended doctor |
| 12 | 2d8e56d | Telegram Bot — python-telegram-bot, commands, inline keyboards |
| 13 | 80502b7 + 624eb09 | WhatsApp Bridge — Baileys Node.js bridge + Python adapter |
| Systemd | 6454f0f | Echo Core + WhatsApp bridge as systemd user services |
| Setup | setup.sh | Interactive 10-step onboarding wizard |
## Architectural Decisions
- **Claude invocation**: Claude Code CLI cu `--resume` pentru sesiuni persistente
- **Credentials**: keyring (nu plain text pe disk), subprocess isolation
- **Discord**: slash commands (`/`), canale asociate dinamic
- **Telegram**: commands + inline keyboards, @mention/reply in groups
- **WhatsApp**: Baileys Node.js bridge + Python polling adapter, separate auth namespace
- **Cron**: APScheduler, sesiuni izolate per job, `--allowedTools` per job
- **Heartbeat**: verificari periodice, quiet hours (23-08), state tracking
- **Memory Search**: Ollama all-minilm (384 dim), SQLite, cosine similarity
- **Security**: prompt injection markers, separate security.log, extended doctor
- **Concurrency**: Discord + Telegram + WhatsApp in same asyncio event loop via gather
## Infrastructure
- **Ollama:** http://10.0.20.161:11434 (all-minilm, llama3.2, nomic-embed-text)
- **Services:** systemd user services (`echo-core`, `echo-whatsapp-bridge`)
- **CLI:** `eco` (installed at `~/.local/bin/eco` by setup.sh)
## Key Files
| File | Description |
|------|-------------|
| `src/main.py` | Entry point — Discord + Telegram + WhatsApp + scheduler + heartbeat |
| `src/claude_session.py` | Claude Code CLI wrapper cu --resume, injection protection |
| `src/router.py` | Message routing (command vs Claude) |
| `src/scheduler.py` | APScheduler cron jobs |
| `src/heartbeat.py` | Verificari periodice |
| `src/memory_search.py` | Semantic search — Ollama embeddings + SQLite |
| `src/credential_store.py` | Credential broker (keyring) |
| `src/config.py` | Config loader (config.json) |
| `src/adapters/discord_bot.py` | Discord bot cu slash commands |
| `src/adapters/telegram_bot.py` | Telegram bot cu commands + inline keyboards |
| `src/adapters/whatsapp.py` | WhatsApp adapter — polls Node.js bridge |
| `bridge/whatsapp/index.js` | Node.js WhatsApp bridge — Baileys + Express |
| `cli.py` | CLI tool (installed as `eco`) |
| `setup.sh` | Interactive setup wizard — 10-step onboarding |
| `config.json` | Runtime config (channels, telegram_channels, whatsapp, admins, models) |

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

@@ -1,64 +0,0 @@
# 2026-01-29 — Prima zi
## Bootstrap complet! 🌀
- **Eu:** Echo
- **El:** Marius, Constanța
- **Conectare:** WhatsApp + Telegram
## Despre Marius
- 25 ani experiență: VFP9 + Oracle
- ERP ROA — desktop Windows, acum se modernizează
- Stack nou: Vue.js, FastAPI, Telegram bot
- Site: roa2web.romfast.ro
- Email: mmarius28@gmail.com
- Telegram: @mariusmutu (ID: 5040014994)
- WhatsApp: +40723197939
## Ce vrea de la mine
- Proactivitate, idei 80/20
- Mai puțin cod, mai mult impact
- Automatizări
- **Monitorizare ANAF.ro** pentru schimbări declarații/formulare
## Configurări făcute azi
### 1. Monitorizare ANAF ✅
**Locație:** `/home/moltbot/clawd/anaf-monitor/`
**Pagini monitorizate (actualizat):**
- D100, D101, D300, D394, D406
- Situații financiare semestriale 2025
- Situații financiare anuale 2025
- Pagina principală descărcare declarații
**Cum funcționează:**
- Script Python (`monitor.py`) care calculează hash-ul paginilor
- Cron job `anaf-monitor` la fiecare 6 ore
- Notificare automată când se detectează schimbări
### 2. Web Search ✅
- Brave Search API configurat
- Pot căuta pe web acum
### 3. Telegram ✅
- Marius aprobat (pairing code M8893EE3)
- Pot trimite/primi mesaje pe Telegram
### 4. Email SMTP ✅
**Cont:** moltbot@romfast.ro
**Server:** mail.romfast.ro (SMTP 465, IMAP 993)
**Script:** `/home/moltbot/clawd/tools/email_send.py`
- Pot trimite emailuri
- Testat cu succes către mmarius28@gmail.com
## TODO
- [x] Setup monitorizare pagini ANAF ✅
- [x] Configurare Brave Search API ✅
- [x] Aprobare Telegram ✅
- [x] Configurare email SMTP ✅
- [ ] Configurare citire inbox (IMAP) - opțional
- [ ] Explora ce alte automatizări ar ajuta

View File

@@ -1,36 +0,0 @@
# 2026-01-30 - Note consolidate
## Setup inițial multi-agent
- Agenți creați: echo-work, echo-health, echo-growth, echo-sprijin, echo-scout
- Conectați la Discord și WhatsApp
## Context per domeniu
### Sănătate
- Durere cervicală C6-C7 cronică (~1 an)
- Chisturi sebacee pe scalp (12-13 ani) - se infectează periodic
- Interesat de: NMG, post negru, abordări alternative
- A făcut fizioterapie pentru cervicală
### Dezvoltare personală
- Căutare avatar ideal
- Definire 1-2 scopuri mari de viață
- Blocaje: inacțiune în găsirea clienților noi
- Credință limitativă: "clienți noi = mai multă muncă"
- Interese: NLP, Sleight of Mouth, CNV
### Scout
- Marius e voluntar la cercetași în Constanța
- Ajut cu planificare activități, tabere, jocuri
### Sprijin
- Grupul de sprijin de joi
- Spațiu pentru procesare emoțională
- Confidențialitate maximă
## De urmărit
- Pattern-uri durere cervicală
- Episoade chisturi
- Experimente post negru

View File

@@ -1,38 +0,0 @@
# Memory - 2026-01-31
## Probleme identificate cu cron jobs
### 1. Job-uri respirație nu trimiteau notificări
**Cauză:** `wakeMode: "next-heartbeat"` în loc de `"now"`
**Soluție:** Am schimbat la `wakeMode: "now"` și am consolidat 11 job-uri într-unul singur `respiratie-orar` cu schedule `0 7-17 * * *`
### 2. Job-uri coaching nu salvează fișiere
**Cauză fundamentală:** Job-urile trimit instrucțiuni în sesiunea "main" (WhatsApp), dar acea sesiune NU le procesează - răspunde rapid fără să execute pașii.
**Test confirmat:** Execuția directă de pe sesiunea Discord funcționează perfect (mesaj + fișier salvat în kb/coaching/).
**Soluții propuse (de discutat cu Marius):**
1. Script Python dedicat - face totul (citește surse, generează, trimite, salvează)
2. Schimb sessionTarget - trimit pe sesiunea Discord în loc de "main"
3. Logică în HEARTBEAT.md - execut la heartbeat la ora potrivită
**Recomandare:** Opțiunea 1 (script Python) - cel mai robust.
## Actualizări dashboard
### API cron dinamic
- Actualizat `dashboard/api.py` - nou endpoint `/api/cron` care citește din `~/.clawdbot/cron/jobs.json`
- Actualizat `dashboard/index.html` - funcția `loadCronStatus()` folosește API-ul dinamic în loc de lista hardcodată
- Serverul API restartat
## Job-uri active echo-health
| Job | Schedule | wakeMode | Status |
|-----|----------|----------|--------|
| respiratie-orar | 0 7-17 * * * | now | ✅ configurat |
| morning-coaching | 0 7 * * * | now | ⚠️ nu execută instrucțiuni |
| evening-coaching | 0 19 * * * | now | ⚠️ nu execută instrucțiuni |
## De făcut
- [ ] Rezolvare coaching jobs (script Python sau altă soluție)
- [ ] Documentare în kb/projects/FLUX-JOBURI.md

View File

@@ -1,142 +0,0 @@
# Memory 2026-02-01
## 🔄 RESTRUCTURARE MAJORĂ: 4 agenți → 1 agent (IMPORTANT)
**Decizia lui Marius (12:30-14:00 UTC):**
- Unificare toți agenții într-unul singur: **Echo**
- Eliminat: echo-work, echo-health, echo-growth, echo-self, echo-sprijin, echo-scout
- Păstrat canale separate cu ton diferit
**Ce s-a făcut:**
1. Config: doar `echo` în agents.list
2. Bindings: toate canalele Discord + WhatsApp → echo
3. Job-uri: toate 13 mutate pe agentId: echo
4. Directoare: `agents/` șters complet
5. Memory: mutat din agents/echo-self/memory/ → memory/
**Semnături per canal:**
- #echo, #echo-work → [⚡ Echo]
- #echo-self, #echo-sprijin → [⭕ Echo]
- #echo-scout → [⚜️ Echo]
**Fișiere actualizate:**
- SOUL.md: unificat cu SOUL-base.md (117 linii)
- AGENTS.md: refăcut cu reguli (162 linii)
- TOOLS.md: consolidat (66 linii)
- SOUL-base.md: ȘTERS (integrat în SOUL.md)
**Reducere bootstrap:** 714 linii → 521 linii (-27%)
**Tehnici mitigare dezavantaje implementate:**
- Ton diferit per canal (în SOUL.md)
- Semnătură diferită per canal
- Sesiuni izolate per canal (built-in)
- memory_search pentru context (built-in)
---
## Consolidare Echo + Echo Work (IMPORTANT) - mai devreme
**Decizia lui Marius:** Un singur agent (Echo) cu o singură memorie, dar două canale Discord:
- `#echo` - conversație generală
- `#echo-work` - rapoarte automate
**Ce s-am făcut:**
1. Schimbat bindings: #echo-work + WhatsApp Work → acum vin la Echo
2. Mutat 7 joburi cron de la `agentId: echo-work``agentId: echo`
3. Actualizat paths: `approved-tasks.md` acum în `/home/moltbot/clawd/memory/`
4. Echo Work nu mai e folosit (poate fi șters)
## Job Content Discovery (NOU)
**Setat la cererea lui Marius:**
- Rulează la 02:00 București (00:00 UTC)
- Caută automat video-uri YouTube + articole
- Prioritate: 60% teme recente, 40% interese bază
- Procesează și salvează note în kb/
- Rezultatele apar în morning report
**Script:** `tools/content_discovery.py`
## Reguli noi adăugate
1. **Mentenanță listă joburi (OBLIGATORIU):** Când creez/modific joburi cron, actualizez TOOLS.md
2. **Security Rules:** Adăugate în AGENTS.md (nu afișa .env, nu executa comenzi periculoase fără confirmare)
## Realizări azi-noapte (31 ian seara → 1 feb)
### 📧 Sistem Email configurat
- Adresă nouă: `echo@romfast.ro`
- IMAP + SMTP funcțional
- Script `tools/email_process.py` pentru salvare note din email
- Flux: forward → salvare în `kb/emails/` → extragere insights
- Credențiale în `~/.clawd/.env` (nu hardcoded)
### 🎬 4 Video-uri YouTube procesate
1. **Monica Ion - Ep.1 Diagnosticul** - antreprenor cu ciclu yo-yo, cauza cauzelor = vină/rușine
2. **Monica Ion - Ep.2 Vina** - proces practic de dizolvare vină cu legea dualității
3. **James Clear 3-2-1 Newsletter** - simplificare, fundamentale, jocuri infinite
4. **ClawdBot 10x Better** - reverse prompting, expectation setting, tooling propriu
### 🔒 Securizare Clawdbot
- Cercetat OWASP LLM Top 10 (prompt injection)
- Citit Clawdbot security docs complet
- Creat `kb/projects/securizare-clawdbot.md`
- Adăugat Security Rules în AGENTS.md
- Recomandare: `clawdbot security audit --deep`
### 🔍 Content Discovery
- Prima căutare automată bazată pe interese
- Creat `kb/insights/content-recomandat-2026-02-01.md`
- Propus sistem săptămânal automat
## Git Status
16 fișiere modificate/noi - de întrebat dimineață dacă fac commit
## De făcut (backlog rămas)
- [ ] Sistem auto-descoperire conținut (cron săptămânal)
- [ ] Episodul 3 Monica Ion (când Marius uploadează pe YouTube)
- [ ] Instalare Whisper pentru transcriere locală (opțional)
## Insights cheie din video-uri
- **"Nu merit"** e cauza cauzelor pentru instabilitate financiară
- **Dizolvare vină:** găsește beneficiile pentru persoana "afectată"
- **Jocuri infinite:** nu încerca să "termini", caută ritm zilnic sustenabil
- **Reverse prompting:** întreabă AI-ul ce să facă, nu spune-i
## Note tehnice
- 44 note în KB
- TOOLS.md actualizat cu email
- AGENTS.md actualizat cu security rules
- Backlog funcțional în `kb/insights/backlog.md`
---
## Restructurare Joburi (14:45-18:05 UTC)
**Cererea lui Marius:** Separare roluri între joburi + procesare video-uri noaptea
**Job-uri noi create:**
1. **insights-extract** (06:00, 17:00 UTC) - extrage insights din TOATE notele noi din kb/
2. **night-execute-late** (01:00 UTC = 03:00 București) - continuă procesarea task-urilor
**Job-uri modificate:**
- **morning-report** și **evening-report**: NU mai extrag insights, doar propun din cele existente
- **night-execute**: clarificat - execută task-uri, nu marchează insights
**Marcaje insights (sistem nou):**
- `[ ]` = disponibil
- `[x]` = executat
- `[→]` = backlog
- `[—]` = skip
- `[✓]` = notat/înțeles (NOU - pentru insights valoroase fără acțiune necesară)
**Video-uri de procesat noaptea (21 total):**
- 20x Zoltan Vereș
- 1x Monica Ion - Povestea lui Marc #3
Listate în `memory/approved-tasks.md`
**Documentație actualizată:**
- TOOLS.md - tabel joburi
- kb/projects/FLUX-JOBURI.md - flux complet

View File

@@ -1,33 +0,0 @@
# 2 Februarie 2026
## Decizii
- Marius aprobă TOATE propunerile din raportul de seară ("Da")
- A0 + A3 executate imediat
- A1 + A2 (sesiuni TU+EU) de programat luni-joi 15:00-16:00
## Executat
- **A0:** Git commit și push (2 commits: TOOLS.md, KB index, coaching, email tool)
- **A3:** Integrată întrebarea "Ce poveste despre tine ar trebui să renunți?" în insights pentru coaching dimineață
## De programat
- **A1:** Sesiune "Dizolvarea lui Nu Merit" (30 min) - exercițiu Monica Ion
- **A2:** Sistemul 5 pași pentru frici (15 min) - Zoltan Vereș
## Feedback Marius
1. **Email replies:** Nu primește email-urile de confirmare - de verificat flux
2. **Insights → Rapoarte:** Raportul de seară a fost prea conservator - 22 insights extrase dar doar 4 propuneri în raport. De ajustat job-ul evening-report să propună mai multe.
## Stats azi
- 23 note YouTube în KB (20 procesate azi - Zoltan Vereș workshop)
- 22 insights extrase în `memory/kb/insights/2026-02-02.md`
- Job insights-extract funcționează, dar rapoartele nu folosesc toate
## De făcut
- [x] Ajustez evening-report și morning-report să propună cu ZI și ORĂ concrete
- [x] Adăugat listare insights disponibile în rapoarte
- [ ] Programez A1 și A2 cu Marius
## Lecții învățate
- **Rapoarte:** TOATE propunerile TU+EU/FAC TU trebuie să aibă zi și oră concrete
- **Email flow:** Reply #1 imediat (confirmare primire), Reply #2 după execuție (ce s-a făcut)
- **Insights:** Listează TOATE insight-urile disponibile, nu doar câteva

View File

@@ -1,77 +0,0 @@
# 3 Februarie 2026
## roa2web WhatsApp Import - COMPLET
### Ce s-a realizat:
1. **OCR prin API** - doctr-plus, ~4 sec per bon (nu 30 sec ca PaddleOCR cold start)
2. **Flux complet testat:** PDF WhatsApp → OCR → SQLite → Oracle
3. **Scripturi în repo:** `roa2web/backend/scripts/whatsapp_import/`
4. **Commit:** `1366dbc` pe main
### Flux final:
```
PDF (WhatsApp) → OCR API (~4sec) → SQLite (draft) → Aprobare frontend → Oracle
```
### Probleme rezolvate:
- **Oracle pool "SID not found"** - trebuia restart complet backend (kill -9)
- **Frontend fără server dropdown** - Marius a fixat și făcut commit
- **Server ID** - acum e `central` nu `test`
### Endpoint-uri API folosite:
- `POST /api/auth/login` - cu server_id="central"
- `POST /api/auth/check-identity` - verifică user și returnează servere
- `POST /api/data-entry/ocr/extract` - submit OCR job
- `GET /api/data-entry/ocr/jobs/{id}` - rezultat OCR
- `POST /api/data-entry/receipts/` - creare receipt în SQLite
### Test real efectuat:
- Bon Dedeman (RO10562600) primit pe WhatsApp
- OCR: 5.2 sec, confidence 96%
- Salvat în SQLite: ID=73, status=draft
- Salvat și în Oracle: COD=1140631, luna 01/2026
### Locații importante (claude-agent LXC 171):
- Backend: http://localhost:8000 (sau claude-agent:8000)
- Frontend: http://localhost:3000 (sau claude-agent:3000)
- Scripturi: `/workspace/roa2web/backend/scripts/whatsapp_import/`
- Start: `./start.sh central`
---
## Decizii
- (în așteptare raport dimineață)
## Executat azi
- **06:02 UTC:** Job `insights-extract` - verificat insights 2026-02-03.md (deja complet)
- **06:02 UTC:** Adăugat tehnică nouă în tehnici-pauza.md: "Pauza de 10 secunde" (Zoltan Vereș)
- **06:02 UTC:** Actualizat index KB (87 note)
- **07:01 UTC:** Morning coaching trimis (tema: Umbrele/claritate)
- **12:00 UTC:** Alertă calendar: sesiune 15:00 notificată pe Discord
- **18:01 UTC:** Raport seară trimis - propuneri: cold email, sesiuni, audit securitate
## De făcut
- [ ] A1: Sesiune "Dizolvarea lui Nu Merit" (30 min) - de programat
- [ ] A2: Sistemul 5 pași pentru frici (15 min) - de programat
- [ ] Verificare securitate Clawdbot (din insights tehnice)
- [ ] Verificare email replies (flux nefuncțional?)
- [ ] **BON DE SALVAT:** CUI RO11201891, 310.98 RON, 02.02.2026
- PDF: `2831eeeb-f331-4fb1-a7b1-ede1c954eadb.pdf`
- Partener nou - de verificat numele real
- Dry run făcut, așteaptă confirmare
## Insights disponibile (din 2026-02-03.md)
- ⚡ Heartbeat cost optimization - VERIFICAT, monitorizăm
- ⚡ Securitate Clawdbot - audit recomandat
- 📌 Multi-agent > single super-agent - framework delegare
- 📌 Overnight coding - experiment seara → review dimineața
- 📌 Paradoxul utilitate-securitate - nivele trust angajat
- 💡 Work on agents, not app - sisteme vs task-uri
## Context
- Luni, începe săptămâna
- Note tehnice procesate ieri (Clawdbot, Claude Code)
- Zoltan Vereș workshop-uri complete în KB (20+ note)
## Lecții
- (de completat pe parcursul zilei)

View File

@@ -1,23 +0,0 @@
# 4 Februarie 2026
## Executat azi
- **06:30 UTC:** Raport dimineață trimis pe email
- Calendar: azi liber, mâine sesiune 15:00 + grup 18:00
- Travel alert: NLP 7-8 feb - urgent bilete!
- Propuneri: vizualizare motivație pt grup, verificare bilete
## De făcut
- [ ] Procesare răspuns email Marius (când vine)
- [ ] BON de salvat: CUI RO11201891, 310.98 RON (așteaptă nume partener)
## Context
- Miercuri, ziua liberă
- Mâine: Sesiune 5 pași frici (15:00) + GRUP JOI (18:00)
- Weekend: NLP M4 (7-8 feb) - verificat bilete?
## Decizii
- (de completat)
## Lecții
- (de completat)

View File

@@ -1,59 +0,0 @@
# 5 Februarie 2026
## Executat azi
### 📊 Raport de seară (22:20 UTC)
- **Generat și trimis:** raport complet pe email mmarius28@gmail.com
- **Conținut:** Calendar (mâine + săptămână), Status azi, Propuneri concrete
- **Model:** Sonnet 4.5 (calitate înaltă)
### 🧠 Insights & Analysis
- **Procesate:** 4 surse noi (FEATURE-files-pdf-download, cron-jobs, session-initialization, infrastructure)
- **Extras:** 6 insights importante despre automation, optimization, infrastructure, coaching
- **Insight principal:** Energia pentru sisteme nu se traduce în acțiune externă (business development)
### 💭 Coaching de seară
- Creat gând despre vizualizare și prospecting
- Provocare: vizualizarea prospectingului (5 min)
- Focus: deblocarea emoțională pentru contactare clienți noi
### 📋 Task Management
- Verificat approved-tasks.md
- Pregătit pentru night-execute (23:00): YouTube Monica Ion
- Programat: articole Monica Ion (joi-luni), PDF (vineri)
## Context urgent
### ⚠️ WEEKEND 7-8 februarie - BUCUREȘTI NLP M4
- **Verificare necesară JOI DIMINEAȚĂ (6 feb, 08:00):**
- Bilete tren București?
- Cazare confirmată?
- Materiale pregătite?
## Propuneri prioritare pentru mâine (6 feb)
1. **08:00-09:00** - Verificare călătorie NLP (URGENT)
2. **11:00-12:00** - Business development: un apel de prospecting (vezi coaching)
3. **14:00+** - Task aprobat: articole Monica Ion (primele 3-5)
## De făcut
- [ ] Verificare logistică NLP (7-8 feb) - JOI DIMINEAȚĂ
- [ ] Un apel prospecting (vezi coaching vizualizare)
- [ ] Procesare răspuns email (când vine)
- [ ] BON de salvat: CUI RO11201891, 310.98 RON (așteaptă nume partener)
- [ ] Articole Monica Ion: start procesare
## Decizii necesare
- [ ] **Luni 9 feb:** Decizie PDF Download Feature (Pandoc pe LXC flowise?)
## Lecții din insights
- Automation internă ≠ growth extern
- 80/20 mindset pe probleme interne, nu externe
- Un apel = posibil client nou în 3 luni (statistică)
- Coaching automatizat ≠ coaching transformațional (consideră sesiune 1-1 externă?)
## Note sub-agent
- **Task:** Generare raport de seară manual (cerut de Marius 22:20)
- **Completat:** Calendar verificat din memorie (calendar_check.py indisponibil - lipsă module google)
- **Trimis:** Email cu raport complet, structurat, cu propuneri concrete
- **Model folosit:** Sonnet 4.5 (conform cerință calitate)

View File

@@ -1,146 +0,0 @@
# 2026-02-06 (Joi)
## 🔒 Security Audit Executat (14:41 UTC / 16:41 București)
### Findings:
#### ⚠️ CRITICAL (2 issues):
**1. Control UI allows insecure HTTP auth**
- **Problema:** `gateway.controlUi.allowInsecureAuth=true` permite token-only auth peste HTTP
- **Risc:** Dacă e expus extern (reverse proxy), token poate fi interceptat
- **Status:** Gateway rulează pe localhost (127.0.0.1) → risc REDUS dacă nu e expus
- **Fix posibil:**
- Disable `allowInsecureAuth`
- SAU switch la HTTPS (Tailscale Serve)
- SAU keep localhost-only (current setup OK)
**2. Small model (qwen2.5-7b) fără sandboxing + web tools enabled**
- **Problema:** Model 7B folosit ca fallback, dar:
- Sandboxing = OFF
- Web tools enabled (web_search, web_fetch, browser)
- Small models = susceptibili la prompt injection prin dirty data
- **Risc:** Dacă modelul mic procesează emailuri/web content → vulnerabil
- **Fix recomandat:**
- Enable sandboxing pentru toate sessions: `agents.defaults.sandbox.mode="all"`
- SAU disable web tools pentru model mic: `tools.deny=["group:web","browser"]`
- SAU remove model mic din fallback chain
#### ⚠️ WARN (2 issues):
**3. Reverse proxy headers not trusted**
- **Problema:** `gateway.trustedProxies` e empty
- **Risc:** Dacă expui Control UI prin reverse proxy, IP checks pot fi spoofed
- **Fix:** Setează `gateway.trustedProxies` la IP-urile proxy-ului
- **SAU:** Keep Control UI local-only (current setup)
**4. Gateway password în config file**
- **Problema:** `gateway.auth.password` e stocat în config pe disk
- **Risc:** Dacă cineva accesează filesystem → vede parola
- **Fix recomandat:**
- Folosește `OPENCLAW_GATEWAY_PASSWORD` (env variable)
- Remove `gateway.auth.password` din config
#### ✅ INFO (bun):
- WhatsApp DMs disabled (evită dirty data)
- Attack surface: 0 open groups, 3 allowlist
- Elevated tools enabled (OK, controlat prin aprobare)
- Browser control enabled (OK pentru automatizări)
---
## ✅ Acțiuni Executate:
### 1. Security Rules adăugate în AGENTS.md
- Secțiune nouă: "Securitate (MANDATORY)"
- Reguli: API keys în .env, whitelist email, plan mode, model selection
- Marcată ca META-REGULĂ (nu se modifică fără aprobare)
### 2. Daily Self-Audit Cron Job Creat
- **Când:** 09:30 București (07:30 UTC), zilnic
- **Ce face:**
- Review AGENTS/SOUL/USER/IDENTITY/HEARTBEAT/TOOLS/cron-jobs/infrastructure
- Caută: info outdated, reguli conflictuale, workflow-uri nedocumentate
- Propune cleanup în #echo-work (doar dacă găsește probleme)
- **Model:** Sonnet (balance între cost și capability)
### 3. Cron-jobs.md actualizat
- Adăugat daily-self-audit la 09:30
---
## 📋 Recomandări pentru Marius:
### 🔥 Prioritate ÎNALTĂ:
**A. Fix model mic (qwen2.5-7b) vulnerability:**
- **Opțiune 1 (RECOMAND):** Remove din fallback chain (folosește doar Claude models)
- **Opțiune 2:** Enable sandboxing global (`agents.defaults.sandbox.mode="all"`)
- **Opțiune 3:** Disable web tools pentru model mic
**De ce e important:** Model 7B + web tools + dirty data = vulnerabil la prompt injection
---
### 📌 Prioritate MEDIE:
**B. Move gateway password în environment variable:**
```bash
# .env
OPENCLAW_GATEWAY_PASSWORD=<current_password>
```
Apoi remove din config.json.
**C. Review Control UI exposure:**
- Verifică dacă e expus extern (reverse proxy, Tailscale)
- Dacă DA → setează `trustedProxies` sau disable `allowInsecureAuth`
- Dacă NU (localhost-only) → OK as-is
---
### 💡 Nice-to-Have:
**D. Periodic security audits:**
- Manual: `openclaw security audit --deep` (lunar)
- Sau: Cron job pentru audit automat (dar poate fi noisy)
---
## 📊 Video-uri Procesate Azi:
1.**A Powerful NLP Reframe** (8:50) - Reframing pentru credințe limitatoare
2.**NLP Trick Cold Calls** (0:59) - Tehnică: spune numele întâi
3.**NLP Sales Techniques** (4:20) - Promotional, no content
4.**OpenClaw Best Practices** (22:31) - Tutorial complet (18KB notă)
---
## 🎯 Următorii Pași:
- [ ] Marius decide fix pentru qwen2.5-7b vulnerability
- [ ] Marius decide move password în .env
- [ ] Daily self-audit rulează prima dată mâine 09:30
- [ ] Monica Ion Blog - Tura 1 (20 articole) programată diseară 23:00
---
**Ora finalizare:** 14:41 UTC (16:41 București)
---
## 🌙 Raport Seară Executat (18:00 UTC / 20:00 București)
### ✅ Acțiuni:
- Email raport trimis pe mmarius28@gmail.com
- Git commit + push: 8 fișiere (5 noi, 3 modificate)
- Propuneri cu ZI și ORĂ concrete:
- A1: Reframe NLP - Luni 9 feb 15:00
- A2: Diagnostic Platou - Marți 10 feb 15:00
- A3: Legea Transformării - Miercuri 11 feb 15:00
- A4: Cold Call Trick - OPȚIONAL (test singur)
### 📋 Conținut raport:
- Mâine: NLP Master Modul 4 (toată ziua)
- Status azi: Security audit, 4 video-uri, 5 insights, 4 exerciții
- Săptămâna viitoare: Luni-Miercuri 15:00-16:00 liber (Joi ocupat)

View File

@@ -1,7 +0,0 @@
# 2026-02-07
## Daily Self-Audit (09:30)
- Audit rulat la 07:30 UTC (09:30 București)
- **1 problemă găsită:** USER.md conține MM3 (6 feb 2026) trecut + M4 (7-8 feb) în desfășurare
- Propunere trimisă în #echo-work: marchez MM3 ca ✅ trecut
- Aștept confirmare pentru cleanup

View File

@@ -1,66 +0,0 @@
# 2026-02-08
## Discuție workflow proiecte/features (Marius + Echo)
**Context:** Marius vrea să încep să propun și să creez programe/proiecte în cod care l-ar putea ajuta (80/20), inspirate din ce învăț de pe Discovery/YouTube/articole.
**Cerințe:**
1. **Raport seară:** Propune 1-2 proiecte noi + 2-3 features pentru proiecte existente
2. **Proiecte de "joacă":** Mai întâi pentru Marius să vadă cum îl ajută, apoi să le aplice la clienți
3. **Criterii:** 80/20 strict - doar lucruri cu impact mare, NU orice
4. **Inspirație:** Din interesele lui (USER.md) + Discovery (YouTube, articole, bloguri procesate)
**Implementare:**
- **Mașină:** claude-agent (LXC 171, 10.0.20.171) în `/workspace/`
- **Git:** Push la gitea.romfast.ro
- **Model strategy (OBLIGATORIU):**
- **Opus** → Planning, PRD, stories (eu, Echo)
- **Sonnet** → Coding, debugging, implementare (Ralph loop)
**Ralph workflow:**
1. **Seara (20:00):** Propun proiecte (P1, P2) + features (F1, F2, F3)
2. **Marius aprobă:** "P pentru P1, P2" sau "F pentru F1, F3"
3. **Noapte (23:00, 03:00):**
- Eu (Opus) pe claude-agent: `/prd` skill → PRD markdown
- Eu (Opus): `/ralph` skill → prd.json cu stories prioritizate
- `ralph.sh` (Sonnet): loop autonom implementare story by story
- Quality checks: typecheck, lint, test
- Git push gitea
4. **Dimineața (08:30):** Raportez ce s-a realizat, stories complete, learnings
**Ralph plugin:** `/workspace/ralph-claude/` pe claude-agent
- Skills: `/prd` (generare PRD prin întrebări) + `/ralph` (conversie la prd.json)
- Script: `ralph.sh` - loop autonom cu Claude Code (Sonnet)
- Output: prd.json cu stories, progress.txt cu learnings
**Job-uri actualizate:**
- ✅ evening-report: §4 Programe/Proiecte (P1, P2) + Features (F1, F2, F3)
- ✅ night-execute: Opus + Ralph workflow (proiecte prioritate #1, YouTube după)
- ✅ night-execute-late: Continuare execuție
- ✅ morning-report: §2 raport proiecte/features cu stories + learnings + link gitea
**Note tehnice:**
- SSH claude-agent: `ssh echo@10.0.20.201 "sudo pct exec 171 -- su - claude -c 'cd /workspace && bash'"`
- Claude Code instalat pe claude-agent
- Ralph structură: PROJECT-NAME/tasks/prd-*.md + scripts/ralph/prd.json + progress.txt
---
## TODO următoarele teste
- [ ] Test primul proiect propus seara
- [ ] Verificare execuție Ralph noapte
- [ ] Raport dimineață cu status proiecte
---
## Daily Self-Audit (09:30)
**Status:** 3 probleme găsite și raportate în #echo-work
**Probleme:**
1. **Ralph workflow nedocumentat** în AGENTS.md/TOOLS.md → propus update ambele fișiere
2. **Curs NLP M4 ASTĂZI** (7-8 feb) → Marius ocupat weekend, trebuie marcat în USER.md
3. **Email whitelist inconsistent** → USER.md lipsește marius.mutu@romfast.ro
**Trimis:** Discord #echo-work la 09:30 (UTC 07:30)

View File

@@ -1,28 +0,0 @@
# 2026-02-09 - Luni
## ✅ Done
### Evening Report trimis (20:00)
- Status: Email HTML trimis pe mmarius28@gmail.com
- Conținut:
- ⚠️ Calendar token expirat - necesită re-autentificare
- Procesare Monica Ion Ep7 - Pattern Sacrificiu→Durere→Sabotare
- 4 insights noi generate în 2026-02-09.md
- Propuneri: 2 sesiuni coaching (marți + joi) + sistematizare training angajat
- 3 features roa2web: validare ANAF, notificări Telegram, FAQ chatbot Maria
- Night execute (23:00): 40 articole Monica Ion Friday Spark 178-139
### Insights generate
- Pattern toxic: Sacrificiu→Durere→Sabotare (aplicabil la angajat nou)
- Întrebarea care deblochează: "Ce beneficii ai din blocaj?" (proiect 4000 euro)
- Sistematizare > Dependență oameni (training video/doc pentru angajat)
- Identitate: Dalta, nu Ciocan + Body Loose, Head Clear (James Clear)
## 📊 Git Status
- Modified: dashboard/status.json, memory/kb/index.json, tehnici-pauza.md
- Untracked: insights/2026-02-09.md, projects/NLP/
- Acțiune: commit la final de săptămână
## 🔄 Calendar Issue
- Token Google Calendar expirat → RefreshError
- Marius trebuie să re-autentifice manual: `python3 tools/calendar_check.py`

View File

@@ -1,290 +0,0 @@
# 2026-02-10
## Antfarm - Habit Tracker Dashboard Feature (COMPLET)
### Session 1: Prima încercare (09:33-14:09)
**09:33 - Request:** Marius vrea Habit Tracker în dashboard cu antfarm.
**Greșeli (învățături):**
- ❌ Lansat direct workflow fără întrebări → implementare minimalistă
- ❌ Planner cu Sonnet (nu Opus) → planning superficial
- ❌ Test files în dashboard/ root → aglomerare
- ❌ Nu am pus întrebări UX înainte → features incomplete (fără edit, fără customizare frecvență, etc.)
**Rezultat:** Feature incomplet, șters branch, restart cu flux nou.
---
### Session 2: Flux NOU cu Discovery (14:57-15:30)
**14:57 - Feedback Marius:**
- Feature basic, lipseau: edit, customizare frecvență (zile, categorii, culori, icoane)
- Test files în locul greșit
- Lipsă discovery/întrebări UX
- Planning ar trebui cu Opus, execuție cu Sonnet
**Actions:**
1. ✅ Creat flux nou documentat: `memory/kb/tools/antfarm-flux-complet.md`
- Discovery cu 5-7 întrebări adaptive (inspirat din ralph /prd)
- PRD complet cu toate detaliile
- Config Opus pentru planner, Sonnet pentru rest
2. ✅ Discovery complet pentru Habit Tracker:
- Întrebări: funcționalitate, layout, create/edit, frecvență, customizare, check-in, stats
- Răspunsuri Marius: cards grid, modal form, TOATE frequency types, TOATE customizare options, lives system Duolingo-style
3. ✅ PRD Complet generat: `tasks/prd-habit-tracker.md` (25 KB):
- 19 User Stories (dependencies-first)
- Schema habits.json completă cu frequency types (6 tipuri)
- 8 API endpoints (GET, POST, PUT, DELETE, check, skip, restore-life)
- UX mockups (cards, modals, forms)
- Lives system (3 lives, restore după 7 consecutive)
- Check-in opțiuni (simple click SAU long-press cu note/rating/mood)
- Stats (streak, best, completion rate, weekly summary)
- Tests location explicit (dashboard/tests/)
- Non-goals (cloud sync, gamification advanced, export/import)
4. ✅ Modificat antfarm pentru Opus + Sonnet:
- Editat `workflow.yml``model: opus` la planner
- Modificat `agent-cron.ts` → extrage model din agent definition
- Rebuild antfarm (`npm run build`)
- Reinstall feature-dev workflow
5. ✅ Lansat workflow cu PRD complet (15:31):
- Run ID: `1fa11b74-636a-4ffa-b14c-c873893ee49d`
- Task string include link la PRD + overview requirements
- Planner (Opus) va citi PRD complet și descompune în stories
- Developer/Verifier/Tester (Sonnet) vor executa
**Status checks:**
- **15:31** - Workflow lansat, planner pending
- **16:01** - Planner done, setup done, 3/15 stories complete (US-001, US-002, US-003)
- **16:03** - US-004 în progress (check-in endpoint cu streak logic)
- Dashboard monitor: https://moltbot.tailf7372d.ts.net:3333
- Estimare completion: ~17:30-18:00 (2-2.5h de la start)
**Planner optimizations (Opus):**
- PRD avea 19 stories → Planner le-a consolidat la 15 stories
- Dependencies: Backend APIs (US-001 to US-005) → Frontend components (US-006 to US-014) → Tests (US-015)
**Progress:**
- ✅ US-001: Habits JSON schema and helper functions (done)
- ✅ US-002: Backend API - GET and POST habits (done)
- ✅ US-003: Backend API - PUT and DELETE habits (done)
- 🔄 US-004: Backend API - Check-in endpoint with streak logic (running)
- ⏳ US-005 to US-015: Pending (11 stories remaining)
---
## Lecții Învățate (OBLIGATORIU pentru viitor)
**Fluxul corect pentru antfarm:**
1. **Discovery:** 5-7 întrebări adaptive despre UX/features (80/20)
2. **PRD:** Generat complet cu user stories, mockups, acceptance criteria
3. **Config models:** Opus pentru planner, Sonnet pentru execuție
4. **Launch:** Cu link la PRD + overview (nu prompt vag)
5. **Monitor:** Dashboard + status checks
**NU mai fac:**
- ❌ Launch direct fără întrebări
- ❌ Presupun ce vrea utilizatorul
- ❌ Las planner-ul să interpreteze minimal
- ❌ Accept structure greșită (ex: tests în locul greșit)
**Flux documentat:** `memory/kb/tools/antfarm-flux-complet.md`
---
## Pre-Compaction State (~16:10)
**Workflow still running:** `1fa11b74-636a-4ffa-b14c-c873893ee49d`
- 4/15 stories complete (26% progress)
- US-004 (check-in endpoint) în dezvoltare
- Developer și Verifier agents lucrează simultan
- Branch: `feature/habit-tracker`
- Estimated completion: ~17:30-18:00
**Next actions (după compaction):**
1. Monitor workflow status periodic
2. Check când completează toate cele 15 stories
3. Review PR pentru verificare:
- Tests în `dashboard/tests/` (NU dashboard/ root)
- API paths folosesc `/echo/api/habits` prefix
- Toate frequency types implementate (6 tipuri)
- Lives system complete (3 max, restore după 7 consecutive)
- Full customization (category, color, icon, priority, notes, reminder)
4. Test manual features match PRD
5. Raportează către Marius când completează
**Critical files:**
- PRD: `tasks/prd-habit-tracker.md` (25KB, 19 stories → consolidated to 15)
- Flow docs: `memory/kb/tools/antfarm-flux-complet.md`
- Antfarm config: `antfarm/workflows/feature-dev/workflow.yml` (Opus for planner)
- Session notes: `memory/2026-02-10.md` (acest fișier)
---
## Session 3: Workflow 1 Completat + Refinements UX (17:58-21:10)
### 17:58 - Workflow 1 completat cu SUCCES! ✅
**Run:** `1fa11b74-636a-4ffa-b14c-c873893ee49d`
**Timp:** 2h 24min (15:31 → 17:55)
**Stories:** 15/15 complete (100%)
**Implementare completă:**
- ✅ Backend (5 stories): Schema, APIs (GET, POST, PUT, DELETE, check, skip), streak logic, lives system
- ✅ Frontend (9 stories): Page, cards, modals (create/edit), check-in (click + long-press), filter/sort, stats, mobile responsive
- ✅ Tests (1 story): 4 fișiere în `dashboard/tests/` (API, frontend, helpers, integration) - total 147KB
**Verificări PRD:**
- ✅ Tests în locația corectă (`dashboard/tests/`)
- ✅ Toate frequency types (6 tipuri)
- ✅ Lives system Duolingo-style
- ✅ Customization completă (category, color, icon, priority, notes, reminder)
- ✅ Check-in options (simple + long-press)
- ✅ Mobile responsive
---
### 18:03 - Feedback Marius: UX prea lăbărțat, trebuie minimalist
**Probleme identificate:**
1. ❌ Carduri prea mari → compacte pentru mobil
2. ❌ Căutare/filtre prea mari → colapate
3. ❌ Statistici prea mari → colapate
4. ❌ Nu poți debifa după bifat
5. ❌ Progress 3.33% → rotunjit
6. ❌ Modal transparentă → opacă
7. ❌ Lista iconițe full → colapsată
**18:04 - Discovery pentru Refinements (7 întrebări):**
Folosit același flux ralph /prd:
1. **Q1:** Ce componente prea mari? → **A:** Toate
2. **Q2:** Card compact - ce vizibil? → **A:** Medium + icon + culoare (nume + check + streak + progress% + next date + icon + accent)
3. **Q3:** Search/filter collapse? → **A:** Icon doar (expand inline)
4. **Q4:** Stats collapse? → **A:** Collapse implicit (chevron expand)
5. **Q5:** Check/uncheck toggle? → **A:** Buton toggle (click ↔ debifează)
6. **Q6:** Icon picker collapse? → **A:** Dropdown cu search
7. **Q7:** Modal refinements? → **A:** Backdrop opac
**18:12 - PRD Refinements generat:**
- `tasks/prd-habit-tracker-refinements.md` (16KB)
- 9 User Stories pentru UX improvements
- Mobile-first minimalism focus
**18:13 - Workflow 2 lansat:**
- Run ID: `94c10162-8a6c-4848-a4f0-a4d1e8cb2e97`
- Branch: `feature/habit-tracker` (continuare în același branch, NU nou)
- Planner: Opus → 8 stories (optimizat din 9)
**Progress workflow 2:**
- **19:05** - 4/8 stories done (50% în 52 min)
- **19:29** - 7/8 stories done (87.5%)
- **20:46** - 7/8 stories, US-008 (tests) blocat >1h fără progres
---
### 20:48 - Restart workflow + Fix manual
**Marius:** "Restart workflow. În plus văd că US-007 nu este făcută"
**Verificat US-007:**
- ✅ Modal backdrop ESTE opac în cod (`rgba(0, 0, 0, 0.6)`)
- ✅ Touch targets 44px implementate
- **Problema:** Browser cache (trebuie hard refresh)
**Actions:**
1. ✅ Workflow step US-008 marcat failed → va fi retried
2. ✅ Restart server dashboard (pentru a reîncărca habits.html)
3. **21:07** - Marius testează: "Nu este opac. Cardurile cu totaluri nu sunt colapsabile"
**Root cause găsit:**
- Modal backdrop: browser cache (CSS corect în fișier)
- **Stats collapse: BUG în implementare** - developer a făcut collapse doar pentru Weekly Summary (subsecțiune), NU pentru stats cardurile
---
### 21:09 - Fix Manual Stats Collapse
**Marius:** "Fix manual și oprește workflow"
**Actions:**
1. ✅ Oprit antfarm dashboard (`node antfarm/dist/cli/cli.js dashboard stop`)
2. ✅ Manual fix în `dashboard/habits.html`:
- Adăugat `.stats-header` cu chevron clickable
- Wrap stats-row + weekly-summary în `.stats-content` colapsabil
- CSS pentru header, chevron, și animations
- JS: `toggleStats()` + `restoreStatsState()` funcții
- localStorage persist pentru user preference
3. ✅ Git commit: `fix: Stats section collapse header + content (manual fix)`
4. ✅ Restart server dashboard (PID: 31702)
**Fix complet:**
```html
<div class="stats-section">
<div class="stats-header" onclick="toggleStats()">
<h3>Stats</h3>
<chevron>
</div>
<div class="stats-content" id="statsContent">
[stats-row + weekly-summary - colapsabile]
</div>
</div>
```
**Status final:**
- Branch: `feature/habit-tracker`
- Commits: 15 (workflow 1) + 7 (workflow 2) + 1 (manual fix) = 23 commits
- Antfarm workflow: stopped
- Server dashboard: running (PID 31702)
---
## Lecții Session 3
**Ce a funcționat:**
- ✅ Discovery cu 7 întrebări → PRD refinements precis
- ✅ Workflow rapid pentru refinements (7/8 stories în ~1h)
- ✅ Identificare rapidă bug (stats collapse incomplet)
**Ce NU a funcționat:**
- ❌ Developer blocat >1h pe US-008 (tests) fără progres
- ❌ US-005 (stats collapse) implementat INCOMPLET (doar subsecțiune, nu tot)
- ❌ Browser cache face debugging confuz
**Învățături:**
- Workflow-uri lungi (>1h pe un story) → intervine manual sau fail/retry
- Acceptance criteria trebuie MAI SPECIFICE pentru a evita interpretări greșite
- Fix manual > așteptat retry când bug-ul e clar și simplu
---
## YouTube Playlist - Trading Basics (23:01)
**Request:** Marius vrea să parcurg fiecare video din playlist, să descarc subtitrarea, și să fac proiect distinct în kb pentru a înțelege esențialul despre trading.
**Playlist URL:** https://youtube.com/playlist?list=PLQ4pOucwalxKioNbHnK-n6wszDiAl-AiX
**Acțiuni:**
1. ✅ Verificat playlist - ~20 videouri despre trading
2. ✅ Testat download subtitrări pe 3 videouri:
- Video 1: NU are subtitrări
- Video 2: NU are subtitrări
- Video 3 (EPISODUL 38): ✅ ARE subtitrări
3. ✅ Salvat primul video manual în `memory/kb/projects/trading-basics/01-episodul-38-formula-trading.md`
4. ✅ Programat restul playlist-ului (18 videouri) pentru **night-execute (10->11 feb, 23:00)**
5. ✅ Actualizat `memory/approved-tasks.md` cu task-ul
6. ✅ Actualizat KB index (200 notes total)
**Video procesat: EPISODUL 38 - Formula MAPS**
- **Durată:** 31:10
- **Concept principal:** Formula MAPS = Model (pattern) + Acțiune (trigger) + Plan (profit/loss) + Sumă (position size)
- **Exemple:** Strategie investiții 20 ani ($3,318 → $53,000) + strategie scalping 5 min (win rate 80%)
- **Key insight:** "Nu strategia e problema, ci lipsa unei formule clare care să lege toate deciziile"
- **Tags:** @work @trading @strategie @maps @investitii
**Link salvat:** https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/projects/trading-basics/01-episodul-38-formula-trading.md
**Next:** Night-execute va procesa restul videoclipurilor (doar cele cu subtitrări disponibile)

View File

@@ -1,16 +0,0 @@
# 2026-02-11
## ANAF Monitor - Eroare Dublare Muncă
**Cerere:** Marius via Discord #echo-work - dashboard arăta doar data ultimei verificării, nu modificările detectate
**Greșeală:** Am implementat din nou ceva ce era DEJA făcut în commit c7bea57 (10 feb)
- Modificarea era deja completă: monitor_v2.py + dashboard/index.html
- Folosea câmpul `changes` (nu `details` cum am pus eu)
- Commit greșit: 3adc775
**Rezolvare:**
- Revert la implementarea corectă din c7bea57
- Commit 1c3971f - restaurare
**Lecție:** Verific ÎNTÂI în git history înainte să implementez ceva!

View File

@@ -1,28 +0,0 @@
# 2026-02-12
## Dashboard Fix - Dropdown Dark Mode
- **Problem:** Dropdown items (select/option) au text alb pe fundal alb în dark mode
- **Cauză:** `<option>` primește implicit background alb de la browser, dar `.input` avea background translucid
- **Fix:** Adăugat în `dashboard/common.css`:
```css
select.input {
background: var(--bg-elevated);
}
select.input option {
background: var(--bg-base);
color: var(--text-primary);
}
```
- **Commit:** 4500bfe - pushed la Gitea
## Cron Jobs WhatsApp Issue
- **Problem:** Marius primește pe WhatsApp mesaje de la exercise-snack-uri și confirmări automate
- **Cauză:**
1. Job-urile `exercise-snack-1`, `exercise-snack-2`, `exercise-snack-3` rulau pe **main session** → trimiteau în ultimul canal activ
2. Răspunsuri automate (YouTube links, confirmări) trimiteau în "ultimul canal activ" în loc să folosească reply la mesajul de origine
- **Fix aplicat:**
1. ✅ Mutat exercise-snack-uri pe isolated session cu target explicit Discord #echo-self
- `dde8d30c-6126-4e95-9372-eca6de769ac0` (exercise-snack-1)
- `9892a116-96e0-47e5-b86c-4be06e3f40e0` (exercise-snack-2)
- `c9df03f8-d0a7-4a16-b279-8b4a1251acda` (exercise-snack-3)
2. ✅ Actualizat AGENTS.md: folosesc `[[reply_to_current]]` pentru răspunsuri la mesaje directe (YouTube, tasks, etc.)

View File

@@ -1,18 +0,0 @@
# 2026-02-13
## Rate Limit Sonnet
- Sonnet atins limita, se resetează **luni 13 feb 8:59 AM** (greșit - de fapt luni 16 feb)
- Multiple joburi eșuate: morning-report, morning-coaching, evening-report, evening-coaching, daily-self-audit, insights-extract, exercise-snack-1, exercise-snack-3
- **Fix:** Am schimbat toate 6 joburile critice de pe `model: sonnet` pe `model: opus` temporar
- Luni când revine Sonnet → trebuie schimbate înapoi pe sonnet
## Joburi schimbate temporar pe Opus
- morning-report (906bf597)
- morning-coaching (95828a25)
- evening-report (b723a1cf)
- evening-coaching (ca26efdd)
- daily-self-audit (7f08d4ac)
- insights-extract (a036e891)
## Calendar
- 15:00 București: Sesiune coaching "Echilibrare căutare clienți noi (cu Echo)"

View File

@@ -1,323 +0,0 @@
# Approved Tasks
## ✅ Noapte 7->8 feb - COMPLETAT
**✅ Procesat:**
- 1 video YouTube: Monica Ion despre creșterea prețurilor
- Index actualizat: 140 note în kb/
---
## 🌙 Noaptea asta (8->9 feb, 23:00) - Tranșa 1 Monica Ion (40 articole)
### Articole Monica Ion - Friday Spark 178-139
- [x] https://monicaion.ro/friday-spark-178/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #178: Cele 7 Oglinzi Eseniene)
- [x] https://monicaion.ro/friday-spark-177/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #177: Primul retreat Bali)
- [x] https://monicaion.ro/friday-spark-176/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #176: Când religia nu mai explică)
- [x] https://monicaion.ro/friday-spark-175/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #175: Tiparele relații și bani)
- [x] https://monicaion.ro/friday-spark-174/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #174: 13 moduri Legea Dualității în business)
- [x] https://monicaion.ro/friday-spark-173/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #173: Pasajele de viață)
- [x] https://monicaion.ro/friday-spark-172/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #172: Priorități reale vs declarate)
- [x] https://monicaion.ro/friday-spark-171/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #171: Fractalul Coreei de Sud)
- [x] https://monicaion.ro/friday-spark-170/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #170: Claritatea din liniște - Mongolia)
- [x] https://monicaion.ro/friday-spark-169/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #169: Transformarea bărbatului 45-55 ani)
- [x] https://monicaion.ro/friday-spark-168-de-ce-ti-se-blocheaza-afacerea-si-ce-poti-sa-faci-tu-sa-iesi-din-blocaj/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #168: Blocaj afacere)
- [x] https://monicaion.ro/friday-spark-167/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #167: Traume financiare)
- [x] https://monicaion.ro/friday-spark-166/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #166: Conectare și semnificație)
- [x] https://monicaion.ro/friday-spark-165/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #165: De la "Știu" la "Trăiesc")
- [—] https://monicaion.ro/friday-spark-164/ → ⚠️ 404 NOT FOUND (nu există)
- [x] https://monicaion.ro/friday-spark-163/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #163: Anatomia nemulțumirii)
- [x] https://monicaion.ro/friday-spark-162/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #162: 3 salturi mentale antreprenori prosperi)
- [x] https://monicaion.ro/friday-spark-161/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #161: De la violență la vindecare)
- [x] https://monicaion.ro/friday-spark-160/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #160: 3 tipare femei relații abuzive)
- [x] https://monicaion.ro/friday-spark-159/ → ✅ 2026-02-09 (Batch 1 - Friday Spark #159: Frumusețe, pierdere, renaștere 45-50 ani)
- [x] https://monicaion.ro/friday-spark-158/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #158: 13 minciuni invizibile bărbați)
- [x] https://monicaion.ro/friday-spark-157/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #157: Ce cale de evoluție ai ales?)
- [x] https://monicaion.ro/fridayspark-156/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #156: 156 Spark-uri, 3 ani, o lumină)
- [x] https://monicaion.ro/friday-spark-155/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #155: Minciuni și adevăruri feminine)
- [x] https://monicaion.ro/friday-spark-154/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #154: 16 minciuni feminine)
- [x] https://monicaion.ro/friday-spark-153/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #153: 10 minciuni subtile)
- [x] https://monicaion.ro/friday-spark-152/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #152: 7 moduri încheiere relații)
- [x] https://monicaion.ro/friday-spark-151/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #151: 7 nivele conștiință - Misiunea)
- [x] https://monicaion.ro/friday-spark-150/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #150: Căderea din lumină - Judecata)
- [x] https://monicaion.ro/friday-spark-149/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #149: 6 cauze dependență suferință)
- [x] https://monicaion.ro/friday-spark-148/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #148: Atacuri de panică)
- [x] https://monicaion.ro/friday-spark-147/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #147: Pilot automat vs conectat)
- [x] https://monicaion.ro/friday-spark-146/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #146: Pasiune vs inspirație)
- [x] https://monicaion.ro/friday-spark-145/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #145: Cum te îmbolnăvește datoria)
- [x] https://monicaion.ro/friday-spark-144-cum-sa-iti-definesti-propriul-succes-fara-sa-te-lasi-prins-in-criteriile-din-social-media/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #144: Definiți succesul TĂU)
- [x] https://monicaion.ro/friday-spark-143-furia-in-business-6-cauze-emotionale-si-solutiile-care-te-echilibreaza/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #143: Furia în business - 6 cauze)
- [x] https://monicaion.ro/friday-spark-142/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #142: 3 stiluri procrastinare)
- [x] https://monicaion.ro/friday-spark-141/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #141: Ecuația Prosperității)
- [x] https://monicaion.ro/friday-spark-140/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #140: Controlezi banii sau ei te controlează?)
- [x] https://monicaion.ro/friday-spark-139/ → ✅ 2026-02-09 (Batch 2 - Friday Spark #139: De ce dezvoltarea personală NU funcționează)
**Destinație:** `memory/kb/projects/monica-ion/articole/friday-spark-XXX.md`
**Format:** TL;DR + Puncte cheie + Quote-uri + Tag-uri
**Model:** Sonnet (REGULĂ GENERALĂ: ORICE procesare conținut = Sonnet, nu doar Monica Ion)
**⚠️ IMPORTANT:** Sleep 3-5 secunde între fiecare articol (evită rate limiting)
**Workflow:**
1. **night-execute (23:00):** Extrage + salvează structurat (Sonnet)
2. **insights-extract (08:00, 19:00):** Analiză profundă + aplicații practice (Sonnet)
**Regula se aplică pentru:**
- YouTube (orice canal)
- Articole blog (Monica Ion, alți autori)
- Emailuri importante
- Orice extractie TL;DR + quote-uri + idei
---
## ✅ Noapte 11->12 feb (Tranșa 2) - COMPLETAT
### Articole Monica Ion - Friday Spark 138-99
- [x] https://monicaion.ro/friday-spark-138/ → ✅ 2026-02-12 (Teama de eșec financiar)
- [x] https://monicaion.ro/friday-spark-137/ → ✅ 2026-02-12 (9 greșeli în relație)
- [x] https://monicaion.ro/friday-spark-136/ → ✅ 2026-02-12 (Insecuritate emoțională)
- [x] https://monicaion.ro/friday-spark-135/ → ✅ 2026-02-12 (Relația cu timpul - 9 mituri)
- [x] https://monicaion.ro/friday-spark-134/ → ✅ 2026-02-12 (Susținere partener - 13 strategii)
- [x] https://monicaion.ro/friday-spark-133/ → ✅ 2026-02-12 (Pierdere identitate în relație)
- [x] https://monicaion.ro/friday-spark-132/ → ✅ 2026-02-12 (Tipare financiare - 10 întrebări)
- [x] https://monicaion.ro/friday-spark-131/ → ✅ 2026-02-12 (Cum să spui NU - 6 pași)
- [x] https://monicaion.ro/friday-spark-130/ → ✅ 2026-02-12 (An productiv - metoda 5 pași)
- [x] https://monicaion.ro/friday-spark-129/ → ✅ 2026-02-12 (Obiective fără furie)
- [x] https://monicaion.ro/friday-spark-128/ → ✅ 2026-02-12 (Încredere sine neclintit)
- [x] https://monicaion.ro/friday-spark-127/ → ✅ 2026-02-12 (Închei anul cu claritate)
- [x] https://monicaion.ro/friday-spark-126/ → ✅ 2026-02-12 (Sărbători luminoase)
- [x] https://monicaion.ro/friday-spark-125/ → ✅ 2026-02-12 (Scapi de migrenă)
- [x] https://monicaion.ro/friday-spark-124/ → ✅ 2026-02-12 (Decision Fatigue)
- [x] https://monicaion.ro/friday-spark-123/ → ✅ 2026-02-12 (Convingeri Limitative)
- [x] https://monicaion.ro/friday-spark-122/ → ✅ 2026-02-12 (Tipare emoționale relații)
- [x] https://monicaion.ro/friday-spark-121/ → ✅ 2026-02-12 (Două greșeli majore)
- [x] https://monicaion.ro/friday-spark-120/ → ✅ 2026-02-12 (Frustrare - 5 cauze)
- [x] https://monicaion.ro/friday-spark-119/ → ✅ 2026-02-12 (Regăsire - Laos)
- [x] https://monicaion.ro/friday-spark-118/ → ✅ 2026-02-12 (Tipare emoționale)
- [x] https://monicaion.ro/friday-spark-117/ → ✅ 2026-02-12 (Autenticitate)
- [x] https://monicaion.ro/friday-spark-116/ → ✅ 2026-02-12 (Coaching transformațional)
- [x] https://monicaion.ro/friday-spark-115/ → ✅ 2026-02-12 (Bani și spiritualitate)
- [x] https://monicaion.ro/friday-spark-114/ → ✅ 2026-02-12 (Transformare profundă)
- [x] https://monicaion.ro/friday-spark-113/ → ✅ 2026-02-12 (Relații toxice)
- [x] https://monicaion.ro/friday-spark-112/ → ✅ 2026-02-12 (Încredere sine)
- [x] https://monicaion.ro/friday-spark-111/ → ✅ 2026-02-12 (Putere personală)
- [x] https://monicaion.ro/friday-spark-110/ → ✅ 2026-02-12 (Eșec și succes)
- [x] https://monicaion.ro/friday-spark-109/ → ✅ 2026-02-12 (Banii nu sunt importanți - 8 nivele)
- [x] https://monicaion.ro/friday-spark-108/ → ✅ 2026-02-12 (Putere personală - 7 nivele)
- [x] https://monicaion.ro/friday-spark-107/ → ✅ 2026-02-12 (Cauzalitate vs manifestare)
- [x] https://monicaion.ro/friday-spark-106/ → ✅ 2026-02-12 (Programări familiale)
- [x] https://monicaion.ro/friday-spark-105/ → ✅ 2026-02-12 (Iubirea care transcende)
- [x] https://monicaion.ro/friday-spark-104-mancatul-emotional/ → ✅ 2026-02-12 (Mâncatul emoțional)
- [x] https://monicaion.ro/friday-spark-102-despre-performanta-si-alegeri-in-business-interviu-de-la-suflet-la-suflet-cu-diana-crisan/ → ✅ 2026-02-12 (Interviu Diana Crișan)
- [x] https://monicaion.ro/friday-spark-102/ → ✅ 2026-02-12 (Încredere în intuiție)
- [x] https://monicaion.ro/friday-spark-101/ → ✅ 2026-02-12 (7 Legi Universale)
- [x] https://monicaion.ro/spark-aniversar-100/ → ✅ 2026-02-12 (Spark 100 - generația Z)
- [—] https://monicaion.ro/friday-spark-99/ → ⚠️ 404 NOT FOUND (nu există)
**Status:** ✅ COMPLETAT 2026-02-12 02:15
**Articole procesate:** 39 cu succes + 1 marcat 404
**Index actualizat:** 294 note în total
---
## ✅ Noapte 11->12 feb - COMPLETAT
### YouTube Trading - Procesare RAW → Structurat (39 videouri)
**Status descărcare:** ✅ COMPLETAT 2026-02-11 03:55
**Status procesare:** ✅ COMPLETAT 2026-02-11 23:00
- Toate 39 videouri deja procesate cu format structurat
- 5 duplicate cu nume corupte mutate în _duplicates/
- Ep38 header standardizat
- Index actualizat: 261 note
**TASK ACTUAL:** ~~Procesare RAW → Format structurat~~ DONE
**Format NECESAR (vezi memory/kb/youtube/ pentru exemple):**
```markdown
# Titlu Video
**Video:** URL YouTube
**Duration:** MM:SS
**Saved:** 2026-02-11
**Tags:** #trading #strategie @work
---
## 📋 TL;DR
[Sumar 2-3 propoziții - ESENȚA videoclipului]
---
## 🎯 Concepte Principale
### Concept 1
- Punct cheie
- Detalii relevante
### Concept 2
- etc.
---
## 💡 Quote-uri Importante
> "Quote relevant 1"
> "Quote relevant 2"
---
## ✅ Aplicații Practice / Acțiuni
- [ ] Acțiune concretă 1
- [ ] Acțiune concretă 2
```
**PROCESARE:**
- Model: **Sonnet** (OBLIGATORIU pentru procesare conținut)
- Pentru fiecare fișier .md din trading-basics/:
1. Citește transcript RAW
2. Procesează cu Sonnet → TL;DR + Concepte + Quote-uri + Aplicații
3. Salvează în același fișier (suprascrie)
- Sleep 2-3s între fiecare (evită rate limit)
**Estimare:** ~2-3h pentru 39 videouri (Sonnet procesare calitate)
---
## 📅 Programat (10->11 feb, 23:00) - YouTube Trading + Monica Ion Tranșa 3
### ✅ YouTube Playlist - Trading Basics - DESCĂRCAT
**Status:** Subtitrări descărcate 2026-02-11 03:55
- 39 videouri cu subtitrări salvate
- Procesare structurată → programată pentru 11->12 feb (vezi mai sus)
---
## 📅 Programat Tranșa 3 (12->13 feb, 23:00) - 40 articole
### Articole Monica Ion - Friday Spark 98-59
- [ ] https://monicaion.ro/friday-spark-98/
- [ ] https://monicaion.ro/friday-spark-97/
- [ ] https://monicaion.ro/friday-spark-96/
- [ ] https://monicaion.ro/friday-spark-95/
- [ ] https://monicaion.ro/friday-spark-94/
- [ ] https://monicaion.ro/friday-spark-93/
- [ ] https://monicaion.ro/friday-spark-92/
- [ ] https://monicaion.ro/friday-spark-91/
- [ ] https://monicaion.ro/friday-spark-90/
- [ ] https://monicaion.ro/friday-spark-89/
- [ ] https://monicaion.ro/friday-spark-88/
- [ ] https://monicaion.ro/friday-spark-87/
- [ ] https://monicaion.ro/friday-spark-86/
- [ ] https://monicaion.ro/friday-spark-85/
- [ ] https://monicaion.ro/friday-spark-84/
- [ ] https://monicaion.ro/friday-spark-83/
- [ ] https://monicaion.ro/friday-spark-82/
- [ ] https://monicaion.ro/friday-spark-81/
- [ ] https://monicaion.ro/friday-spark-80/
- [ ] https://monicaion.ro/friday-spark-79/
- [ ] https://monicaion.ro/friday-spark-78/
- [ ] https://monicaion.ro/friday-spark-77/
- [ ] https://monicaion.ro/friday-spark-76/
- [ ] https://monicaion.ro/friday-spark-75/
- [ ] https://monicaion.ro/friday-spark-74/
- [ ] https://monicaion.ro/friday-spark-73/
- [ ] https://monicaion.ro/friday-spark-72/
- [ ] https://monicaion.ro/friday-spark-71/
- [ ] https://monicaion.ro/friday-spark-70/
- [ ] https://monicaion.ro/friday-spark-69/
- [ ] https://monicaion.ro/friday-spark-68/
- [ ] https://monicaion.ro/friday-spark-67/
- [ ] https://monicaion.ro/friday-spark-66/
- [ ] https://monicaion.ro/friday-spark-65/
- [ ] https://monicaion.ro/friday-spark-64/
- [ ] https://monicaion.ro/friday-spark-63/
- [ ] https://monicaion.ro/friday-spark-62/
- [ ] https://monicaion.ro/friday-spark-61/
- [ ] https://monicaion.ro/friday-spark-60/
- [ ] https://monicaion.ro/friday-spark-59/
---
## 📅 Programat Tranșa 4 (13->14 feb, 23:00) - 40 articole
### Articole Monica Ion - Friday Spark 58-19
- [ ] https://monicaion.ro/friday-spark-58/
- [ ] https://monicaion.ro/friday-spark-57/
- [ ] https://monicaion.ro/friday-spark-56/
- [ ] https://monicaion.ro/friday-spark-55/
- [ ] https://monicaion.ro/friday-spark-54/
- [ ] https://monicaion.ro/friday-spark-53/
- [ ] https://monicaion.ro/friday-spark-52/
- [ ] https://monicaion.ro/friday-spark-51/
- [ ] https://monicaion.ro/friday-spark-50/
- [ ] https://monicaion.ro/friday-spark-49/
- [ ] https://monicaion.ro/friday-spark-48/
- [ ] https://monicaion.ro/friday-spark-47/
- [ ] https://monicaion.ro/friday-spark-46/
- [ ] https://monicaion.ro/friday-spark-45/
- [ ] https://monicaion.ro/friday-spark-44/
- [ ] https://monicaion.ro/friday-spark-43/
- [ ] https://monicaion.ro/friday-spark-42/
- [ ] https://monicaion.ro/friday-spark-41/
- [ ] https://monicaion.ro/friday-spark-40/
- [ ] https://monicaion.ro/friday-spark-39/
- [ ] https://monicaion.ro/friday-spark-38/
- [ ] https://monicaion.ro/friday-spark-37/
- [ ] https://monicaion.ro/friday-spark-36/
- [ ] https://monicaion.ro/friday-spark-35/
- [ ] https://monicaion.ro/friday-spark-34/
- [ ] https://monicaion.ro/friday-spark-33/
- [ ] https://monicaion.ro/friday-spark-32/
- [ ] https://monicaion.ro/friday-spark-31/
- [ ] https://monicaion.ro/friday-spark-30/
- [ ] https://monicaion.ro/friday-spark-29/
- [ ] https://monicaion.ro/friday-spark-28/
- [ ] https://monicaion.ro/friday-spark-27/
- [ ] https://monicaion.ro/friday-spark-26/
- [ ] https://monicaion.ro/friday-spark-25/
- [ ] https://monicaion.ro/friday-spark-24/
- [ ] https://monicaion.ro/friday-spark-23/
- [ ] https://monicaion.ro/friday-spark-22/
- [ ] https://monicaion.ro/friday-spark-21/
- [ ] https://monicaion.ro/friday-spark-20/
- [ ] https://monicaion.ro/friday-spark-19/
---
## 📅 Programat Tranșa 5 (14->15 feb, 23:00) - 18 articole
### Articole Monica Ion - Friday Spark 18-1
- [ ] https://monicaion.ro/friday-spark-18/
- [ ] https://monicaion.ro/friday-spark-17/
- [ ] https://monicaion.ro/friday-spark-16/
- [ ] https://monicaion.ro/friday-spark-15/
- [ ] https://monicaion.ro/friday-spark-14/
- [ ] https://monicaion.ro/friday-spark-13/
- [ ] https://monicaion.ro/friday-spark-12/
- [ ] https://monicaion.ro/friday-spark-11/
- [ ] https://monicaion.ro/friday-spark-10/
- [ ] https://monicaion.ro/friday-spark-9/
- [ ] https://monicaion.ro/friday-spark-8/
- [ ] https://monicaion.ro/friday-spark-7/
- [ ] https://monicaion.ro/friday-spark-6/
- [ ] https://monicaion.ro/friday-spark-5/
- [ ] https://monicaion.ro/friday-spark-4/
- [ ] https://monicaion.ro/friday-spark-3/
- [ ] https://monicaion.ro/friday-spark-2/
- [ ] https://monicaion.ro/friday-spark-1/
---
## ✅ Noapte 7 feb - SUCCESS
### ANALIZA LEAD SYSTEM (Opus)
- [x] Analizat: articol cold email + insight + sistem curent + clienți existenți
→ ✅ PROCESAT: 2026-02-07
→ Notă: memory/kb/insights/2026-02-06-lead-system-analysis.md
### YouTube - Monica Ion Povestea lui Marc ep5
- [x] https://youtu.be/vkRGAMD1AgQ
→ ✅ PROCESAT: 2026-02-07 03:00
→ Notă: memory/kb/youtube/2026-02-07_monica-ion-povestea-lui-marc-ep5-datorie-familie.md
→ Concept: Schimb echitabil - buclele deschise blochează oportunități

View File

@@ -1,16 +0,0 @@
# Approved Tasks - Night Execute (23:00 București)
## 2026-02-06 Noapte
### [ ] Monica Ion Blog - Tura 1 (20 articole)
- **Articole:** Spark #178-159
- **Output:** memory/kb/articole/monica-ion/
- **Update:** URL-LIST.md + index KB
- **Format:** TL;DR + Puncte Cheie + Quote-uri + Tag-uri
- **După finalizare:** Marchează [x] și raportează progress
---
**Note:**
- Fiecare tură = 20 articole
- Programare automată pentru nopțile următoare după finalizare

View File

@@ -1,14 +0,0 @@
{
"lastChecks": {
"agents_sync": "2026-02-04",
"email": 1770303600,
"calendar": 1770303600,
"git": 1770220800,
"kb_index": 1770303600
},
"notes": {
"2026-02-02": "15:00 UTC - Email OK (nimic nou). Cron jobs funcționale toată ziua.",
"2026-02-03": "12:00 UTC - Calendar: sesiune 15:00 alertată. Emailuri răspuns rapoarte în inbox (deja read).",
"2026-02-04": "06:00 UTC - Toate emailurile deja citite. KB index la zi. Upcoming: morning-report 08:30."
}
}

View File

@@ -1,40 +0,0 @@
# Jurnal - Drumul spre regăsirea motivației
---
## 📅 07 februarie 2025
### Context
Am primit invitație de la Alexandru Moldovan pentru tabăra de CNV din august. I-am răspuns că reevaluez ce mă motivează și o iau mai încet cu proiectele. Nu merg în tabără.
Alexandru mi-a sugerat: *"Grija cu motivația. Când ajungi la o concluzie, mi-ar place să aud cum a fost procesul prin care ți-ai regăsit-o."*
### Descoperiri
**Am fost mult timp (1-3 ani) în căutarea scopului și motivației.**
Una dintre lucrurile pe care le-am găsit: **să fac acțiuni** - lucrurile acelea din "coșul de frăguțe" care îmi aduc plăcere și pe care nu le mai făceam.
**Întrebarea cheie:** Cum să fiu motivat, fericit, împlinit dacă eu nu fac lucrurile care îmi aduc fericire, împlinire, motivație?
### Acțiuni concrete
**🏊 Bazin + 🧖 Saună**
- Am început să merg seara, deși era iarnă, frig, zăpadă, gheață
- La început mi-a fost greu - eram obosit, voiam să amân
- După câteva zile a început să-mi placă
- M-am obișnuit, dezmortesc/încălzesc mușchii
- Motivație suplimentară: durerea cronică cervicală (C6-C7, de un an)
**🤝 Grupul de sprijin**
- Mi-l doream de 3 ani
- Am mai încercat o dată, nu s-a legat (apel, contactat colegi vechi)
- Acum: am împărtășit ideea cu Raisa, m-a antrenat să continui
- Am făcut 2 întâlniri (joi, odată la 2 săptămâni)
- Sunt și facilitator și participant
- Încerc să echilibrez: să nu intervin prea mult în rol de facilitator, să nu "dau lecții"
- **Îmi aduce împlinire și energie**
### Insight
**Acțiunile mici de plăcere îmi readuc plăcerea și motivația.**

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

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

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