From 81db44a45ad10b573236d799873e84d923581677 Mon Sep 17 00:00:00 2001 From: kimi Date: Sun, 22 Mar 2026 18:25:00 -0400 Subject: [PATCH 1/2] fix: scripts read GITEA_API from ~/.hermes/gitea_api file or env vars Fixes #951 The triage_score.py, loop_guard.py, and backfill_retro.py scripts were hardcoded to use 'http://localhost:3000/api/v1' as the default Gitea API URL. This caused the triage scorer to fail when Gitea is hosted on a remote VPS (143.198.27.163:3000). Changes: - Added _get_gitea_api() helper function to all three scripts - Priority order: TIMMY_GITEA_API env > GITEA_API env > ~/.hermes/gitea_api file > localhost default - Also made REPO_SLUG configurable via env var in backfill_retro.py (was hardcoded) This allows the scripts to work correctly without requiring environment variables to be exported by the calling shell script. --- scripts/backfill_retro.py | 19 +++++++++++++++++-- scripts/loop_guard.py | 17 ++++++++++++++++- scripts/triage_score.py | 18 +++++++++++++++++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/scripts/backfill_retro.py b/scripts/backfill_retro.py index 8bb26f48..dbfb6888 100644 --- a/scripts/backfill_retro.py +++ b/scripts/backfill_retro.py @@ -17,8 +17,23 @@ REPO_ROOT = Path(__file__).resolve().parent.parent RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl" SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json" -GITEA_API = "http://localhost:3000/api/v1" -REPO_SLUG = "rockachopa/Timmy-time-dashboard" + +def _get_gitea_api() -> str: + """Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default.""" + # Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility) + api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API") + if api_url: + return api_url + # Check ~/.hermes/gitea_api file + api_file = Path.home() / ".hermes" / "gitea_api" + if api_file.exists(): + return api_file.read_text().strip() + # Default fallback + return "http://localhost:3000/api/v1" + + +GITEA_API = _get_gitea_api() +REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" TAG_RE = re.compile(r"\[([^\]]+)\]") diff --git a/scripts/loop_guard.py b/scripts/loop_guard.py index b6bad133..0da2221a 100644 --- a/scripts/loop_guard.py +++ b/scripts/loop_guard.py @@ -30,7 +30,22 @@ IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json" CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json" TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" -GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") + +def _get_gitea_api() -> str: + """Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default.""" + # Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility) + api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API") + if api_url: + return api_url + # Check ~/.hermes/gitea_api file + api_file = Path.home() / ".hermes" / "gitea_api" + if api_file.exists(): + return api_file.read_text().strip() + # Default fallback + return "http://localhost:3000/api/v1" + + +GITEA_API = _get_gitea_api() REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") # Default cycle duration in seconds (5 min); stale threshold = 2× this diff --git a/scripts/triage_score.py b/scripts/triage_score.py index 7d29b599..1f475176 100644 --- a/scripts/triage_score.py +++ b/scripts/triage_score.py @@ -20,7 +20,23 @@ from datetime import datetime, timezone from pathlib import Path # ── Config ────────────────────────────────────────────────────────────── -GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") + + +def _get_gitea_api() -> str: + """Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default.""" + # Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility) + api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API") + if api_url: + return api_url + # Check ~/.hermes/gitea_api file + api_file = Path.home() / ".hermes" / "gitea_api" + if api_file.exists(): + return api_file.read_text().strip() + # Default fallback + return "http://localhost:3000/api/v1" + + +GITEA_API = _get_gitea_api() REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" REPO_ROOT = Path(__file__).resolve().parent.parent -- 2.43.0 From 3418f28789830884e750d1e66f5f28f43a6eee96 Mon Sep 17 00:00:00 2001 From: kimi Date: Sun, 22 Mar 2026 18:26:49 -0400 Subject: [PATCH 2/2] chore: fix pre-existing lint errors in world/ module Fix B027 errors (empty methods without @abstractmethod) by adding noqa comments since these methods intentionally have default no-op implementations. Fix UP042 error by changing ActionStatus to inherit from StrEnum instead of str, Enum. --- src/infrastructure/world/adapters/mock.py | 18 +++++++------- src/infrastructure/world/adapters/tes3mp.py | 16 ++++--------- src/infrastructure/world/interface.py | 4 ++-- src/infrastructure/world/types.py | 4 ++-- src/loop/heartbeat.py | 25 ++++++++++++-------- tests/infrastructure/world/test_interface.py | 1 - tests/infrastructure/world/test_registry.py | 1 - tests/loop/test_heartbeat.py | 6 ++--- 8 files changed, 34 insertions(+), 41 deletions(-) diff --git a/src/infrastructure/world/adapters/mock.py b/src/infrastructure/world/adapters/mock.py index 5c6f3bd6..14152e66 100644 --- a/src/infrastructure/world/adapters/mock.py +++ b/src/infrastructure/world/adapters/mock.py @@ -7,7 +7,7 @@ without a running game server. from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import UTC, datetime from infrastructure.world.interface import WorldInterface @@ -81,9 +81,7 @@ class MockWorldAdapter(WorldInterface): def act(self, command: CommandInput) -> ActionResult: logger.debug("MockWorldAdapter.act(%s)", command.action) - self.action_log.append( - _ActionLog(command=command, timestamp=datetime.now(UTC)) - ) + self.action_log.append(_ActionLog(command=command, timestamp=datetime.now(UTC))) return ActionResult( status=ActionStatus.SUCCESS, message=f"Mock executed: {command.action}", @@ -92,8 +90,10 @@ class MockWorldAdapter(WorldInterface): def speak(self, message: str, target: str | None = None) -> None: logger.debug("MockWorldAdapter.speak(%r, target=%r)", message, target) - self.speech_log.append({ - "message": message, - "target": target, - "timestamp": datetime.now(UTC).isoformat(), - }) + self.speech_log.append( + { + "message": message, + "target": target, + "timestamp": datetime.now(UTC).isoformat(), + } + ) diff --git a/src/infrastructure/world/adapters/tes3mp.py b/src/infrastructure/world/adapters/tes3mp.py index 955b04ac..37968ccb 100644 --- a/src/infrastructure/world/adapters/tes3mp.py +++ b/src/infrastructure/world/adapters/tes3mp.py @@ -35,14 +35,10 @@ class TES3MPWorldAdapter(WorldInterface): # -- lifecycle --------------------------------------------------------- def connect(self) -> None: - raise NotImplementedError( - "TES3MPWorldAdapter.connect() — wire up TES3MP server socket" - ) + raise NotImplementedError("TES3MPWorldAdapter.connect() — wire up TES3MP server socket") def disconnect(self) -> None: - raise NotImplementedError( - "TES3MPWorldAdapter.disconnect() — close TES3MP server socket" - ) + raise NotImplementedError("TES3MPWorldAdapter.disconnect() — close TES3MP server socket") @property def is_connected(self) -> bool: @@ -51,9 +47,7 @@ class TES3MPWorldAdapter(WorldInterface): # -- core contract (stubs) --------------------------------------------- def observe(self) -> PerceptionOutput: - raise NotImplementedError( - "TES3MPWorldAdapter.observe() — poll TES3MP for player/NPC state" - ) + raise NotImplementedError("TES3MPWorldAdapter.observe() — poll TES3MP for player/NPC state") def act(self, command: CommandInput) -> ActionResult: raise NotImplementedError( @@ -61,6 +55,4 @@ class TES3MPWorldAdapter(WorldInterface): ) def speak(self, message: str, target: str | None = None) -> None: - raise NotImplementedError( - "TES3MPWorldAdapter.speak() — send chat message via TES3MP" - ) + raise NotImplementedError("TES3MPWorldAdapter.speak() — send chat message via TES3MP") diff --git a/src/infrastructure/world/interface.py b/src/infrastructure/world/interface.py index 0c5449b2..2e379c9b 100644 --- a/src/infrastructure/world/interface.py +++ b/src/infrastructure/world/interface.py @@ -27,14 +27,14 @@ class WorldInterface(ABC): # -- lifecycle (optional overrides) ------------------------------------ - def connect(self) -> None: + def connect(self) -> None: # noqa: B027 """Establish connection to the game world. Default implementation is a no-op. Override to open sockets, authenticate, etc. """ - def disconnect(self) -> None: + def disconnect(self) -> None: # noqa: B027 """Tear down the connection. Default implementation is a no-op. diff --git a/src/infrastructure/world/types.py b/src/infrastructure/world/types.py index 5301407d..479093e1 100644 --- a/src/infrastructure/world/types.py +++ b/src/infrastructure/world/types.py @@ -10,10 +10,10 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum +from enum import StrEnum -class ActionStatus(str, Enum): +class ActionStatus(StrEnum): """Outcome of an action dispatched to the world.""" SUCCESS = "success" diff --git a/src/loop/heartbeat.py b/src/loop/heartbeat.py index 7da5a9d8..af6810f5 100644 --- a/src/loop/heartbeat.py +++ b/src/loop/heartbeat.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio import logging import time -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from datetime import UTC, datetime from loop.phase1_gather import gather @@ -32,6 +32,7 @@ logger = logging.getLogger(__name__) # Cycle log entry # --------------------------------------------------------------------------- + @dataclass class CycleRecord: """One observe → reason → act → reflect cycle.""" @@ -50,6 +51,7 @@ class CycleRecord: # Heartbeat # --------------------------------------------------------------------------- + class Heartbeat: """Manages the recurring cognitive loop with optional world adapter. @@ -268,14 +270,17 @@ class Heartbeat: try: from infrastructure.ws_manager.handler import ws_manager - await ws_manager.broadcast("heartbeat.cycle", { - "cycle_id": record.cycle_id, - "timestamp": record.timestamp, - "action": record.action_taken, - "action_status": record.action_status, - "reasoning_summary": record.reasoning_summary[:300], - "observation": record.observation, - "duration_ms": record.duration_ms, - }) + await ws_manager.broadcast( + "heartbeat.cycle", + { + "cycle_id": record.cycle_id, + "timestamp": record.timestamp, + "action": record.action_taken, + "action_status": record.action_status, + "reasoning_summary": record.reasoning_summary[:300], + "observation": record.observation, + "duration_ms": record.duration_ms, + }, + ) except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc: logger.debug("Heartbeat broadcast skipped: %s", exc) diff --git a/tests/infrastructure/world/test_interface.py b/tests/infrastructure/world/test_interface.py index e68c859c..4878b014 100644 --- a/tests/infrastructure/world/test_interface.py +++ b/tests/infrastructure/world/test_interface.py @@ -10,7 +10,6 @@ from infrastructure.world.types import ( PerceptionOutput, ) - # --------------------------------------------------------------------------- # Type construction # --------------------------------------------------------------------------- diff --git a/tests/infrastructure/world/test_registry.py b/tests/infrastructure/world/test_registry.py index 4323b725..3b8d3f4d 100644 --- a/tests/infrastructure/world/test_registry.py +++ b/tests/infrastructure/world/test_registry.py @@ -3,7 +3,6 @@ import pytest from infrastructure.world.adapters.mock import MockWorldAdapter -from infrastructure.world.interface import WorldInterface from infrastructure.world.registry import AdapterRegistry diff --git a/tests/loop/test_heartbeat.py b/tests/loop/test_heartbeat.py index f7c1734a..07d90547 100644 --- a/tests/loop/test_heartbeat.py +++ b/tests/loop/test_heartbeat.py @@ -6,7 +6,6 @@ Acceptance criteria: - WebSocket broadcasts include current action and reasoning summary """ -import asyncio from unittest.mock import AsyncMock, patch import pytest @@ -81,6 +80,7 @@ class TestHeartbeatWithAdapter: @pytest.mark.asyncio async def test_on_cycle_callback(self, mock_adapter): received = [] + async def callback(record): received.append(record) @@ -145,9 +145,7 @@ class TestHeartbeatBroadcast: ) as mock_ws: mock_ws.broadcast = AsyncMock() # Patch the import inside heartbeat - with patch( - "infrastructure.ws_manager.handler.ws_manager" - ) as ws_mod: + with patch("infrastructure.ws_manager.handler.ws_manager") as ws_mod: ws_mod.broadcast = AsyncMock() hb = Heartbeat(world=mock_adapter) await hb.run_once() -- 2.43.0