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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ __pycache__/
logs/
runs/
tasks/
skill-candidates/
config/telegram_state.json
config/imessage_state.json
273 changes: 251 additions & 22 deletions hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import subprocess
import logging
import re
import time
import threading
import yaml
Expand Down Expand Up @@ -45,6 +46,33 @@ def load_config():
return yaml.safe_load(f)


def _ollama_generate(prompt: str, timeout: int = 120, model: str = "llama3.1:8b") -> str:
"""POST a prompt to the local Ollama server; return the response text ("" on any
failure). Shared by the resume-handoff and post-task-review writers — both distill
text locally so they cost zero Claude tokens."""
try:
import json as _json
import urllib.request as _urllib

payload = _json.dumps({"model": model, "prompt": prompt, "stream": False}).encode()
req = _urllib.Request(
"http://localhost:11434/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _urllib.urlopen(req, timeout=timeout) as resp:
return _json.loads(resp.read().decode()).get("response", "").strip()
except TimeoutError:
log.error(f"Ollama generate timed out after {timeout}s")
return ""
except (OSError, ValueError) as exc:
# OSError covers connect-refused AND HTTPError (e.g. model not pulled);
# ValueError covers a non-JSON body. Honor the "" -on-any-failure contract.
log.error(f"Ollama generate failed: {exc}")
return ""


def write_handoff_via_ollama(
task: str,
session_name: str,
Expand Down Expand Up @@ -86,24 +114,12 @@ def write_handoff_via_ollama(

Be specific and factual. Only include what is supported by the partial output above."""

try:
import json as _json
import urllib.request as _urllib

payload = _json.dumps({"model": "llama3.1:8b", "prompt": prompt, "stream": False}).encode()
req = _urllib.Request(
"http://localhost:11434/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _urllib.urlopen(req, timeout=120) as resp:
response_text = _json.loads(resp.read().decode()).get("response", "").strip()

if not response_text:
log.error("Ollama handoff generation returned empty response")
return ""
response_text = _ollama_generate(prompt, timeout=120)
if not response_text:
log.error("Ollama handoff generation returned empty response")
return ""

try:
handoff_dir = Path.home() / "Documents" / "RaphBrain" / "Projects" / "handoffs"
handoff_dir.mkdir(parents=True, exist_ok=True)
handoff_path = handoff_dir / f"{session_name}-handoff.md"
Expand All @@ -116,11 +132,8 @@ def write_handoff_via_ollama(
)
log.info(f"Handoff written: {handoff_path}")
return str(handoff_path)
except TimeoutError:
log.error("Ollama handoff timed out after 120s")
return ""
except OSError:
log.error("Ollama not found — is it installed?")
except OSError as exc:
log.error(f"Failed to write handoff file: {exc}")
return ""


Expand Down Expand Up @@ -292,6 +305,202 @@ def _append_pipeline_to_daily_note(
log.warning(f"[daily-note] Failed to write to daily note: {exc}")


RAPHBRAIN_PROJECTS_DIR = Path.home() / "Documents" / "RaphBrain" / "Projects"
HERMES_RUN_LOG_HEADER = "## Hermes Run Log"
# 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"


_PROGRESS_LOCK = threading.Lock()
_RUN_LOG_HEADER_RE = re.compile(r"(?m)^" + re.escape(HERMES_RUN_LOG_HEADER) + r"$")


def _prepend_under_runlog(project: str, entry: str) -> None:
"""Insert `entry` newest-first under the '## Hermes Run Log' header in the
project's Progress.md, creating the file + section if missing. Shared by the
deterministic run-note and the post-task learnings writer.

Held under a lock so the daemon's two writer threads (a slow post-task-review
thread and the next run's deterministic write) can't clobber each other's
read-modify-write. Cross-PROCESS writers (a `claude -p` sub-agent, Obsidian sync)
are out of scope — different sections, infrequent — and not synchronized here."""
# Registry is the source of truth for the vault folder — capitalize() misroutes
# hyphenated projects (finance-tracker → a Finance-tracker orphan nothing reads).
proj = resolve_project(project)
dir_name = (proj.raphbrain_dir if proj else None) or project.capitalize()
progress_path = RAPHBRAIN_PROJECTS_DIR / dir_name / "Progress.md"
with _PROGRESS_LOCK:
if progress_path.exists():
content = progress_path.read_text(encoding="utf-8")
m = _RUN_LOG_HEADER_RE.search(content) # line-anchored — not any prose match
if m:
end = m.end()
if content[end : end + 1] == "\n":
content = content[: end + 1] + entry + "\n" + content[end + 1 :]
else:
# Header is the file's last line with no trailing newline — insert
# one so it stays on its own line. Without this the entry glues on
# ("## Hermes Run Log### …") and the next run can't match the header,
# appending a SECOND section and breaking the single-section rule.
content = content[:end] + "\n" + entry + "\n" + content[end:]
else:
content = content.rstrip() + f"\n\n{HERMES_RUN_LOG_HEADER}\n{entry}"
progress_path.write_text(content, encoding="utf-8")
else:
progress_path.parent.mkdir(parents=True, exist_ok=True)
progress_path.write_text(
f"# {dir_name} — Progress\n\n{HERMES_RUN_LOG_HEADER}\n{entry}", encoding="utf-8"
)


def _append_pipeline_to_progress_note(
project: str,
branch: str,
final_status: str,
duration: str,
pr_url: str = "",
failed_step: str = "",
reason: str = "",
done_count: int = 0,
step_count: int = 0,
) -> None:
"""Deterministically record a run's outcome in the project's Progress.md.

Runs in Python on the completion path, so 'done' GUARANTEES a note exists — it
never depends on a sub-agent remembering to write one. Entries go under a
dedicated '## Hermes Run Log' section so human-curated sections are untouched."""
try:
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
emoji = {"done": "✅", "failed": "❌", "aborted": "🛑"}.get(final_status, "🏁")
lines = [f"### {stamp} — {emoji} {project}/{branch or '(no branch)'} — {final_status}"]
lines.append(
f"- {done_count}/{step_count} steps · {duration}" if step_count else f"- {duration}"
)
if pr_url:
lines.append(f"- PR: {pr_url}")
if final_status == "failed" and failed_step:
lines.append(f"- Failed at `{failed_step}`" + (f" — {reason}" if reason else ""))
_prepend_under_runlog(project, "\n".join(lines) + "\n")
log.info(f"[progress-note] Recorded {final_status} run for {project} in Progress.md")
except Exception as exc:
log.warning(f"[progress-note] Failed to write Progress.md: {exc}")


_POST_TASK_REVIEW_PROMPT = """You are reviewing a COMPLETED automation run to capture reusable knowledge for next time. Be factual — use ONLY what the notes below support.

ORIGINAL TASK: {task}
PROJECT: {project} BRANCH: {branch}

WHAT THE AGENTS PRODUCED (their handoff notes):
{context}

Respond with EXACTLY these two sections and nothing else:

## Learnings
2-4 short bullets: what worked, and any gotcha worth remembering. Specific and factual.

## Skill Candidate
DEFAULT TO NO_SKILL. Propose a skill ONLY if this run followed a genuinely reusable, repeatable procedure you would run again on a DIFFERENT project. One-off, project-specific, or "just did the task" work is NOT a skill — when in doubt, write exactly: NO_SKILL
If (and only if) it is truly reusable, output ONLY a skill in this exact format:
---
name: <kebab-case-name>
description: <one line under 60 chars>
---
<numbered step-by-step a future agent would follow to repeat this>
"""


def _split_review(text: str) -> tuple[str, str]:
"""Split an Ollama review into (learnings, skill_candidate). skill is "" unless the
section actually parses as a frontmatter skill — a paraphrased "No skill needed"
or plain prose must NOT stage a candidate (the model rarely emits the literal
NO_SKILL token, so the gate is 'looks like a skill', not 'absence of NO_SKILL')."""
skill = ""
pre = text
if "## Skill Candidate" in text:
pre, _, post = text.partition("## Skill Candidate")
post = post.strip()
if post.startswith("---") and "NO_SKILL" not in post.upper():
skill = post
learnings = pre.split("## Learnings", 1)[1].strip() if "## Learnings" in pre else pre.strip()
return learnings, skill


def _skill_name(skill_md: str) -> str:
"""Pull the kebab `name:` from a skill candidate's frontmatter, sanitized to a safe
bare filename ("" if none). Sanitizing is load-bearing security, not cosmetics: the
name comes from an 8B model fed agent notes, and it becomes a path — an unsanitized
`../../.claude/skills/x` or absolute path would escape the staging dir into the
globally-active skills folder, defeating the whole review gate."""
for line in skill_md.splitlines():
if line.strip().startswith("name:"):
raw = line.split("name:", 1)[1].strip().strip("\"'")
return re.sub(r"[^a-z0-9-]+", "-", raw.lower()).strip("-")[:60]
return ""


def spawn_post_task_review(engine, run_id: str, project: str, branch: str, hermes_config) -> None:
"""Nous-style 'learning': after a DONE run, distill it (local Ollama, free,
non-blocking) into a Learnings entry in Progress.md + an optional skill CANDIDATE
staged in ~/hermes/skill-candidates for review. Never self-installs a skill —
an 8B model's draft is a suggestion, not a globally-active skill.
ponytail: local model + human promote-gate; good enough for a draft."""

def _worker():
try:
run = engine.get_run(run_id)
task = run.get("task_raw", "") or run.get("task", "")
notes = []
for step in run.get("pipeline", []):
op = step.get("output_path")
if not op:
continue
note_path = Path.home() / "Documents" / "RaphBrain" / op
if note_path.exists():
notes.append(
f"### {step.get('role')}\n{note_path.read_text(encoding='utf-8')[:1500]}"
)
context = ("\n\n".join(notes))[:6000] or "(no step notes captured)"
out = _ollama_generate(
_POST_TASK_REVIEW_PROMPT.format(
task=task[:400], project=project, branch=branch, context=context
),
timeout=180,
)
if not out:
return
learnings, skill = _split_review(out)
if learnings:
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
_prepend_under_runlog(project, f"#### {stamp} — 🧠 Learnings\n{learnings}\n")
candidate_path = ""
# Stage ONLY a candidate that parses as a real skill with a safe name — no
# timestamp fallback. A nameless/prose blob is not a skill, so it never
# stages a garbage file or pings Jaiden on a plain successful run.
name = _skill_name(skill) if skill else ""
if skill and skill.startswith("---") and name:
SKILL_CANDIDATES_DIR.mkdir(parents=True, exist_ok=True)
candidate = SKILL_CANDIDATES_DIR / f"{name}.md"
# Extra belt: the resolved path must stay inside the staging dir.
if candidate.resolve().parent == SKILL_CANDIDATES_DIR.resolve():
candidate.write_text(skill + "\n", encoding="utf-8")
candidate_path = str(candidate)
_send_reply(
hermes_config,
f"🧠 {project}: drafted a skill candidate — review to promote:\n{candidate_path}",
)
log.info(
f"[post-task-review] {project}: learnings={'y' if learnings else 'n'} "
f"skill_candidate={'y' if candidate_path else 'n'}"
)
except Exception as exc:
log.warning(f"[post-task-review] failed for {project}: {exc}")

threading.Thread(target=_worker, daemon=True, name=f"hermes-review-{run_id}").start()


def _format_duration(started_iso: str, ended_iso: str) -> str:
"""Return a human-readable duration like '2m 14s'."""
try:
Expand Down Expand Up @@ -607,6 +816,26 @@ def _finalize_step_failure(reason: str) -> None:
_append_pipeline_to_daily_note(
project, branch, final_status, total_duration, failed_step, failed_reason
)

# Guarantee a Progress.md entry for every terminal run — deterministic Python,
# not dependent on a sub-agent choosing to write one.
_append_pipeline_to_progress_note(
project,
branch,
final_status,
total_duration,
pr_url or "",
failed_step,
failed_reason,
done_count,
step_count,
)

# Nous-style "learning": on success, distill the run into a Progress.md
# Learnings note + an optional skill CANDIDATE (local Ollama, free, staged
# for review — never auto-installed). Non-blocking.
if final_status == "done":
spawn_post_task_review(engine, run_id, project, branch, hermes_config)
except Exception as exc:
log.error(f"[orchestrate] Failed to send completion notification: {exc}")

Expand Down
Loading
Loading