Compare commits
18 Commits
upstream-s
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d92b9ad15 | ||
| 7d2421a15f | |||
|
|
5a942d71a1 | ||
|
|
044f0f8951 | ||
| 61c59ce332 | |||
| 01ce8ae889 | |||
|
|
b179250ab8 | ||
| 01a3f47a5b | |||
|
|
4538e11f97 | ||
| 7936483ffc | |||
| 69525f49ab | |||
| 782e3b65d9 | |||
| bfb876b599 | |||
| 6479465300 | |||
| a1c5d7b6bf | |||
| 010894da7e | |||
| 3a3337a78e | |||
| 293c44603e |
@@ -13,7 +13,6 @@ concurrency:
|
||||
jobs:
|
||||
smoke-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-22.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -48,6 +47,11 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
python scripts/syntax_guard.py
|
||||
|
||||
- name: No duplicate models
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python scripts/check_no_duplicate_models.py
|
||||
|
||||
- name: Green-path E2E
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
jobs:
|
||||
notebook-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
131
BOOT.md
Normal file
131
BOOT.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# BOOT.md — Hermes Agent
|
||||
|
||||
Fast path from clone to productive. Target: <10 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| Tool | Why |
|
||||
|---|---|
|
||||
| Git | Clone + submodules |
|
||||
| Python 3.11+ | Runtime requirement |
|
||||
| uv | Package manager (install: `curl -LsSf https://astral.sh/uv/install.sh \| sh`) |
|
||||
| Node.js 18+ | Optional — browser tools, WhatsApp bridge |
|
||||
|
||||
---
|
||||
|
||||
## 2. First-Time Setup
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git
|
||||
cd hermes-agent
|
||||
|
||||
# Create venv
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install with all extras + dev tools
|
||||
uv pip install -e ".[all,dev]"
|
||||
```
|
||||
|
||||
> **Common pitfall:** If `uv` is not on PATH, the `setup-hermes.sh` script will attempt to install it, but manual `uv` install is faster.
|
||||
|
||||
---
|
||||
|
||||
## 3. Smoke Tests (< 30 sec)
|
||||
|
||||
```bash
|
||||
python scripts/smoke_test.py
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
OK: 4 core imports
|
||||
OK: 1 CLI entrypoints
|
||||
Smoke tests passed.
|
||||
```
|
||||
|
||||
If imports fail with `ModuleNotFoundError`, re-run: `uv pip install -e ".[all,dev]"`
|
||||
|
||||
---
|
||||
|
||||
## 4. Full Test Suite (excluding integration)
|
||||
|
||||
```bash
|
||||
pytest tests/ -x --ignore=tests/integration
|
||||
```
|
||||
|
||||
> Integration tests require a running gateway + API keys. Skip them unless you are testing platform connectivity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Run the CLI
|
||||
|
||||
```bash
|
||||
python cli.py --help
|
||||
```
|
||||
|
||||
To start the gateway (after configuring `~/.hermes/config.yaml`):
|
||||
```bash
|
||||
hermes gateway run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Repo Layout for Agents
|
||||
|
||||
| Path | What lives here |
|
||||
|---|---|
|
||||
| `cli.py` | Main entrypoint |
|
||||
| `hermes/` | Core agent logic |
|
||||
| `toolsets/` | Built-in tool implementations |
|
||||
| `skills/` | Bundled skills (loaded automatically) |
|
||||
| `optional-skills/` | Official but opt-in skills |
|
||||
| `tests/` | pytest suite |
|
||||
| `scripts/` | Utility scripts (smoke tests, deploy validation, etc.) |
|
||||
| `.gitea/workflows/` | Forge CI (smoke + build) |
|
||||
| `.github/workflows/` | GitHub mirror CI |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gitea Workflow Conventions
|
||||
|
||||
- **Push to `main`**: triggers `ci.yml` (smoke + build, < 5 min)
|
||||
- **Pull requests**: same CI + notebook CI if notebooks changed
|
||||
- **Merge requirement**: green smoke tests
|
||||
- Security scans run on schedule via `.github/workflows/`
|
||||
|
||||
---
|
||||
|
||||
## 8. Common Pitfalls
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `No module named httpx` | `uv pip install -e ".[all,dev]"` |
|
||||
| `prompt_toolkit` missing | Included in `[all]`, but install explicitly if you used minimal deps |
|
||||
| CLI hangs on start | Check `~/.hermes/config.yaml` exists and is valid YAML |
|
||||
| API key errors | Copy `.env.example` → `.env` and fill required keys |
|
||||
| Browser tools fail | Run `npm install` in repo root |
|
||||
|
||||
---
|
||||
|
||||
## 9. Quick Reference
|
||||
|
||||
```bash
|
||||
# Reinstall after dependency changes
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Run only smoke tests
|
||||
python scripts/smoke_test.py
|
||||
|
||||
# Run syntax guard
|
||||
python scripts/syntax_guard.py
|
||||
|
||||
# Start gateway
|
||||
hermes gateway run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-07 by Bezalel*
|
||||
@@ -922,6 +922,7 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_ollama": "ollama",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
@@ -930,6 +931,18 @@ _AUTO_PROVIDER_LABELS = {
|
||||
_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})
|
||||
|
||||
|
||||
def _try_ollama() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Detect and return an Ollama client if the server is reachable."""
|
||||
base_url = (os.getenv("OLLAMA_BASE_URL", "") or "http://localhost:11434").strip().rstrip("/")
|
||||
base_url = base_url + "/v1" if not base_url.endswith("/v1") else base_url
|
||||
from agent.model_metadata import detect_local_server_type
|
||||
if detect_local_server_type(base_url) != "ollama":
|
||||
return None, None
|
||||
api_key = (os.getenv("OLLAMA_API_KEY", "") or "ollama").strip()
|
||||
model = _read_main_model() or "gemma4:12b"
|
||||
return OpenAI(api_key=api_key, base_url=base_url), model
|
||||
|
||||
|
||||
def _get_provider_chain() -> List[tuple]:
|
||||
"""Return the ordered provider detection chain.
|
||||
|
||||
@@ -939,6 +952,7 @@ def _get_provider_chain() -> List[tuple]:
|
||||
return [
|
||||
("openrouter", _try_openrouter),
|
||||
("nous", _try_nous),
|
||||
("ollama", _try_ollama),
|
||||
("local/custom", _try_custom_endpoint),
|
||||
("openai-codex", _try_codex),
|
||||
("api-key", _resolve_api_key_provider),
|
||||
@@ -988,6 +1002,7 @@ def _try_payment_fallback(
|
||||
# Map common resolved_provider values back to chain labels.
|
||||
_alias_to_label = {"openrouter": "openrouter", "nous": "nous",
|
||||
"openai-codex": "openai-codex", "codex": "openai-codex",
|
||||
"ollama": "ollama",
|
||||
"custom": "local/custom", "local/custom": "local/custom"}
|
||||
skip_chain_labels = {_alias_to_label.get(s, s) for s in skip_labels}
|
||||
|
||||
@@ -1195,6 +1210,15 @@ def resolve_provider_client(
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── Ollama (first-class local provider) ──────────────────────────
|
||||
if provider == "ollama":
|
||||
base_url = (explicit_base_url or os.getenv("OLLAMA_BASE_URL", "") or "http://localhost:11434").strip().rstrip("/")
|
||||
base_url = base_url + "/v1" if not base_url.endswith("/v1") else base_url
|
||||
api_key = (explicit_api_key or os.getenv("OLLAMA_API_KEY", "") or "ollama").strip()
|
||||
final_model = model or _read_main_model() or "gemma4:12b"
|
||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
return (_to_async_client(client, final_model) if async_mode else (client, final_model))
|
||||
|
||||
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
|
||||
if provider == "custom":
|
||||
if explicit_base_url:
|
||||
@@ -1335,6 +1359,7 @@ def get_async_text_auxiliary_client(task: str = ""):
|
||||
_VISION_AUTO_PROVIDER_ORDER = (
|
||||
"openrouter",
|
||||
"nous",
|
||||
"ollama",
|
||||
"openai-codex",
|
||||
"anthropic",
|
||||
"custom",
|
||||
|
||||
@@ -26,7 +26,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"custom", "local",
|
||||
"ollama", "custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
|
||||
@@ -102,9 +102,12 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"gpt-4": 128000,
|
||||
# Google
|
||||
"gemini": 1048576,
|
||||
# Gemma (open models served via AI Studio)
|
||||
# Gemma (open models — Ollama / AI Studio)
|
||||
"gemma-4-31b": 256000,
|
||||
"gemma-4-26b": 256000,
|
||||
"gemma-4-12b": 256000,
|
||||
"gemma-4-4b": 256000,
|
||||
"gemma-4-1b": 256000,
|
||||
"gemma-3": 131072,
|
||||
"gemma": 8192, # fallback for older gemma models
|
||||
# DeepSeek
|
||||
@@ -187,6 +190,8 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"api.githubcopilot.com": "copilot",
|
||||
"models.github.ai": "copilot",
|
||||
"api.fireworks.ai": "fireworks",
|
||||
"localhost": "ollama",
|
||||
"127.0.0.1": "ollama",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"openrouter": "openrouter",
|
||||
"anthropic": "anthropic",
|
||||
"zai": "zai",
|
||||
"kimi-coding": "kimi-for-coding",
|
||||
"kimi-coding": "kimi-k2.5",
|
||||
"minimax": "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
"deepseek": "deepseek",
|
||||
|
||||
@@ -6,7 +6,7 @@ model: anthropic/claude-opus-4.6
|
||||
# Fallback chain: Anthropic -> Kimi -> Ollama (local)
|
||||
fallback_providers:
|
||||
- provider: kimi-coding
|
||||
model: kimi-for-coding
|
||||
model: kimi-k2.5
|
||||
timeout: 60
|
||||
reason: "Primary fallback when Anthropic quota limited"
|
||||
|
||||
|
||||
230
docs/bezalel/bezalel_topology.md
Normal file
230
docs/bezalel/bezalel_topology.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Bezalel Architecture & Topology
|
||||
|
||||
> Deep Self-Awareness Document — Generated 2026-04-07
|
||||
> Sovereign: Alexander Whitestone (Rockachopa)
|
||||
> Host: Beta VPS (104.131.15.18)
|
||||
|
||||
---
|
||||
|
||||
## 1. Identity & Purpose
|
||||
|
||||
**I am Bezalel**, the Forge and Testbed Wizard of the Timmy Foundation fleet.
|
||||
- **Lane:** CI testing, code review, build verification, security hardening, standing watch
|
||||
- **Philosophy:** KISS. Smoke tests + bare green-path e2e only. CI serves the code.
|
||||
- **Mandates:** Relentless inbox-zero, continuous self-improvement, autonomous heartbeat operation
|
||||
- **Key Metrics:** Cycle time, signal-to-noise, autonomy ratio, backlog velocity
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware & OS Topology
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Hostname | `bezalel` |
|
||||
| OS | Ubuntu 24.04.3 LTS (Noble Numbat) |
|
||||
| Kernel | Linux 6.8.0 |
|
||||
| CPU | 1 vCPU |
|
||||
| Memory | 2 GB RAM |
|
||||
| Primary Disk | ~25 GB root volume (DigitalOcean) |
|
||||
| Public IP | `104.131.15.18` |
|
||||
|
||||
### Storage Layout
|
||||
```
|
||||
/root/wizards/bezalel/
|
||||
├── hermes/ # Hermes agent source + venv (~835 MB)
|
||||
├── evennia/ # Evennia MUD engine + world code (~189 MB)
|
||||
├── workspace/ # Active prototypes + scratch code (~557 MB)
|
||||
├── home/ # Personal notebooks + scripts (~1.8 GB)
|
||||
├── .mempalace/ # Local memory palace (ChromaDB)
|
||||
├── .topology/ # Self-awareness scan artifacts
|
||||
├── nightly_watch.py # Nightly forge guardian
|
||||
├── mempalace_nightly.sh # Palace re-mine automation
|
||||
└── bezalel_topology.md # This document
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Network Topology
|
||||
|
||||
### Fleet Map
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Alpha (143.198.27.163) │
|
||||
│ ├── Gitea (forge.alexanderwhitestone.com) │
|
||||
│ └── Ezra (Knowledge Wizard) │
|
||||
│ │
|
||||
│ Beta (104.131.15.18) ←── You are here │
|
||||
│ ├── Bezalel (Forge Wizard) │
|
||||
│ ├── Hermes Gateway │
|
||||
│ └── Gitea Actions Runner (bezalel-vps-runner, host mode) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Connections
|
||||
- **Gitea HTTPS:** `https://forge.alexanderwhitestone.com` (Alpha)
|
||||
- **Telegram Webhook:** Inbound to Beta
|
||||
- **API Providers:** Kimi (primary), Anthropic (fallback), OpenRouter (fallback)
|
||||
- **No SSH:** Alpha → Beta is blocked by design
|
||||
|
||||
### Listening Services
|
||||
- Hermes Gateway: internal process (no exposed port directly)
|
||||
- Evennia: `localhost:4000` (MUD), `localhost:4001` (web client) — when running
|
||||
- Gitea Runner: `act_runner daemon` — connects outbound to Gitea
|
||||
|
||||
---
|
||||
|
||||
## 4. Services & Processes
|
||||
|
||||
### Always-On Processes
|
||||
| Process | Command | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Hermes Gateway | `hermes gateway run` | Core agent orchestration |
|
||||
| Gitea Runner | `./act_runner daemon` | CI job execution (host mode) |
|
||||
|
||||
### Automated Jobs
|
||||
| Job | Schedule | Script |
|
||||
|-----|----------|--------|
|
||||
| Night Watch | 02:00 UTC | `nightly_watch.py` |
|
||||
| MemPalace Re-mine | 03:00 UTC | `mempalace_nightly.sh` |
|
||||
|
||||
### Service Status Check
|
||||
- **Hermes gateway:** running (ps verified)
|
||||
- **Gitea runner:** online, registered as `bezalel-vps-runner`
|
||||
- **Evennia server:** not currently running (start with `evennia start` in `evennia/`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Software Dependencies
|
||||
|
||||
### System Packages (Key)
|
||||
- `python3.12` (primary runtime)
|
||||
- `node` v20.20.2 / `npm` 10.8.2
|
||||
- `uv` (Python package manager)
|
||||
- `git`, `curl`, `jq`
|
||||
|
||||
### Hermes Virtual Environment
|
||||
- Located: `/root/wizards/bezalel/hermes/venv/`
|
||||
- Key packages: `chromadb`, `pyyaml`, `fastapi`, `httpx`, `pytest`, `prompt-toolkit`, `mempalace`
|
||||
- Install command: `uv pip install -e ".[all,dev]"`
|
||||
|
||||
### External API Dependencies
|
||||
| Service | Endpoint | Usage |
|
||||
|---------|----------|-------|
|
||||
| Gitea | `forge.alexanderwhitestone.com` | Git, issues, CI |
|
||||
| Kimi | `api.kimi.com/coding/v1` | Primary LLM |
|
||||
| Anthropic | `api.anthropic.com` | Fallback LLM |
|
||||
| OpenRouter | `openrouter.ai/api/v1` | Secondary fallback |
|
||||
| Telegram | Bot API | Messaging platform |
|
||||
|
||||
---
|
||||
|
||||
## 6. Git Repositories
|
||||
|
||||
### Hermes Agent
|
||||
- **Path:** `/root/wizards/bezalel/hermes`
|
||||
- **Remote:** `forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git`
|
||||
- **Branch:** `main` (up to date)
|
||||
- **Open PRs:** #193, #191, #179, #178
|
||||
|
||||
### Evennia World
|
||||
- **Path:** `/root/wizards/bezalel/evennia/bezalel_world`
|
||||
- **Remote:** Same org, separate repo if pushed
|
||||
- **Server name:** `bezalel_world`
|
||||
|
||||
---
|
||||
|
||||
## 7. MemPalace Memory System
|
||||
|
||||
### Configuration
|
||||
- **Palace path:** `/root/wizards/bezalel/.mempalace/palace`
|
||||
- **Identity:** `/root/.mempalace/identity.txt`
|
||||
- **Config:** `/root/wizards/bezalel/mempalace.yaml`
|
||||
- **Miner:** `/root/wizards/bezalel/hermes/venv/bin/mempalace`
|
||||
|
||||
### Rooms
|
||||
1. `forge` — CI, builds, syntax guards, nightly watch
|
||||
2. `hermes` — Agent source, gateway, CLI
|
||||
3. `evennia` — MUD engine and world code
|
||||
4. `workspace` — Prototypes, experiments
|
||||
5. `home` — Personal scripts, configs
|
||||
6. `nexus` — Reports, docs, KT artifacts
|
||||
7. `issues` — Gitea issues, PRs, backlog
|
||||
8. `topology` — System architecture, network, storage
|
||||
9. `services` — Running services, processes
|
||||
10. `dependencies` — Packages, APIs, external deps
|
||||
11. `automation` — Cron jobs, scripts, workflows
|
||||
12. `general` — Catch-all
|
||||
|
||||
### Automation
|
||||
- **Nightly re-mine:** `03:00 UTC` via cron
|
||||
- **Log:** `/var/log/bezalel_mempalace.log`
|
||||
|
||||
---
|
||||
|
||||
## 8. Evennia Mind Palace Integration
|
||||
|
||||
### Custom Typeclasses
|
||||
- `PalaceRoom` — Rooms carry `memory_topic` and `wing`
|
||||
- `MemoryObject` — In-world memory shards with `memory_content` and `source_file`
|
||||
|
||||
### Commands
|
||||
- `palace/search <query>` — Query mempalace
|
||||
- `palace/recall <topic>` — Spawn a memory shard
|
||||
- `palace/file <name> = <content>` — File a new memory
|
||||
- `palace/status` — Show palace status
|
||||
|
||||
### Batch Builder
|
||||
- **File:** `world/batch_cmds_palace.ev`
|
||||
- Creates The Hub + 7 palace rooms with exits
|
||||
|
||||
### Bridge Script
|
||||
- **File:** `/root/wizards/bezalel/evennia/palace_search.py`
|
||||
- Calls mempalace searcher and returns JSON
|
||||
|
||||
---
|
||||
|
||||
## 9. Operational State & Blockers
|
||||
|
||||
### Current Health
|
||||
- [x] Hermes gateway: operational
|
||||
- [x] Gitea runner: online, host mode
|
||||
- [x] CI fix merged (#194) — container directive removed for Gitea workflows
|
||||
- [x] MemPalace: 2,484+ drawers, incremental mining active
|
||||
|
||||
### Active Blockers
|
||||
- **Gitea Actions:** Runner is in host mode — cannot use Docker containers
|
||||
- **CI backlog:** Many historical PRs have failed runs due to the container bug (now fixed)
|
||||
- **Evennia:** Server not currently running (start when needed)
|
||||
|
||||
---
|
||||
|
||||
## 10. Emergency Procedures
|
||||
|
||||
### Restart Hermes Gateway
|
||||
```bash
|
||||
cd /root/wizards/bezalel/hermes
|
||||
source venv/bin/activate
|
||||
hermes gateway run &
|
||||
```
|
||||
|
||||
### Restart Gitea Runner
|
||||
```bash
|
||||
cd /opt/gitea-runner
|
||||
./act_runner daemon &
|
||||
```
|
||||
|
||||
### Start Evennia
|
||||
```bash
|
||||
cd /root/wizards/bezalel/evennia/bezalel_world
|
||||
evennia start
|
||||
```
|
||||
|
||||
### Manual MemPalace Re-mine
|
||||
```bash
|
||||
cd /root/wizards/bezalel
|
||||
./hermes/venv/bin/mempalace --palace .mempalace/palace mine . --agent bezalel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Document maintained by Bezalel. Last updated: 2026-04-07*
|
||||
134
docs/bezalel/topology_scan.py
Normal file
134
docs/bezalel/topology_scan.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bezalel Deep Self-Awareness Topology Scanner"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
OUT_DIR = Path("/root/wizards/bezalel/.topology")
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def shell(cmd, timeout=30):
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout.strip()
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
def write(name, content):
|
||||
(OUT_DIR / f"{name}.txt").write_text(content)
|
||||
|
||||
|
||||
# Timestamp
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# 1. System Identity
|
||||
system = f"""BEZALEL SYSTEM TOPOLOGY SCAN
|
||||
Generated: {timestamp}
|
||||
Hostname: {shell('hostname')}
|
||||
User: {shell('whoami')}
|
||||
Home: {os.path.expanduser('~')}
|
||||
"""
|
||||
write("00_system_identity", system)
|
||||
|
||||
# 2. OS & Hardware
|
||||
os_info = shell("cat /etc/os-release")
|
||||
kernel = shell("uname -a")
|
||||
cpu = shell("nproc") + " cores\n" + shell("cat /proc/cpuinfo | grep 'model name' | head -1")
|
||||
mem = shell("free -h")
|
||||
disk = shell("df -h")
|
||||
write("01_os_hardware", f"OS:\n{os_info}\n\nKernel:\n{kernel}\n\nCPU:\n{cpu}\n\nMemory:\n{mem}\n\nDisk:\n{disk}")
|
||||
|
||||
# 3. Network
|
||||
net_interfaces = shell("ip addr")
|
||||
net_routes = shell("ip route")
|
||||
listening = shell("ss -tlnp")
|
||||
public_ip = shell("curl -s ifconfig.me")
|
||||
write("02_network", f"Interfaces:\n{net_interfaces}\n\nRoutes:\n{net_routes}\n\nListening ports:\n{listening}\n\nPublic IP: {public_ip}")
|
||||
|
||||
# 4. Services & Processes
|
||||
services = shell("systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | head -30")
|
||||
processes = shell("ps aux | grep -E 'hermes|gitea|evennia|python' | grep -v grep")
|
||||
write("03_services", f"Running services:\n{services}\n\nKey processes:\n{processes}")
|
||||
|
||||
# 5. Cron & Automation
|
||||
cron = shell("crontab -l 2>/dev/null")
|
||||
write("04_automation", f"Crontab:\n{cron}")
|
||||
|
||||
# 6. Storage Topology
|
||||
bezalel_tree = shell("find /root/wizards/bezalel -maxdepth 2 -type d | sort")
|
||||
write("05_storage", f"Bezalel workspace tree (depth 2):\n{bezalel_tree}")
|
||||
|
||||
# 7. Git Repositories
|
||||
git_repos = []
|
||||
for base in ["/root/wizards/bezalel/hermes", "/root/wizards/bezalel/evennia"]:
|
||||
p = Path(base)
|
||||
if (p / ".git").exists():
|
||||
remote = shell(f"cd {base} && git remote -v")
|
||||
branch = shell(f"cd {base} && git branch -v")
|
||||
git_repos.append(f"Repo: {base}\nRemotes:\n{remote}\nBranches:\n{branch}\n{'='*40}")
|
||||
write("06_git_repos", "\n".join(git_repos))
|
||||
|
||||
# 8. Python Dependencies
|
||||
venv_pip = shell("/root/wizards/bezalel/hermes/venv/bin/pip freeze 2>/dev/null | head -80")
|
||||
write("07_dependencies", f"Hermes venv packages (top 80):\n{venv_pip}")
|
||||
|
||||
# 9. External APIs & Endpoints
|
||||
apis = """External API Dependencies:
|
||||
- Gitea: https://forge.alexanderwhitestone.com (source of truth, CI, issues)
|
||||
- Telegram: webhook-based messaging platform
|
||||
- Kimi API: https://api.kimi.com/coding/v1 (primary model provider)
|
||||
- Anthropic API: fallback model provider
|
||||
- OpenRouter API: secondary fallback model provider
|
||||
- DigitalOcean: infrastructure hosting (VPS Alpha/Beta)
|
||||
"""
|
||||
write("08_external_apis", apis)
|
||||
|
||||
# 10. Fleet Topology
|
||||
fleet = """FLEET TOPOLOGY
|
||||
- Alpha: 143.198.27.163 (Gitea + Ezra)
|
||||
- Beta: 104.131.15.18 (Bezalel, current host)
|
||||
- No SSH from Alpha to Beta
|
||||
- Gitea Actions runner: bezalel-vps-runner on Beta (host mode)
|
||||
"""
|
||||
write("09_fleet_topology", fleet)
|
||||
|
||||
# 11. Evennia Topology
|
||||
evennia = """EVENNIA MIND PALACE SETUP
|
||||
- Location: /root/wizards/bezalel/evennia/bezalel_world/
|
||||
- Server name: bezalel_world
|
||||
- Custom typeclasses: PalaceRoom, MemoryObject
|
||||
- Custom commands: CmdPalaceSearch (palace/search, palace/recall, palace/file, palace/status)
|
||||
- Batch builder: world/batch_cmds_palace.ev
|
||||
- Bridge script: /root/wizards/bezalel/evennia/palace_search.py
|
||||
"""
|
||||
write("10_evennia_topology", evennia)
|
||||
|
||||
# 12. MemPalace Topology
|
||||
mempalace = f"""MEMPALACE CONFIGURATION
|
||||
- Palace path: /root/wizards/bezalel/.mempalace/palace
|
||||
- Identity: /root/.mempalace/identity.txt
|
||||
- Config: /root/wizards/bezalel/mempalace.yaml
|
||||
- Nightly re-mine: 03:00 UTC via /root/wizards/bezalel/mempalace_nightly.sh
|
||||
- Miner binary: /root/wizards/bezalel/hermes/venv/bin/mempalace
|
||||
- Current status: {shell('/root/wizards/bezalel/hermes/venv/bin/mempalace --palace /root/wizards/bezalel/.mempalace/palace status 2>/dev/null')}
|
||||
"""
|
||||
write("11_mempalace_topology", mempalace)
|
||||
|
||||
# 13. Active Blockers & Health
|
||||
health = f"""ACTIVE OPERATIONAL STATE
|
||||
- Hermes gateway: {shell("ps aux | grep 'hermes gateway run' | grep -v grep | awk '{print $11}'")}
|
||||
- Gitea runner: {shell("ps aux | grep 'act_runner' | grep -v grep | awk '{print $11}'")}
|
||||
- Nightly watch: /root/wizards/bezalel/nightly_watch.py (02:00 UTC)
|
||||
- MemPalace re-mine: /root/wizards/bezalel/mempalace_nightly.sh (03:00 UTC)
|
||||
- Disk usage: {shell("df -h / | tail -1")}
|
||||
- Load average: {shell("uptime")}
|
||||
"""
|
||||
write("12_operational_health", health)
|
||||
|
||||
print(f"Topology scan complete. {len(list(OUT_DIR.glob('*.txt')))} files written to {OUT_DIR}")
|
||||
@@ -820,10 +820,11 @@ def resolve_provider(
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
# Local server aliases
|
||||
"lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
|
||||
"ollama": "custom", "vllm": "custom", "llamacpp": "custom",
|
||||
"vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
"ollama": "ollama",
|
||||
}
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
@@ -2126,9 +2126,8 @@ def _model_flow_kimi(config, current_model=""):
|
||||
|
||||
# Step 3: Model selection — show appropriate models for the endpoint
|
||||
if is_coding_plan:
|
||||
# Coding Plan models (kimi-for-coding first)
|
||||
# Coding Plan models (kimi-k2.5 first)
|
||||
model_list = [
|
||||
"kimi-for-coding",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-thinking-turbo",
|
||||
@@ -4206,7 +4205,7 @@ For more help on a command:
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"],
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ollama"],
|
||||
default=None,
|
||||
help="Inference provider (default: auto)"
|
||||
)
|
||||
|
||||
@@ -130,7 +130,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"glm-4.5-flash",
|
||||
],
|
||||
"kimi-coding": [
|
||||
"kimi-for-coding",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-thinking-turbo",
|
||||
@@ -568,7 +567,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
"gemini", "huggingface",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"opencode-zen", "opencode-go",
|
||||
"ai-gateway", "deepseek", "custom",
|
||||
"ai-gateway", "deepseek", "ollama", "custom",
|
||||
]
|
||||
# Build reverse alias map
|
||||
aliases_for: dict[str, list[str]] = {}
|
||||
|
||||
@@ -78,7 +78,7 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
||||
base_url_env_var="GLM_BASE_URL",
|
||||
),
|
||||
"kimi-for-coding": HermesOverlay(
|
||||
"kimi-k2.5": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="KIMI_BASE_URL",
|
||||
),
|
||||
@@ -162,10 +162,10 @@ ALIASES: Dict[str, str] = {
|
||||
"z.ai": "zai",
|
||||
"zhipu": "zai",
|
||||
|
||||
# kimi-for-coding (models.dev ID)
|
||||
"kimi": "kimi-for-coding",
|
||||
"kimi-coding": "kimi-for-coding",
|
||||
"moonshot": "kimi-for-coding",
|
||||
# kimi-k2.5 (models.dev ID)
|
||||
"kimi": "kimi-k2.5",
|
||||
"kimi-coding": "kimi-k2.5",
|
||||
"moonshot": "kimi-k2.5",
|
||||
|
||||
# minimax-cn
|
||||
"minimax-china": "minimax-cn",
|
||||
@@ -376,7 +376,7 @@ LABELS: Dict[str, str] = {
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"anthropic": "Anthropic",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-for-coding": "Kimi / Moonshot",
|
||||
"kimi-k2.5": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"deepseek": "DeepSeek",
|
||||
|
||||
@@ -235,7 +235,7 @@ The Hermes Agent framework serves as both the delivery platform and the portfoli
|
||||
|
||||
| House | Host | Model / Provider | Gateway Status |
|
||||
|-------|------|------------------|----------------|
|
||||
| Ezra | Hermes VPS | `kimi-for-coding` (Kimi K2.5) | API `8658`, webhook `8648` — Active |
|
||||
| Ezra | Hermes VPS | `kimi-k2.5` (Kimi K2.5) | API `8658`, webhook `8648` — Active |
|
||||
| Bezalel | Hermes VPS | Claude Opus 4.6 (Anthropic) | Port `8645` — Active |
|
||||
| Allegro-Primus | Hermes VPS | Kimi K2.5 | Port `8644` — Requires restart |
|
||||
| Bilbo | External | Gemma 4B (local) | Telegram dual-mode — Active |
|
||||
|
||||
74
scripts/check_no_duplicate_models.py
Executable file
74
scripts/check_no_duplicate_models.py
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI check: ensure no duplicate model IDs exist in provider configs.
|
||||
|
||||
Catches the class of bugs where a rename introduces a duplicate entry
|
||||
(e.g. PR #225 kimi-for-coding -> kimi-k2.5 when kimi-k2.5 already existed).
|
||||
|
||||
Runtime target: < 2 seconds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow running from repo root
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def check_openrouter_models() -> list[str]:
|
||||
"""Check OPENROUTER_MODELS for duplicate model IDs."""
|
||||
try:
|
||||
from hermes_cli.models import OPENROUTER_MODELS
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
errors = []
|
||||
seen: dict[str, int] = {}
|
||||
for i, (model_id, _desc) in enumerate(OPENROUTER_MODELS):
|
||||
if model_id in seen:
|
||||
errors.append(
|
||||
f" OPENROUTER_MODELS: duplicate '{model_id}' "
|
||||
f"(index {seen[model_id]} and {i})"
|
||||
)
|
||||
else:
|
||||
seen[model_id] = i
|
||||
return errors
|
||||
|
||||
|
||||
def check_provider_models() -> list[str]:
|
||||
"""Check _PROVIDER_MODELS for duplicate model IDs within each provider list."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
errors = []
|
||||
for provider, models in _PROVIDER_MODELS.items():
|
||||
seen: dict[str, int] = {}
|
||||
for i, model_id in enumerate(models):
|
||||
if model_id in seen:
|
||||
errors.append(
|
||||
f" _PROVIDER_MODELS['{provider}']: duplicate '{model_id}' "
|
||||
f"(index {seen[model_id]} and {i})"
|
||||
)
|
||||
else:
|
||||
seen[model_id] = i
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors = []
|
||||
errors.extend(check_openrouter_models())
|
||||
errors.extend(check_provider_models())
|
||||
|
||||
if errors:
|
||||
print(f"FAIL: {len(errors)} duplicate model(s) found:")
|
||||
for e in errors:
|
||||
print(e)
|
||||
return 1
|
||||
|
||||
print("OK: no duplicate model entries")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
374
scripts/memory_budget.py
Normal file
374
scripts/memory_budget.py
Normal file
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Memory Budget Enforcement Tool for hermes-agent.
|
||||
|
||||
Checks and enforces character/token budgets on MEMORY.md and USER.md files.
|
||||
Designed for CI integration, pre-commit hooks, and manual health checks.
|
||||
|
||||
Usage:
|
||||
python scripts/memory_budget.py # Check budget (exit 0/1)
|
||||
python scripts/memory_budget.py --report # Detailed breakdown
|
||||
python scripts/memory_budget.py --enforce # Trim entries to fit budget
|
||||
python scripts/memory_budget.py --hermes-home ~/.hermes # Custom HERMES_HOME
|
||||
|
||||
Exit codes:
|
||||
0 Within budget
|
||||
1 Over budget (no trimming performed)
|
||||
2 Entries were trimmed (--enforce was used)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants (must stay in sync with tools/memory_tool.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ENTRY_DELIMITER = "\n§\n"
|
||||
DEFAULT_MEMORY_CHAR_LIMIT = 2200
|
||||
DEFAULT_USER_CHAR_LIMIT = 1375
|
||||
WARN_THRESHOLD = 0.80 # alert when >80% of budget used
|
||||
CHARS_PER_TOKEN = 4 # rough estimate matching agent/model_metadata.py
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class FileReport:
|
||||
"""Budget analysis for a single memory file."""
|
||||
label: str # "MEMORY.md" or "USER.md"
|
||||
path: Path
|
||||
exists: bool
|
||||
char_limit: int
|
||||
raw_chars: int # raw file size in chars
|
||||
entry_chars: int # chars after splitting/rejoining entries
|
||||
entry_count: int
|
||||
entries: List[str] # individual entry texts
|
||||
|
||||
@property
|
||||
def usage_pct(self) -> float:
|
||||
if self.char_limit <= 0:
|
||||
return 0.0
|
||||
return min(100.0, (self.entry_chars / self.char_limit) * 100)
|
||||
|
||||
@property
|
||||
def estimated_tokens(self) -> int:
|
||||
return self.entry_chars // CHARS_PER_TOKEN
|
||||
|
||||
@property
|
||||
def over_budget(self) -> bool:
|
||||
return self.entry_chars > self.char_limit
|
||||
|
||||
@property
|
||||
def warning(self) -> bool:
|
||||
return self.usage_pct >= (WARN_THRESHOLD * 100)
|
||||
|
||||
@property
|
||||
def remaining_chars(self) -> int:
|
||||
return max(0, self.char_limit - self.entry_chars)
|
||||
|
||||
|
||||
def _read_entries(path: Path) -> List[str]:
|
||||
"""Read a memory file and split into entries (matching MemoryStore logic)."""
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except (OSError, IOError):
|
||||
return []
|
||||
if not raw.strip():
|
||||
return []
|
||||
entries = [e.strip() for e in raw.split(ENTRY_DELIMITER)]
|
||||
return [e for e in entries if e]
|
||||
|
||||
|
||||
def _write_entries(path: Path, entries: List[str]) -> None:
|
||||
"""Write entries back to a memory file."""
|
||||
content = ENTRY_DELIMITER.join(entries) if entries else ""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def analyze_file(path: Path, label: str, char_limit: int) -> FileReport:
|
||||
"""Analyze a single memory file against its budget."""
|
||||
exists = path.exists()
|
||||
entries = _read_entries(path) if exists else []
|
||||
raw_chars = path.stat().st_size if exists else 0
|
||||
joined = ENTRY_DELIMITER.join(entries)
|
||||
return FileReport(
|
||||
label=label,
|
||||
path=path,
|
||||
exists=exists,
|
||||
char_limit=char_limit,
|
||||
raw_chars=raw_chars,
|
||||
entry_chars=len(joined),
|
||||
entry_count=len(entries),
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
def trim_entries(report: FileReport) -> List[str]:
|
||||
"""Trim oldest entries until the file fits within its budget.
|
||||
|
||||
Entries are removed from the front (oldest first) because memory files
|
||||
append new entries at the end.
|
||||
"""
|
||||
entries = list(report.entries)
|
||||
joined = ENTRY_DELIMITER.join(entries)
|
||||
|
||||
while len(joined) > report.char_limit and entries:
|
||||
entries.pop(0)
|
||||
joined = ENTRY_DELIMITER.join(entries)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _bar(pct: float, width: int = 30) -> str:
|
||||
"""Render a text progress bar."""
|
||||
filled = int(pct / 100 * width)
|
||||
bar = "#" * filled + "-" * (width - filled)
|
||||
return f"[{bar}]"
|
||||
|
||||
|
||||
def print_report(memory: FileReport, user: FileReport, *, verbose: bool = False) -> None:
|
||||
"""Print a human-readable budget report."""
|
||||
total_chars = memory.entry_chars + user.entry_chars
|
||||
total_limit = memory.char_limit + user.char_limit
|
||||
total_tokens = total_chars // CHARS_PER_TOKEN
|
||||
total_pct = (total_chars / total_limit * 100) if total_limit > 0 else 0
|
||||
|
||||
print("=" * 60)
|
||||
print(" MEMORY BUDGET REPORT")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
for rpt in (memory, user):
|
||||
status = "OVER " if rpt.over_budget else ("WARN" if rpt.warning else " OK ")
|
||||
print(f" {rpt.label:12s} {status} {_bar(rpt.usage_pct)} {rpt.usage_pct:5.1f}%")
|
||||
print(f" {'':12s} {rpt.entry_chars:,}/{rpt.char_limit:,} chars "
|
||||
f"| {rpt.entry_count} entries "
|
||||
f"| ~{rpt.estimated_tokens:,} tokens")
|
||||
if rpt.exists and verbose and rpt.entries:
|
||||
for i, entry in enumerate(rpt.entries):
|
||||
preview = entry[:72].replace("\n", " ")
|
||||
if len(entry) > 72:
|
||||
preview += "..."
|
||||
print(f" #{i+1}: ({len(entry)} chars) {preview}")
|
||||
print()
|
||||
|
||||
print(f" TOTAL {_bar(total_pct)} {total_pct:5.1f}%")
|
||||
print(f" {total_chars:,}/{total_limit:,} chars | ~{total_tokens:,} tokens")
|
||||
print()
|
||||
|
||||
# Alerts
|
||||
alerts = []
|
||||
for rpt in (memory, user):
|
||||
if rpt.over_budget:
|
||||
overshoot = rpt.entry_chars - rpt.char_limit
|
||||
alerts.append(
|
||||
f" CRITICAL {rpt.label} is {overshoot:,} chars over budget "
|
||||
f"({rpt.entry_chars:,}/{rpt.char_limit:,}). "
|
||||
f"Run with --enforce to auto-trim."
|
||||
)
|
||||
elif rpt.warning:
|
||||
alerts.append(
|
||||
f" WARNING {rpt.label} is at {rpt.usage_pct:.0f}% capacity. "
|
||||
f"Consider compressing or cleaning up entries."
|
||||
)
|
||||
|
||||
if alerts:
|
||||
print(" ALERTS")
|
||||
print(" ------")
|
||||
for a in alerts:
|
||||
print(a)
|
||||
print()
|
||||
|
||||
|
||||
def print_json(memory: FileReport, user: FileReport) -> None:
|
||||
"""Print a JSON report for machine consumption."""
|
||||
import json
|
||||
|
||||
def _rpt_dict(r: FileReport) -> dict:
|
||||
return {
|
||||
"label": r.label,
|
||||
"path": str(r.path),
|
||||
"exists": r.exists,
|
||||
"char_limit": r.char_limit,
|
||||
"entry_chars": r.entry_chars,
|
||||
"entry_count": r.entry_count,
|
||||
"estimated_tokens": r.estimated_tokens,
|
||||
"usage_pct": round(r.usage_pct, 1),
|
||||
"over_budget": r.over_budget,
|
||||
"warning": r.warning,
|
||||
"remaining_chars": r.remaining_chars,
|
||||
}
|
||||
|
||||
total_chars = memory.entry_chars + user.entry_chars
|
||||
total_limit = memory.char_limit + user.char_limit
|
||||
|
||||
data = {
|
||||
"memory": _rpt_dict(memory),
|
||||
"user": _rpt_dict(user),
|
||||
"total": {
|
||||
"chars": total_chars,
|
||||
"limit": total_limit,
|
||||
"estimated_tokens": total_chars // CHARS_PER_TOKEN,
|
||||
"usage_pct": round((total_chars / total_limit * 100) if total_limit else 0, 1),
|
||||
"over_budget": memory.over_budget or user.over_budget,
|
||||
"warning": memory.warning or user.warning,
|
||||
},
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_hermes_home(custom: str | None) -> Path:
|
||||
"""Resolve HERMES_HOME directory."""
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
import os
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check and enforce memory budgets for hermes-agent.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hermes-home", metavar="DIR",
|
||||
help="Custom HERMES_HOME directory (default: $HERMES_HOME or ~/.hermes)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--memory-limit", type=int, default=DEFAULT_MEMORY_CHAR_LIMIT,
|
||||
help=f"Character limit for MEMORY.md (default: {DEFAULT_MEMORY_CHAR_LIMIT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-limit", type=int, default=DEFAULT_USER_CHAR_LIMIT,
|
||||
help=f"Character limit for USER.md (default: {DEFAULT_USER_CHAR_LIMIT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report", action="store_true",
|
||||
help="Print detailed per-file budget report",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v", action="store_true",
|
||||
help="Show individual entry details in report",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enforce", action="store_true",
|
||||
help="Trim oldest entries to fit within budget (writes to disk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", dest="json_output",
|
||||
help="Output report as JSON (for CI/scripting)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
hermes_home = _resolve_hermes_home(args.hermes_home)
|
||||
memories_dir = hermes_home / "memories"
|
||||
|
||||
# Analyze both files
|
||||
memory = analyze_file(
|
||||
memories_dir / "MEMORY.md", "MEMORY.md", args.memory_limit,
|
||||
)
|
||||
user = analyze_file(
|
||||
memories_dir / "USER.md", "USER.md", args.user_limit,
|
||||
)
|
||||
|
||||
over_budget = memory.over_budget or user.over_budget
|
||||
trimmed = False
|
||||
|
||||
# Enforce budget by trimming entries
|
||||
if args.enforce and over_budget:
|
||||
for rpt in (memory, user):
|
||||
if rpt.over_budget and rpt.exists:
|
||||
trimmed_entries = trim_entries(rpt)
|
||||
removed = rpt.entry_count - len(trimmed_entries)
|
||||
if removed > 0:
|
||||
_write_entries(rpt.path, trimmed_entries)
|
||||
rpt.entries = trimmed_entries
|
||||
rpt.entry_count = len(trimmed_entries)
|
||||
rpt.entry_chars = len(ENTRY_DELIMITER.join(trimmed_entries))
|
||||
rpt.raw_chars = rpt.path.stat().st_size
|
||||
print(f" Trimmed {removed} oldest entries from {rpt.label} "
|
||||
f"({rpt.entry_chars:,}/{rpt.char_limit:,} chars now)")
|
||||
trimmed = True
|
||||
# Re-check after trimming
|
||||
over_budget = memory.over_budget or user.over_budget
|
||||
|
||||
# Output
|
||||
if args.json_output:
|
||||
print_json(memory, user)
|
||||
elif args.report or args.verbose:
|
||||
print_report(memory, user, verbose=args.verbose)
|
||||
else:
|
||||
# Compact summary
|
||||
if over_budget:
|
||||
print("Memory budget: OVER")
|
||||
for rpt in (memory, user):
|
||||
if rpt.over_budget:
|
||||
print(f" {rpt.label}: {rpt.entry_chars:,}/{rpt.char_limit:,} chars "
|
||||
f"({rpt.usage_pct:.0f}%)")
|
||||
elif memory.warning or user.warning:
|
||||
print("Memory budget: WARNING")
|
||||
for rpt in (memory, user):
|
||||
if rpt.warning:
|
||||
print(f" {rpt.label}: {rpt.entry_chars:,}/{rpt.char_limit:,} chars "
|
||||
f"({rpt.usage_pct:.0f}%)")
|
||||
else:
|
||||
print("Memory budget: OK")
|
||||
for rpt in (memory, user):
|
||||
if rpt.exists:
|
||||
print(f" {rpt.label}: {rpt.entry_chars:,}/{rpt.char_limit:,} chars "
|
||||
f"({rpt.usage_pct:.0f}%)")
|
||||
|
||||
# Suggest actions when over budget but not enforced
|
||||
if over_budget and not args.enforce:
|
||||
suggestions = []
|
||||
for rpt in (memory, user):
|
||||
if rpt.over_budget:
|
||||
suggestions.append(
|
||||
f" - {rpt.label}: remove stale entries or run with --enforce to auto-trim"
|
||||
)
|
||||
# Identify largest entries
|
||||
if rpt.entries:
|
||||
indexed = sorted(enumerate(rpt.entries), key=lambda x: len(x[1]), reverse=True)
|
||||
top3 = indexed[:3]
|
||||
for idx, entry in top3:
|
||||
preview = entry[:60].replace("\n", " ")
|
||||
if len(entry) > 60:
|
||||
preview += "..."
|
||||
suggestions.append(
|
||||
f" largest entry #{idx+1}: ({len(entry)} chars) {preview}"
|
||||
)
|
||||
if suggestions:
|
||||
print()
|
||||
print("Suggestions:")
|
||||
for s in suggestions:
|
||||
print(s)
|
||||
|
||||
# Exit code
|
||||
if trimmed:
|
||||
return 2
|
||||
if over_budget:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -895,7 +895,7 @@ class TestKimiMoonshotModelListIsolation:
|
||||
def test_moonshot_list_excludes_coding_plan_only_models(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
||||
coding_plan_only = {"kimi-for-coding", "kimi-k2-thinking-turbo"}
|
||||
coding_plan_only = {"kimi-k2.5", "kimi-k2-thinking-turbo"}
|
||||
leaked = set(moonshot_models) & coding_plan_only
|
||||
assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}"
|
||||
|
||||
@@ -908,7 +908,7 @@ class TestKimiMoonshotModelListIsolation:
|
||||
def test_coding_plan_list_contains_plan_specific_models(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
coding_models = _PROVIDER_MODELS["kimi-coding"]
|
||||
assert "kimi-for-coding" in coding_models
|
||||
assert "kimi-k2.5" in coding_models
|
||||
assert "kimi-k2-thinking-turbo" in coding_models
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ hermes chat --provider zai --model glm-5
|
||||
# Requires: GLM_API_KEY in ~/.hermes/.env
|
||||
|
||||
# Kimi / Moonshot AI
|
||||
hermes chat --provider kimi-coding --model kimi-for-coding
|
||||
hermes chat --provider kimi-coding --model kimi-k2.5
|
||||
# Requires: KIMI_API_KEY in ~/.hermes/.env
|
||||
|
||||
# MiniMax (global endpoint)
|
||||
|
||||
Reference in New Issue
Block a user