mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting the Ink/npm flow unconditionally; with the dual-engine dispatch merged in, _resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built in the repo, routing the call away from the path under test (first subprocess became 'node --version' instead of 'npm run build'). Pin the engine to ink via an autouse fixture, mirroring the existing pinning precedent in test_tui_resume_flow.py.
This commit is contained in:
parent
ab37440ce6
commit
e1067dbbe5
756 changed files with 79874 additions and 19585 deletions
1
cron/scripts/__init__.py
Normal file
1
cron/scripts/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
|
||||
226
cron/scripts/classify_items.py
Normal file
226
cron/scripts/classify_items.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Classify candidate items by urgency/importance and emit only the urgent ones.
|
||||
|
||||
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
|
||||
feed) produces a list of candidate items; this script scores each with a cheap
|
||||
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
|
||||
print nothing, so a cron job wrapping this stays silent unless something
|
||||
actually matters -- the classic urgency-monitor pattern (fetch -> classify
|
||||
urgency -> surface only what's above the bar).
|
||||
|
||||
Design choices:
|
||||
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
|
||||
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
|
||||
can be a cheap fast model independent of the main chat model.
|
||||
* Reads items as JSON (a list of objects) from stdin or --input-file.
|
||||
* One LLM call scores the whole batch (cheap, single round-trip) and returns
|
||||
structured scores; we filter locally.
|
||||
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
|
||||
suppresses delivery. No spam on quiet intervals.
|
||||
|
||||
Usage (standalone):
|
||||
cat items.json | python classify_items.py --threshold 7 \
|
||||
--criteria "Urgent if it needs a reply today or is from my manager/family"
|
||||
|
||||
Usage (wired to a watcher via cron, agent mode):
|
||||
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
|
||||
pipe its JSON into classify_items.py with my urgency criteria, and deliver
|
||||
whatever it prints. Stay silent if it prints nothing."
|
||||
|
||||
Item schema (flexible): each item is an object; the classifier sees the whole
|
||||
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
|
||||
field (any of id/guid/message_id/url) is echoed back so duplicates can be
|
||||
deduped upstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _eprint(*args: Any) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
|
||||
raw = ""
|
||||
if input_file:
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
_eprint(f"classify_items: input is not valid JSON: {e}")
|
||||
sys.exit(2)
|
||||
if isinstance(data, dict):
|
||||
# Allow {"items": [...]} or a single object.
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
_eprint("classify_items: expected a JSON list or {items: [...]}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _item_id(item: Dict[str, Any], index: int) -> str:
|
||||
for key in ("id", "guid", "message_id", "url", "link"):
|
||||
val = item.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
return f"item-{index}"
|
||||
|
||||
|
||||
_CLASSIFY_INSTRUCTIONS = (
|
||||
"You are an urgency classifier for a proactive assistant. You will be given "
|
||||
"a numbered list of items and the user's importance criteria. Score EACH "
|
||||
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
|
||||
"JSON array, one object per item, in the same order: "
|
||||
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
|
||||
"No prose, no markdown fences. Be conservative: most items should score low. "
|
||||
"Only score high when the item clearly meets the user's criteria."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
|
||||
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
|
||||
for i, item in enumerate(items):
|
||||
# Show a compact view; the model sees the salient fields.
|
||||
view = {
|
||||
k: item[k]
|
||||
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
|
||||
if k in item
|
||||
}
|
||||
if not view:
|
||||
view = item # fall back to the whole object
|
||||
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
|
||||
lines.append(
|
||||
"\nReturn the JSON array of scores now (one object per item, same order)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
|
||||
text = (content or "").strip()
|
||||
# Tolerate accidental markdown fences.
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if "\n" in text:
|
||||
text = text.split("\n", 1)[1]
|
||||
try:
|
||||
arr = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Last-ditch: find the first [...] block.
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
arr = json.loads(text[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
_eprint("classify_items: could not parse classifier output")
|
||||
return {}
|
||||
else:
|
||||
_eprint("classify_items: classifier returned no JSON array")
|
||||
return {}
|
||||
out: Dict[int, Dict[str, Any]] = {}
|
||||
if isinstance(arr, list):
|
||||
for obj in arr:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
idx = obj.get("index")
|
||||
if isinstance(idx, int) and 0 <= idx < n_items:
|
||||
out[idx] = obj
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
|
||||
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
|
||||
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
|
||||
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
|
||||
args = parser.parse_args()
|
||||
|
||||
items = _load_items(args.input_file)
|
||||
if not items:
|
||||
# Nothing to classify -> silent. This is the common quiet-interval case.
|
||||
return 0
|
||||
|
||||
# Import here so --help works without the package importable.
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
_eprint(f"classify_items: cannot import auxiliary client: {e}")
|
||||
return 3
|
||||
|
||||
prompt = _build_prompt(items, args.criteria)
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="monitor",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
except Exception as e:
|
||||
# Classification failure is NOT silent -- surface it so a broken monitor
|
||||
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
|
||||
_eprint(f"classify_items: classifier call failed: {e}")
|
||||
return 4
|
||||
|
||||
scores = _parse_scores(content, len(items))
|
||||
surfaced = []
|
||||
for i, item in enumerate(items):
|
||||
s = scores.get(i)
|
||||
score = s.get("score") if isinstance(s, dict) else None
|
||||
if isinstance(score, int) and score >= args.threshold:
|
||||
surfaced.append((i, item, s))
|
||||
|
||||
if not surfaced:
|
||||
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
|
||||
return 0
|
||||
|
||||
if args.format == "json":
|
||||
out = [
|
||||
{
|
||||
"id": _item_id(item, i),
|
||||
"score": s.get("score"),
|
||||
"reason": s.get("reason", ""),
|
||||
"item": item,
|
||||
}
|
||||
for (i, item, s) in surfaced
|
||||
]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
blocks = []
|
||||
for (i, item, s) in surfaced:
|
||||
title = (
|
||||
item.get("title")
|
||||
or item.get("subject")
|
||||
or item.get("summary")
|
||||
or _item_id(item, i)
|
||||
)
|
||||
url = item.get("url") or item.get("link") or ""
|
||||
reason = s.get("reason", "")
|
||||
block = f"## [{s.get('score')}/10] {title}"
|
||||
if url:
|
||||
block += f"\n{url}"
|
||||
if reason:
|
||||
block += f"\n_{reason}_"
|
||||
blocks.append(block)
|
||||
print("\n\n".join(blocks))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue