Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
RAPHBRAIN = Path.home() / "Documents" / "RaphBrain"
RUN_AGENT = Path.home() / "raphael" / "agents" / "run-agent.sh"
ROLES_DIR = Path.home() / "raphael" / "templates" / "roles"
CONFIG_PATH = Path.home() / "hermes" / "config" / "config.yaml"
_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder)
CONFIG_PATH = _BASE / "config" / "config.yaml"

# Used when config.yaml is missing or has no agent_models section
DEFAULT_MODEL_ROUTE = {"model": "sonnet", "max_turns": 60}
Expand Down
14 changes: 8 additions & 6 deletions config/config.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
hermes:
name: "Hermes"
session_prefix: "hermes"
log_path: "/Users/raphael_the_great-sage/hermes/logs/hermes.log"
engram:
name: "Engram"
session_prefix: "engram"
log_path: "/Users/raphael_the_great-sage/engram/logs/engram.log"

# Telegram is the only supported trigger (see hermes.py main()).
# Telegram is the only supported trigger (see engram.py main()).
trigger:
method: "telegram"

telegram:
# NOTE: keychain service stays "hermes-telegram-bot" — that's where the live
# bot token is stored; renaming it here would break token retrieval.
bot_token_keychain_service: "hermes-telegram-bot"
bot_token_keychain_account: "bot-token"
chat_id: 8922766986
trigger_prefix: "[HERMES]"
trigger_prefix: "[ENGRAM]"
poll_interval_seconds: 15

models:
Expand Down
43 changes: 26 additions & 17 deletions hermes.py → engram.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""
Hermes — persistent orchestration agent for Jaiden's Mac Mini.
Listens for commands, spawns Claude Code sessions via tmux, reports back.
Engram — persistent orchestration agent for Jaiden's Mac Mini (formerly "Hermes";
renamed to disambiguate from Nous Research's hermes-agent).
Listens for commands, dispatches Claude Code sub-agents, reports back.
"""

import subprocess
Expand All @@ -25,10 +26,11 @@
from project_registry import project_map_text, project_names, resolve as resolve_project
from scaffold_project import run_scaffold

CONFIG_PATH = Path.home() / "hermes" / "config" / "config.yaml"
_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder)
CONFIG_PATH = _BASE / "config" / "config.yaml"
STEP_MAX_RETRIES = 1 # max automatic retries per step (not applicable to rate-limited)
STEP_RETRY_DELAY_S = 10 # seconds to wait before retrying a failed step
LOG_PATH = Path.home() / "hermes" / "logs" / "hermes.log"
LOG_PATH = _BASE / "logs" / "hermes.log"

# Ensure logs directory exists before configuring file handler
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -307,15 +309,15 @@ def run_task(
`project` is echoed in the ack so a wrong route is visible immediately.
"""
if not session_name:
session_name = f"hermes-{int(time.time())}"
session_name = f"engram-{int(time.time())}"

log.info(f"Spawning direct task: {session_name}")
log.info(f"Task: {task[:120]}...")

task_file = Path.home() / "hermes" / "tasks" / f"{session_name}.md"
task_file = _BASE / "tasks" / f"{session_name}.md"
task_file.parent.mkdir(parents=True, exist_ok=True)
task_file.write_text(f"# Hermes Task\n\n{task}\n")
log_file = Path.home() / "hermes" / "logs" / f"{session_name}.log"
log_file = _BASE / "logs" / f"{session_name}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)

# Direct tasks default to sonnet — the classifier sometimes routes real work
Expand Down Expand Up @@ -448,7 +450,7 @@ def _append_pipeline_to_daily_note(
# Auto-drafted skills land here for review — NEVER written straight into
# ~/.claude/skills, which is globally active in every Claude Code session. Jaiden
# promotes a candidate by hand once he's read it.
SKILL_CANDIDATES_DIR = Path.home() / "hermes" / "skill-candidates"
SKILL_CANDIDATES_DIR = _BASE / "skill-candidates"


_PROGRESS_LOCK = threading.Lock()
Expand Down Expand Up @@ -771,7 +773,7 @@ def _finalize_step_failure(reason: str) -> None:
_send_reply(
hermes_config,
f"⚠️ Blocked on {role}: {reason}{handoff_line}\n\n"
f"Reply [HERMES] abort to stop, or send a corrected task to work around it.",
f"Reply [ENGRAM] abort to stop, or send a corrected task to work around it.",
)

final_status = engine.poll_step_completion(run_id, idx, task_id)
Expand Down Expand Up @@ -1102,7 +1104,7 @@ def orchestrate_task(task_text: str, config: dict) -> str:
return _resume_handoff(_cmd[len("resume") :].strip(), config)
if _cmd in ("help", "commands", "menu"):
return (
"Hermes commands:\n"
"Engram commands:\n"
"• on <project>, <task> — run in a project (e.g. `on alphabot, fix the rebalance bug`)\n"
"• <task> — I'll infer the project\n"
"• status — current run\n"
Expand Down Expand Up @@ -1153,7 +1155,7 @@ def orchestrate_task(task_text: str, config: dict) -> str:
except RuntimeError as exc:
# Another run is already active — queue not yet supported
log.warning(f"[orchestrate] create_run blocked: {exc}")
return f"⚠️ {exc}\nSend [HERMES] abort to cancel the current run first."
return f"⚠️ {exc}\nSend [ENGRAM] abort to cancel the current run first."

run_id = run["id"]
pipeline_summary = " → ".join(s.role for s in result.pipeline)
Expand Down Expand Up @@ -1186,13 +1188,16 @@ class TelegramPoller:
Replies via sendMessage API.
"""

STATE_FILE = Path.home() / "hermes" / "config" / "telegram_state.json"
STATE_FILE = _BASE / "config" / "telegram_state.json"

def __init__(self, config):
self.cfg = config["trigger"]["telegram"]
self.hermes_cfg = config
self.chat_id = self.cfg["chat_id"]
self.prefix = self.cfg.get("trigger_prefix", "[HERMES]")
# Accept the new [ENGRAM] prefix and the old [HERMES] one (muscle-memory) — both
# optional, since a bare message from the chat is also processed.
self.prefix = self.cfg.get("trigger_prefix", "[ENGRAM]")
self.prefixes = self.cfg.get("trigger_prefixes") or [self.prefix, "[ENGRAM]", "[HERMES]"]
self.token = keyring.get_password(
self.cfg["bot_token_keychain_service"],
self.cfg["bot_token_keychain_account"],
Expand Down Expand Up @@ -1238,7 +1243,11 @@ def poll(self):
if not text:
continue

task = text[len(self.prefix) :].strip() if text.startswith(self.prefix) else text
task = text
for pfx in self.prefixes:
if text.startswith(pfx):
task = text[len(pfx) :].strip()
break
self._journal(update["update_id"], task)
log.info(f"Telegram task: {task[:80]}...")
# Process off the poll loop: a slow classification (Ollama ~15-30s)
Expand Down Expand Up @@ -1270,7 +1279,7 @@ def _journal(self, update_id: int, task: str):
"""Append the raw message before processing so a daemon crash mid-task
leaves a trace instead of silently losing it (offset already advanced)."""
try:
jpath = Path.home() / "hermes" / "logs" / "telegram-journal.log"
jpath = _BASE / "logs" / "telegram-journal.log"
with jpath.open("a") as f:
f.write(f"{update_id}\t{task}\n")
except Exception:
Expand All @@ -1281,10 +1290,10 @@ def _reply(self, message: str):


def main():
log.info("=== Hermes starting up ===")
log.info("=== Engram starting up ===")
config = load_config()

subprocess.run([str(Path.home() / "hermes" / "scripts" / "check-connectivity.sh")])
subprocess.run([str(_BASE / "scripts" / "check-connectivity.sh")])

# Recover crash-orphaned runs ONCE here — never per-message (see RunEngine).
RunEngine().startup_recover_and_cleanup()
Expand Down
5 changes: 4 additions & 1 deletion project_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@

# Repos that don't live under ~/Projects/.
_EXTRA_REPOS = {
"hermes": Path.home() / "hermes",
# The daemon's own repo — resolves from this file's location so it survives the
# ~/hermes → ~/engram move. Aliased so "hermes" still routes here during transition.
"engram": Path(__file__).resolve().parent,
"raphael": Path.home() / "raphael",
}

Expand All @@ -48,6 +50,7 @@
# so rebrands / alt spellings collapse into one entry. Only for names that are
# genuinely the same project — never merge two distinct repos.
_ALIASES = {
"hermes": "engram", # renamed Hermes -> Engram (disambiguate from Nous hermes-agent)
"raphui": "raphael",
"raph": "raphael",
"mira": "vitre", # rebranded Mira -> Vitré
Expand Down
3 changes: 2 additions & 1 deletion run_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
# Constants
# ---------------------------------------------------------------------------

RUNS_DIR = Path.home() / "hermes" / "runs"
_BASE = Path(__file__).resolve().parent # repo dir — rename/move-safe (follows the folder)
RUNS_DIR = _BASE / "runs"
TASKS_DIR = Path.home() / "raphael" / "tasks"
STEP_POLL_INTERVAL_S = 5 # poll raphael task JSON every 5 seconds
STEP_TIMEOUT_S = 3600 # 60-min hard timeout per step
Expand Down
2 changes: 1 addition & 1 deletion scaffold_project.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
scaffold_project.py — Scaffold a new Hermes-managed project.

Triggered by: [HERMES] scaffold new project: <name>
Triggered by: [ENGRAM] scaffold new project: <name>
Creates:
- ~/Projects/<name>/ with src/, tests/, .github/workflows/ci.yml
- Standard files: CLAUDE.md, pyproject.toml, .pre-commit-config.yaml
Expand Down
14 changes: 7 additions & 7 deletions tests/test_daily_note.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
test_daily_note.py — unit tests for hermes._append_pipeline_to_daily_note.
test_daily_note.py — unit tests for engram._append_pipeline_to_daily_note.

Salvaged from feature/pipeline-daily-note-logging. Verifies the create-vs-append
branch and per-status formatting, with RAPHBRAIN_DAILY_DIR patched to a tmp dir
Expand All @@ -12,15 +12,15 @@
from pathlib import Path
from unittest.mock import patch

sys.path.insert(0, str(Path.home() / "hermes"))
import hermes # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import engram # noqa: E402


class TestDailyNote(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self._dir = Path(self._tmp.name) / "Daily"
self._p = patch.object(hermes, "RAPHBRAIN_DAILY_DIR", self._dir)
self._p = patch.object(engram, "RAPHBRAIN_DAILY_DIR", self._dir)
self._p.start()

def tearDown(self):
Expand All @@ -33,20 +33,20 @@ def _today(self):
return self._dir / f"{datetime.now().strftime('%Y-%m-%d')}.md"

def test_creates_note_then_appends(self):
hermes._append_pipeline_to_daily_note("arbiter", "feature/x", "done", "42m")
engram._append_pipeline_to_daily_note("arbiter", "feature/x", "done", "42m")
note = self._today()
self.assertTrue(note.exists())
body = note.read_text()
self.assertIn("✅ Hermes: `arbiter/feature/x` done in 42m", body)

# Second call appends, doesn't clobber.
hermes._append_pipeline_to_daily_note("hermes", "", "failed", "12m", failed_step="tester")
engram._append_pipeline_to_daily_note("hermes", "", "failed", "12m", failed_step="tester")
body = note.read_text()
self.assertIn("✅ Hermes: `arbiter/feature/x` done", body) # first line survived
self.assertIn("❌ Hermes: `hermes` failed at `tester` (12m)", body)

def test_unknown_status_writes_nothing(self):
hermes._append_pipeline_to_daily_note("p", "b", "running", "1m")
engram._append_pipeline_to_daily_note("p", "b", "running", "1m")
self.assertFalse(self._today().exists())


Expand Down
12 changes: 6 additions & 6 deletions tests/test_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from pathlib import Path
from unittest.mock import patch

sys.path.insert(0, str(Path.home() / "hermes"))
import hermes # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import engram # noqa: E402


class _FakeResp:
Expand All @@ -38,9 +38,9 @@ def test_note_records_the_given_cause(self):
fake = _FakeResp({"response": "## What Was Completed\n- ran the plan step"})
with (
patch("urllib.request.urlopen", return_value=fake),
patch.object(hermes.Path, "home", return_value=Path(self._tmp())),
patch.object(engram.Path, "home", return_value=Path(self._tmp())),
):
path = hermes.write_handoff_via_ollama(
path = engram.write_handoff_via_ollama(
task="get me some customers to warm call",
session_name="2026-07-11-000",
partial_output="some partial work",
Expand All @@ -55,9 +55,9 @@ def test_default_cause_is_rate_limit(self):
fake = _FakeResp({"response": "## What Was Completed\n- x"})
with (
patch("urllib.request.urlopen", return_value=fake),
patch.object(hermes.Path, "home", return_value=Path(self._tmp())),
patch.object(engram.Path, "home", return_value=Path(self._tmp())),
):
path = hermes.write_handoff_via_ollama("t", "s", "p")
path = engram.write_handoff_via_ollama("t", "s", "p")
self.assertIn("usage limit reached", Path(path).read_text())

def _tmp(self) -> str:
Expand Down
Loading
Loading