Skip to content

Commit 566a534

Browse files
committed
release: merge develop into main for v0.27.0
2 parents 9024d46 + 447e8e1 commit 566a534

53 files changed

Lines changed: 2469 additions & 186 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.27.0] - 2026-04-22
9+
10+
### Added
11+
12+
- **Frontend i18n — pt-BR, en-US, es** (#24) — `react-i18next` + three locale bundles with 539 structurally identical keys each (validated via AST walker). Sidebar, Setup wizard (every label + validation string, live-switches as the user picks a language on step 1), Login, Settings, and 25+ page headers (Overview, Agents, Skills, Memory, Heartbeats, Goals, Providers, Integrations, Backups, Issues, Audit, Costs, Roles, Reports, MemPalace, Systems, Templates, Scheduler, Routines, Tasks, Knowledge layout, Knowledge Settings, API Keys, Connections) now render in the workspace's chosen language. Resolution order: `workspace.language` (backend) → `localStorage.evo_lang``navigator.language``en-US` fallback. Legacy codes (`ptBR`, `pt_BR`, `pt`, `enUS`, `en_US`) normalize to canonical BCP-47 transparently on both frontend and backend.
13+
- **`dashboard/frontend/.npmrc`**`legacy-peer-deps=true` so `make dashboard-app` installs cleanly despite `i18next@24`/`react-i18next@15` declaring `typescript@^5` as peer while the frontend is on TS 6.
14+
15+
### Changed
16+
17+
- **Backend UTF-8 everywhere** — every Python I/O path that persists or reads user-facing content now uses explicit `encoding="utf-8"`: `workspace.yaml` + `CLAUDE.md` (auth_routes), `.env` editor (config), `routines.yaml` (goals, scheduler), `triggers.yaml`, `heartbeats.yaml`, ADW script docstring parsing, secret key file, port read, and Knowledge CLI env round-trip. Flask JSON responses emit real UTF-8 (`ensure_ascii = False`, `Content-Type: application/json; charset=utf-8`) instead of `\uXXXX` escapes. Accented content (`João`, `Leilões`) now survives on Windows + Docker slim (locale=C) without mangling.
18+
- **`settings.py``_normalize_language`** — transparent BCP-47 normalization on `GET` and `PUT /api/settings/workspace` so legacy `ptBR` in existing `workspace.yaml` promotes to `pt-BR` on read and canonicalizes on write. Alias lookup is case-insensitive (matches frontend's `/^ptBR$/i`).
19+
- **`setup.py`** — default language is now `pt-BR` (BCP-47) instead of legacy `ptBR`. Matches the canonical form used by the UI.
20+
- **`auth_routes._save_workspace_config`** — default language fallback changed from `"en"` to `"pt-BR"`, aligned with setup.py and frontend `DEFAULT_LOCALE`.
21+
22+
### Fixed
23+
24+
- **i18n resolver chain empty at runtime**`LanguageDetector` + `supportedLngs` + `nonExplicitSupportedLngs` + `load: 'currentOnly'` combination left `i18n.languages = []` even with resources and language correctly loaded, so `t()` and `exists()` returned raw keys. Resolve the locale synchronously inline (localStorage → navigator.language → default) and pass it to `init({ lng })`. Drop `i18next-browser-languagedetector` — its job is now done inline.
25+
- **Scheduler — duplicate firings** — removed the `_run_scheduler` thread embedded in `app.py` that was running alongside the standalone `scheduler.py` process, causing every routine to fire 2-3× per trigger. Kept a lightweight `_poll_scheduled_tasks` thread for one-off `ScheduledTask` DB entries only.
26+
- **Scheduler — atomic PID lock** — replaced TOCTOU-prone check-then-create with `O_CREAT|O_EXCL` atomic open. Prevents multiple schedulers from starting simultaneously during rapid restarts (was causing `review-todoist` / `git-sync` to fire multiple times and send duplicate Telegram messages).
27+
- **Dashboard `restart-all`**`pkill` processes directly then re-run `start-services.sh` instead of `systemctl restart` (which on `Type=oneshot` + `KillMode=none` didn't reliably kill children). Works without sudo.
28+
- **Heartbeat prompt passing** — pass prompt as positional arg instead of `-p` flag. Claude CLI has no `-p` flag; the YAML frontmatter (`---`) was being interpreted as an unknown CLI option, failing all heartbeats with `unknown option '---\nname: "zara-cs"'`.
29+
- **`fin-daily-pulse`** — convert all Stripe amounts to BRL (USD/IDR→BRL via exchangerate-api with 5.75 fallback); fix churn to use `customer.subscription.deleted` events with full pagination; unify Telegram to a single `reply()` call per run.
30+
- **`prod-good-morning` / `prod-end-of-day`** — replace sub-skill calls (`/gog-email-triage`, `/prod-review-todoist`) with direct Gmail MCP / Todoist calls, eliminating 2× Telegram notifications per run.
31+
- **`pulse-faq-sync`** — explicit instruction to send exactly ONE Telegram per run.
32+
833
## [0.26.0] - 2026-04-22
934

1035
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.26.0",
3+
"version": "0.27.0",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/app.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@
2727
_key_file = WORKSPACE / "dashboard" / "data" / ".secret_key"
2828
_key_file.parent.mkdir(parents=True, exist_ok=True)
2929
if _key_file.exists():
30-
_secret_key = _key_file.read_text().strip()
30+
_secret_key = _key_file.read_text(encoding="utf-8").strip()
3131
else:
3232
_secret_key = secrets.token_hex(32)
33-
_key_file.write_text(_secret_key)
33+
_key_file.write_text(_secret_key, encoding="utf-8")
3434
_key_file.chmod(0o600)
3535

3636
app.secret_key = _secret_key
@@ -39,6 +39,20 @@
3939
app.config["REMEMBER_COOKIE_DURATION"] = timedelta(days=30)
4040
# SameSite=Strict prevents cross-origin cookie riding (CSRF defense layer 1).
4141
app.config["SESSION_COOKIE_SAMESITE"] = "Strict"
42+
43+
# --- JSON encoding for API responses ---
44+
# With ensure_ascii=True (Flask default) jsonify escapes every non-ASCII
45+
# character as \uXXXX. JSON parsers in the browser decode this correctly,
46+
# but it clutters network logs and occasionally breaks naive consumers that
47+
# look at raw bytes (e.g. grep over nginx access logs). Emit real UTF-8 so
48+
# accented content ("João", "Mirandas Leilões") stays readable end-to-end.
49+
try:
50+
app.json.ensure_ascii = False # type: ignore[attr-defined]
51+
app.json.mimetype = "application/json; charset=utf-8" # type: ignore[attr-defined]
52+
except AttributeError:
53+
# Flask <2.2 exposed this through app.config; keep compatibility.
54+
app.config["JSON_AS_ASCII"] = False
55+
4256
CORS(app, origins=["http://localhost:5173"], supports_credentials=True)
4357

4458
# --------------- Database ---------------
@@ -724,7 +738,7 @@ def serve_frontend(path):
724738
import yaml
725739
config_path = WORKSPACE / "config" / "workspace.yaml"
726740
if config_path.is_file():
727-
with open(config_path) as f:
741+
with open(config_path, encoding="utf-8") as f:
728742
cfg = yaml.safe_load(f)
729743
if cfg and cfg.get("port"):
730744
port = int(cfg["port"])

dashboard/backend/heartbeat_schema.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def load_heartbeats_yaml(path: Path | None = None) -> HeartbeatsFile:
9090
else:
9191
return HeartbeatsFile(heartbeats=[])
9292

93-
with open(path) as f:
93+
with open(path, encoding="utf-8") as f:
9494
raw = yaml.safe_load(f) or {}
9595

9696
return HeartbeatsFile.model_validate(raw)
@@ -112,7 +112,7 @@ def save_heartbeats_yaml(data: HeartbeatsFile, path: Path | None = None) -> None
112112
}
113113

114114
tmp_path = path.with_suffix(".yaml.tmp")
115-
with open(tmp_path, "w") as f:
115+
with open(tmp_path, "w", encoding="utf-8") as f:
116116
yaml.dump(raw, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
117117

118118
os.rename(tmp_path, path)

dashboard/backend/knowledge/cli.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,27 +34,36 @@ def _find_env_file() -> Path:
3434

3535

3636
def _read_env_var(env_path: Path, key: str) -> str:
37-
"""Return the value of *key* in *env_path*, or empty string if absent."""
37+
"""Return the value of *key* in *env_path*, or empty string if absent.
38+
39+
Reads with explicit UTF-8 so non-ASCII content (accented comments,
40+
names) survives on platforms where the default encoding is not UTF-8
41+
(Windows cp1252, Docker slim with locale=C, etc.).
42+
"""
3843
if not env_path.exists():
3944
return ""
40-
for line in env_path.read_text().splitlines():
45+
for line in env_path.read_text(encoding="utf-8").splitlines():
4146
stripped = line.strip()
4247
if stripped.startswith(f"{key}="):
4348
return stripped[len(key) + 1:].strip().strip('"').strip("'")
4449
return ""
4550

4651

4752
def _append_to_env(env_path: Path, key: str, value: str, comment: str = "") -> None:
48-
"""Append a KEY=value pair (with optional preceding comment) to *env_path*."""
53+
"""Append a KEY=value pair (with optional preceding comment) to *env_path*.
54+
55+
Always reads and writes as UTF-8 so existing accented content is
56+
preserved across the round-trip regardless of host locale.
57+
"""
4958
env_path.parent.mkdir(parents=True, exist_ok=True)
50-
content = env_path.read_text() if env_path.exists() else ""
59+
content = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
5160
# Ensure a trailing newline before appending
5261
if content and not content.endswith("\n"):
5362
content += "\n"
5463
if comment:
5564
content += "\n" + comment + "\n"
5665
content += f"{key}={value}\n"
57-
env_path.write_text(content)
66+
env_path.write_text(content, encoding="utf-8")
5867
try:
5968
env_path.chmod(0o600)
6069
except OSError:

dashboard/backend/routes/_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def _extract_agent_from_script(path: Path) -> str:
5656
"""Extract agent name from script docstring (pattern: 'via AgentName')."""
5757
try:
5858
# Read only first 5 lines to find docstring
59-
with open(path) as f:
59+
with open(path, encoding="utf-8") as f:
6060
head = "".join(f.readline() for _ in range(5))
6161
m = re.search(r"via\s+(\w+)", head, re.IGNORECASE)
6262
if m:
@@ -145,7 +145,7 @@ def discover_routines() -> dict:
145145
def _extract_name_from_script(path: Path) -> str:
146146
"""Extract human name from docstring (pattern: 'ADW: Name —')."""
147147
try:
148-
with open(path) as f:
148+
with open(path, encoding="utf-8") as f:
149149
head = "".join(f.readline() for _ in range(5))
150150
m = re.search(r'ADW:\s*(.+?)\s*[—–-]', head)
151151
if m:

dashboard/backend/routes/auth_routes.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,18 @@ def _save_workspace_config(ws: dict):
104104
"owner": ws.get("owner_name", ""),
105105
"company": ws.get("company_name", ""),
106106
"timezone": ws.get("timezone", "UTC"),
107-
"language": ws.get("language", "en"),
107+
"language": ws.get("language", "pt-BR"),
108108
},
109109
"agents": {a: True for a in ws.get("agents", [])},
110110
"integrations": {i: True for i in ws.get("integrations", [])},
111111
"dashboard": {"port": 8080},
112112
}
113113

114114
yaml_path = config_dir / "workspace.yaml"
115-
with open(yaml_path, "w") as f:
115+
# encoding="utf-8" is required — otherwise on Windows Python defaults to
116+
# cp1252 and mangles accented characters in owner/company names
117+
# (e.g. "João" becomes "Jo?o" on read).
118+
with open(yaml_path, "w", encoding="utf-8") as f:
116119
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
117120

118121
# Generate CLAUDE.md inline (no template needed)
@@ -126,7 +129,8 @@ def _save_workspace_config(ws: dict):
126129
f"**Name:** {ws['owner']}\n"
127130
f"**Company:** {ws['company']}\n"
128131
f"**Timezone:** {ws['timezone']}\n\n"
129-
f"## Language\n\nAlways respond in **{ws['language']}**.\n"
132+
f"## Language\n\nAlways respond in **{ws['language']}**.\n",
133+
encoding="utf-8",
130134
)
131135

