216 lines
7.0 KiB
Python
216 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Expertise File Manager — per-cron compounding memory.
|
|
|
|
Each recurring cron job gets its own expertise file at
|
|
~/.hermes/expertise/<job-name>.yaml
|
|
|
|
The file is loaded at job start and updated at job end, making every run
|
|
smarter than the last.
|
|
|
|
Usage:
|
|
# Load expertise into context (inline at top of cron prompt):
|
|
python -m pipeline.expertise --load --job triage
|
|
|
|
# Append a new insight after work is done:
|
|
python -m pipeline.expertise --append --job triage --domain github-issues \
|
|
--insight "Issues with label 'bug' most often miss reproduction steps"
|
|
|
|
# Read all expertise entries (JSON for tool consumption):
|
|
python -m pipeline.expertise --read --job triage
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
try:
|
|
import yaml
|
|
except ModuleNotFoundError:
|
|
class yaml:
|
|
@staticmethod
|
|
def safe_load(f):
|
|
text = f.read()
|
|
if not text.strip():
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
# ponytail: no silent YAML-to-empty fallback; install PyYAML for real YAML files.
|
|
raise RuntimeError("PyYAML is required to read non-JSON expertise files") from exc
|
|
|
|
@staticmethod
|
|
def dump(data, f, **_kwargs):
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def expertise_dir() -> str:
|
|
"""Return ~/.hermes/expertise/ directory, creating it if needed."""
|
|
base = os.path.join(os.path.expanduser("~"), ".hermes", "expertise")
|
|
os.makedirs(base, exist_ok=True)
|
|
return base
|
|
|
|
|
|
def expertise_path(job_name: str) -> str:
|
|
return os.path.join(expertise_dir(), f"{job_name}.yaml")
|
|
|
|
|
|
# ── Data Model ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
ENTRY_SCHEMA = {
|
|
"domain": str,
|
|
"insight": str,
|
|
"date": str,
|
|
"source": str,
|
|
"verified": bool,
|
|
}
|
|
|
|
|
|
def default_expertise() -> dict:
|
|
return {
|
|
"updatable": True,
|
|
"version": 1,
|
|
"entries": [],
|
|
}
|
|
|
|
|
|
# ── Load / Save ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def load(job_name: str) -> dict:
|
|
path = expertise_path(job_name)
|
|
if os.path.exists(path):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or default_expertise()
|
|
data.setdefault("updatable", True)
|
|
data.setdefault("version", 1)
|
|
data.setdefault("entries", [])
|
|
return data
|
|
return default_expertise()
|
|
|
|
|
|
def save(job_name: str, data: dict) -> None:
|
|
path = expertise_path(job_name)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
yaml.dump(
|
|
data,
|
|
f,
|
|
default_flow_style=False,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
)
|
|
|
|
|
|
# ── Operations ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def append_entry(
|
|
job_name: str,
|
|
domain: str,
|
|
insight: str,
|
|
source: Optional[str] = None,
|
|
verified: bool = False,
|
|
) -> dict:
|
|
"""Append a new insight entry to the job's expertise file."""
|
|
data = load(job_name)
|
|
if not data.get("updatable", True):
|
|
print(f"Expertise file for '{job_name}' is not updatable.", file=sys.stderr)
|
|
return data
|
|
|
|
entry = {
|
|
"domain": domain,
|
|
"insight": insight,
|
|
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
"source": source or f"cron:{job_name}",
|
|
"verified": verified,
|
|
}
|
|
data["entries"].append(entry)
|
|
save(job_name, data)
|
|
return data
|
|
|
|
|
|
def render_context(job_name: str) -> str:
|
|
"""Render expertise entries as a context string for agent prompts."""
|
|
data = load(job_name)
|
|
if not data["entries"]:
|
|
return ""
|
|
lines = [f"# Expertise ({job_name})"]
|
|
for i, e in enumerate(data["entries"], 1):
|
|
tag = "✓" if e.get("verified") else "·"
|
|
lines.append(f"{tag} [{e['domain']}] {e['insight']} ({e['date']})")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def purge_domain(job_name: str, domain: str) -> dict:
|
|
"""Remove all entries for a specific domain."""
|
|
data = load(job_name)
|
|
before = len(data["entries"])
|
|
data["entries"] = [e for e in data["entries"] if e.get("domain") != domain]
|
|
removed = before - len(data["entries"])
|
|
if removed:
|
|
save(job_name, data)
|
|
print(f"Removed {removed} entries for domain '{domain}'.")
|
|
else:
|
|
print(f"No entries found for domain '{domain}'.")
|
|
return data
|
|
|
|
|
|
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Expertise file manager")
|
|
parser.add_argument("--job", required=True, help="Job name (corresponds to expertise filename)")
|
|
parser.add_argument("--load", action="store_true", help="Print expertise as context (for injection at cron start)")
|
|
parser.add_argument("--read", action="store_true", help="Dump entire expertise file as JSON")
|
|
parser.add_argument("--append", action="store_true", help="Append a new insight entry")
|
|
parser.add_argument("--domain", default="general", help="Domain tag for the insight")
|
|
parser.add_argument("--insight", help="The insight text to store")
|
|
parser.add_argument("--source", help="Source identifier (default: cron:<job>)")
|
|
parser.add_argument("--verified", action="store_true", default=False, help="Mark insight as verified")
|
|
parser.add_argument("--purge", help="Remove all entries for a domain")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.load:
|
|
print(render_context(args.job))
|
|
return
|
|
|
|
if args.read:
|
|
data = load(args.job)
|
|
print(json.dumps(data, indent=2))
|
|
return
|
|
|
|
if args.purge:
|
|
purge_domain(args.job, args.purge)
|
|
return
|
|
|
|
if args.append:
|
|
if not args.insight:
|
|
print("ERROR: --insight is required with --append", file=sys.stderr)
|
|
sys.exit(1)
|
|
append_entry(
|
|
args.job,
|
|
domain=args.domain,
|
|
insight=args.insight,
|
|
source=args.source,
|
|
verified=args.verified,
|
|
)
|
|
print(f"Appended insight to {args.job}.yaml")
|
|
return
|
|
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|