132136
# Create workspace folders

dashboard/backend/routes/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def update_integration_env():
170170
if key not in updated_keys:
171171
new_lines.append(f"{key}={val}")
172172

173-
env_path.write_text("\n".join(new_lines) + "\n")
173+
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
174174

175175
# Reload dotenv in current process
176176
try:

dashboard/backend/routes/goals.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ def link_routine_to_goal():
450450
return jsonify({"error": "config/routines.yaml not found"}), 404
451451

452452
import yaml # type: ignore
453-
with open(routines_path, "r") as f:
453+
with open(routines_path, "r", encoding="utf-8") as f:
454454
config = yaml.safe_load(f) or {}
455455

456456
updated = False
@@ -468,7 +468,7 @@ def link_routine_to_goal():
468468
if not updated:
469469
return jsonify({"error": f"Routine '{routine_name}' not found in routines.yaml"}), 404
470470

471-
with open(routines_path, "w") as f:
471+
with open(routines_path, "w", encoding="utf-8") as f:
472472
yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
473473

474474
audit(current_user, "link", "goals", f"Linked routine '{routine_name}' to goal '{goal_slug}'")

dashboard/backend/routes/scheduler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def _load_yaml_routines(entries: list):
110110
return
111111

112112
try:
113-
with open(config_path) as f:
113+
with open(config_path, encoding="utf-8") as f:
114114
config = yaml.safe_load(f)
115115
if not config:
116116
return

0 commit comments

Comments
 (0)