Преглед на файлове

Polish Hermes dashboard and polling

Lukas Goldschmidt преди 3 седмици
родител
ревизия
5c2a0e5e8e
променени са 12 файла, в които са добавени 793 реда и са изтрити 12 реда
  1. 11 0
      .env.example
  2. 1 0
      .gitignore
  3. 61 0
      DB_SCHEME.md
  4. 45 0
      HERMES_CONCERNS.md
  5. 121 0
      HERMES_CYCLE.md
  6. 74 0
      src/hermes_mcp/config.py
  7. 29 0
      src/hermes_mcp/crypto_client.py
  8. 76 9
      src/hermes_mcp/dashboard.py
  9. 41 1
      src/hermes_mcp/server.py
  10. 292 2
      src/hermes_mcp/store.py
  11. 25 0
      src/hermes_mcp/trader_client.py
  12. 17 0
      tests/test_schema.py

+ 11 - 0
.env.example

@@ -0,0 +1,11 @@
+HERMES_TRADER_URL=http://192.168.0.249:8570/mcp/sse
+HERMES_CRYPTO_URL=http://192.168.0.200:8505/mcp/sse
+HERMES_METALS_URL=http://192.168.0.249:8515/mcp/sse
+HERMES_NEWS_URL=http://192.168.0.200:8506/mcp/sse
+HERMES_ATLAS_URL=http://192.168.0.249:8550/mcp/sse
+HERMES_EXEC_URL=http://192.168.0.249:8560/mcp/sse
+HERMES_CRYPTO_TIMEFRAMES=1m,5m,15m,1h,4h,1d
+HERMES_RETENTION_DAYS=7
+HERMES_PRUNE_INTERVAL_HOURS=6
+HERMES_CYCLE_SECONDS=60
+HERMES_ALLOW_AUTO_ACTIONS=false

+ 1 - 0
.gitignore

@@ -5,4 +5,5 @@ __pycache__/
 *.log
 logs/
 data/*.sqlite3
+.env
 .DS_Store

+ 61 - 0
DB_SCHEME.md

@@ -0,0 +1,61 @@
+# Hermes MCP DB Scheme
+
+Hermes uses a compact SQLite schema with one job per table.
+
+## 1. concerns
+What Hermes currently cares about.
+
+- one row per account/market scope
+- source tracks where the concern came from (`trader_inventory`, `hermes_config`, `manual`)
+- status marks whether Hermes should actively watch it
+
+## 2. cycles
+One Hermes evaluation pass.
+
+- records the periodic polling loop
+- ties observations, derived state, narratives, decisions, and actions together
+
+## 3. observations
+Raw adapter outputs.
+
+- stored once per cycle, concern, and source
+- payload remains unparsed JSON
+- this is the fact layer, not the interpretation layer
+
+## 4. states
+Derived structured state.
+
+- compact signals like regime, liquidity, sentiment pressure, event risk, execution quality
+- one row per cycle and concern
+- intended for query and dashboard use
+
+## 5. narratives
+Hermes’ synthesis.
+
+- one row per cycle and concern
+- concise human-readable summary plus key drivers, risk flags, and uncertainties
+
+## 6. decisions
+Control intent.
+
+- what Hermes decided or recommends
+- can be observe, recommend, or act
+- stores target strategy and policy when relevant
+
+## 7. actions
+Outbound effect log.
+
+- what was sent to Trader
+- request/response are stored as JSON
+- keeps decision and execution separate
+
+## 8. coverage_gaps
+Gap discovery and recommendations.
+
+- missing strategy, market, or account coverage
+- keeps Hermes aware of blind spots without conflating them with decisions
+
+## Core flow
+`concern -> cycle -> observations -> state -> narrative -> decision -> action`
+
+Coverage gaps are attached to the same cycle and can be linked to a concern when the gap is specific.

+ 45 - 0
HERMES_CONCERNS.md

@@ -0,0 +1,45 @@
+# Hermes Concerns
+
+This note captures the planned concern discovery and configuration flow.
+
+## Goal
+Before Hermes can run cycles, it must know what to watch.
+
+Concerns are the input matrix. They define the account/market scopes Hermes should observe.
+
+## Concern sources
+Concerns can come from:
+- Hermes configuration
+- Trader strategy inventory
+- manual operator entry
+
+## Preferred order
+1. Load Hermes config
+2. Mirror Trader strategy inventory
+3. Build the concern list from account + market coverage
+4. Deduplicate concerns
+5. Mark which concerns are active, paused, or informational only
+6. Persist the concern registry
+7. Use the registry as the stable input matrix for cycle polling
+
+## Discovery rules
+Hermes should discover:
+- which accounts exist
+- which markets each strategy is tied to
+- whether multiple strategies share the same concern
+- which concerns are missing coverage
+
+## Configuration rules
+Hermes config should define:
+- watched accounts
+- watched markets
+- optional concern priority
+- optional manual overrides
+- whether Hermes may only recommend or may also act
+
+## Important principle
+Trader is the authority for live strategy instances.
+Hermes may mirror and reason about them, but v1 should not auto-create instances unless a later policy explicitly allows it.
+
+## What Hermes should remember
+The concern registry should be stable enough that later cycle code can rely on it without guessing.

+ 121 - 0
HERMES_CYCLE.md

@@ -0,0 +1,121 @@
+# Hermes Cycle
+
+This note captures the working model for Hermes as discussed in chat.
+
+## Purpose
+Hermes sits above Trader, Crypto, Metals, and News. It observes the world, evaluates coverage, derives state, forms a narrative, makes a decision, and then optionally acts on Trader.
+
+Hermes is read-mostly and explainable. It should not become a trading engine itself.
+
+## Core idea
+Hermes runs in cycles.
+
+Each cycle answers:
+- What concerns do we have?
+- What changed since the last cycle?
+- What does the state mean?
+- What narrative best explains the current picture?
+- What decision should follow?
+- Should Hermes act, or only recommend?
+- Did anything remain uncovered?
+
+## Cycle inputs
+### Concerns
+Concerns define what Hermes watches.
+
+Examples:
+- account alpha, market xrpusd
+- account beta, market btcusd
+- strategy coverage on a trader instance
+
+Concerns can come from:
+- Hermes config
+- Trader inventory
+- manual registration
+
+Hermes should mirror Trader coverage, but in v1 it should not create instances automatically.
+
+### Sources
+Primary sources for v1:
+- crypto-mcp, especially market regime
+- trader-mcp, especially strategy inventory and execution context
+
+Later sources:
+- news-mcp
+- metals-mcp
+- additional sentiment inputs
+
+## Cycle stages
+### 1. Poll
+For each concern, Hermes polls the relevant adapters.
+
+Example:
+- crypto-mcp returns market regime for the concern’s assets
+- trader-mcp returns strategy inventory and control state
+
+### 2. Store observations
+Hermes stores raw adapter outputs as observations.
+
+This layer is factual and should not interpret anything.
+
+### 3. Derive state
+Hermes converts observations into compact state fields.
+
+Examples:
+- market_regime
+- volatility_state
+- liquidity_state
+- sentiment_pressure
+- event_risk
+- execution_quality
+
+### 4. Build narrative
+Hermes writes a short explanation of the current picture.
+
+The narrative should be human-readable, but still structured enough for dashboards and debugging.
+
+### 5. Make decision
+Hermes decides one of the following:
+- observe
+- recommend
+- act
+
+If acting, Hermes may send a control command to Trader.
+
+### 6. Execute action
+Hermes logs the outbound action and the response.
+
+Decision and execution must remain separate.
+
+### 7. Record gaps
+If Hermes finds missing coverage, it records a gap and recommendation.
+
+This is important and should not be merged into decisions.
+
+## Data model
+The SQLite schema should keep these layers separate:
+- concerns
+- cycles
+- observations
+- states
+- narratives
+- decisions
+- actions
+- coverage_gaps
+
+## First implementation target
+Start with:
+1. concerns registry
+2. cycle runner
+3. crypto polling
+4. raw observation storage
+5. state derivation
+6. narrative stub
+7. decision stub
+8. action logging stub
+
+That gives Hermes a real spine before we add more inputs.
+
+## Guiding rule
+Do not store the same idea twice.
+Raw facts, derived state, prose, and control intent all belong in different places.

+ 74 - 0
src/hermes_mcp/config.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import os
+
+ROOT = Path(__file__).resolve().parents[2]
+ENV_PATH = ROOT / ".env"
+
+
+def _load_env_file(path: Path = ENV_PATH) -> dict[str, str]:
+    values: dict[str, str] = {}
+    if not path.exists():
+        return values
+    for raw in path.read_text().splitlines():
+        line = raw.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        values[key.strip()] = value.strip().strip('"').strip("'")
+    return values
+
+
+def _env(name: str, default: str = "") -> str:
+    file_values = _load_env_file()
+    return os.getenv(name, file_values.get(name, default))
+
+
+def _env_bool(name: str, default: bool = False) -> bool:
+    value = _env(name, "true" if default else "false").strip().lower()
+    return value in {"1", "true", "yes", "on"}
+
+
+def _env_int(name: str, default: int) -> int:
+    try:
+        return int(_env(name, str(default)))
+    except Exception:
+        return default
+
+
+@dataclass(frozen=True)
+class HermesConfig:
+    trader_url: str
+    crypto_url: str
+    metals_url: str
+    news_url: str
+    atlas_url: str
+    exec_url: str
+    crypto_timeframes: tuple[str, ...]
+    retention_days: int
+    prune_interval_hours: int
+    cycle_seconds: int
+    allow_auto_actions: bool
+
+
+def load_config() -> HermesConfig:
+    timeframes = tuple(
+        part.strip()
+        for part in _env("HERMES_CRYPTO_TIMEFRAMES", "5m,15m,1h,4h,1d").split(",")
+        if part.strip()
+    )
+    return HermesConfig(
+        trader_url=_env("HERMES_TRADER_URL", "http://127.0.0.1:8570"),
+        crypto_url=_env("HERMES_CRYPTO_URL", "http://127.0.0.1:8580"),
+        metals_url=_env("HERMES_METALS_URL", "http://127.0.0.1:8591"),
+        news_url=_env("HERMES_NEWS_URL", "http://127.0.0.1:8600"),
+        atlas_url=_env("HERMES_ATLAS_URL", "http://127.0.0.1:8550"),
+        exec_url=_env("HERMES_EXEC_URL", "http://127.0.0.1:8560"),
+        crypto_timeframes=timeframes,
+        retention_days=_env_int("HERMES_RETENTION_DAYS", 7),
+        prune_interval_hours=_env_int("HERMES_PRUNE_INTERVAL_HOURS", 6),
+        cycle_seconds=_env_int("HERMES_CYCLE_SECONDS", 60),
+        allow_auto_actions=_env_bool("HERMES_ALLOW_AUTO_ACTIONS", False),
+    )

+ 29 - 0
src/hermes_mcp/crypto_client.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from typing import Any
+import json
+
+import anyio
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+
+async def get_regime(base_url: str, symbol: str, timeframe: str = "1h") -> dict[str, Any]:
+    async with sse_client(base_url) as (read_stream, write_stream):
+        async with ClientSession(read_stream, write_stream) as session:
+            await session.initialize()
+            result = await session.call_tool("get_regime", {"symbol": symbol, "timeframe": timeframe})
+            content = getattr(result, "content", None)
+            if not content:
+                return {"error": "EMPTY_RESULT", "symbol": symbol, "timeframe": timeframe}
+            # FastMCP commonly returns tool results as text content blocks.
+            first = content[0]
+            text = getattr(first, "text", None)
+            if text is None and isinstance(first, dict):
+                text = first.get("text")
+            if text is None:
+                return {"error": "UNPARSEABLE_RESULT", "symbol": symbol, "timeframe": timeframe}
+            try:
+                return json.loads(text)
+            except Exception:
+                return {"raw": text, "symbol": symbol, "timeframe": timeframe}

+ 76 - 9
src/hermes_mcp/dashboard.py

@@ -1,29 +1,96 @@
 from fastapi import APIRouter
 from fastapi.responses import HTMLResponse
 
+from .store import list_concerns, latest_cycle, latest_regime_samples
+
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
 
 @router.get("/", response_class=HTMLResponse)
 def overview():
+    concerns = list_concerns()
+    cycle = latest_cycle() or {}
+    regimes = latest_regime_samples(10)
+    regime_rows = "".join(
+        f"<tr><td>{r.get('concern_id','')}</td><td>{r.get('timeframe','')}</td><td><pre style='white-space:pre-wrap;margin:0'>{r.get('regime_json','')}</pre></td><td>{r.get('captured_at','')}</td></tr>"
+        for r in regimes
+    ) or "<tr><td colspan='4' class='muted'>No regime samples yet.</td></tr>"
+    concern_rows = "".join(
+        f"<tr><td>{c.get('id','')}</td><td>{c.get('account_id','')}</td><td>{c.get('market_symbol','')}</td><td>{c.get('base_currency','')}</td><td>{c.get('quote_currency','')}</td><td>{c.get('strategy_id','')}</td><td>{c.get('source','')}</td><td>{c.get('status','')}</td></tr>"
+        for c in concerns
+    ) or "<tr><td colspan='8' class='muted'>No concerns yet.</td></tr>"
     return """
-    <html><head><title>Hermes Dashboard</title></head>
+    <html>
+    <head>
+      <title>Hermes MCP Dashboard</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1600px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .muted { color: #6b7280; }
+        table { width: 100%; border-collapse: collapse; margin-top: 14px; }
+        th, td { border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }
+        th { background: #f9fafb; }
+        .pill { display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }
+        .nav { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }
+        .nav a { text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }
+        pre { white-space: pre-wrap; margin: 0; }
+      </style>
+    </head>
     <body>
-      <h1>Hermes</h1>
-      <p>Overview, signals, features, narrative, decision, explanation.</p>
-      <ul>
-        <li><a href="/dashboard/tech">Tech monitor</a></li>
-      </ul>
+      <div class="page"><div class="card">
+      <h1>Hermes MCP Dashboard</h1>
+      <p class="muted">Overview, signals, features, narrative, decision, explanation.</p>
+      <div class="nav">
+        <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/tech">Tech monitor</a>
+      </div>
+      <h2>Last poll</h2>
+      <p><span class="pill">{cycle_status}</span></p>
+      <p><strong>started:</strong> {cycle_started}</p>
+      <p><strong>finished:</strong> {cycle_finished}</p>
+      <p><strong>notes:</strong> {cycle_notes}</p>
+      <h2>Concerns</h2>
+      <table>
+        <tr><th>id</th><th>account</th><th>market</th><th>base</th><th>quote</th><th>strategy</th><th>source</th><th>status</th></tr>
+        {concern_rows}
+      </table>
+      <h2>Latest regime samples</h2>
+      <table>
+        <tr><th>concern</th><th>timeframe</th><th>regime</th><th>captured</th></tr>
+        {regime_rows}
+      </table>
+      </div></div>
     </body></html>
-    """
+    """.format(
+        cycle_status=cycle.get("status", "none"),
+        cycle_started=cycle.get("started_at", "-"),
+        cycle_finished=cycle.get("finished_at", "-"),
+        cycle_notes=cycle.get("notes", "-"),
+        concern_rows=concern_rows,
+        regime_rows=regime_rows,
+    )
 
 
 @router.get("/tech", response_class=HTMLResponse)
 def tech():
     return """
-    <html><head><title>Hermes Tech Monitor</title></head>
+    <html>
+    <head>
+      <title>Hermes Tech Monitor</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1600px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .muted { color: #6b7280; }
+      </style>
+    </head>
     <body>
+      <div class="page"><div class="card">
       <h1>Tech Monitor</h1>
-      <p>Signals, features, state, narrative, decision, explanation.</p>
+      <p class="muted">Signals, features, state, narrative, decision, explanation.</p>
+      </div></div>
     </body></html>
     """

+ 41 - 1
src/hermes_mcp/server.py

@@ -1,12 +1,18 @@
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
+import asyncio
+from datetime import datetime, timezone
+from uuid import uuid4
 
 from fastapi import FastAPI
 from mcp.server.fastmcp import FastMCP
 from mcp.server.transport_security import TransportSecuritySettings
 
-from .store import get_state, init_db
+from .config import load_config
+from .crypto_client import get_regime
+from .store import get_state, init_db, list_concerns, latest_cycle, latest_regime_samples, prune_older_than, sync_concerns_from_strategies, upsert_cycle, upsert_regime_sample
+from .trader_client import list_strategies
 
 mcp = FastMCP(
     "hermes-mcp",
@@ -28,7 +34,41 @@ def report() -> dict:
 
 @asynccontextmanager
 async def lifespan(_: FastAPI):
+    cfg = load_config()
     init_db()
+    try:
+        sync_concerns_from_strategies(list_strategies(cfg.trader_url))
+    except Exception:
+        pass
+    try:
+        prune_older_than(cfg.retention_days)
+    except Exception:
+        pass
+
+    async def _poll_loop() -> None:
+        while True:
+            started = datetime.now(timezone.utc).isoformat()
+            cycle_id = str(uuid4())
+            concerns = list_concerns()
+            upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
+            for concern in concerns:
+                symbol = concern.get("base_currency") or concern.get("market_symbol")
+                if not symbol:
+                    continue
+                for timeframe in cfg.crypto_timeframes:
+                    regime = await get_regime(cfg.crypto_url, str(symbol), timeframe)
+                    upsert_regime_sample(
+                        id=f"{cycle_id}:{concern['id']}:{timeframe}",
+                        cycle_id=cycle_id,
+                        concern_id=str(concern["id"]),
+                        timeframe=timeframe,
+                        regime_json=str(regime),
+                        captured_at=datetime.now(timezone.utc).isoformat(),
+                    )
+            upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
+            await asyncio.sleep(max(10, cfg.cycle_seconds))
+
+    asyncio.create_task(_poll_loop())
     yield
 
 

+ 292 - 2
src/hermes_mcp/store.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import json
 import sqlite3
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Any
 
@@ -9,16 +10,162 @@ ROOT = Path(__file__).resolve().parents[2]
 DATA_DIR = ROOT / "data"
 DB_PATH = DATA_DIR / "hermes_mcp.sqlite3"
 
+SCHEMA_STATEMENTS = [
+    """
+    create table if not exists concerns (
+      id text primary key,
+      account_id text,
+      market_symbol text,
+      base_currency text,
+      quote_currency text,
+      strategy_id text,
+      source text not null,
+      status text not null default 'active',
+      notes text,
+      created_at text not null,
+      updated_at text not null
+    )
+    """,
+    """
+    create table if not exists cycles (
+      id text primary key,
+      started_at text not null,
+      finished_at text,
+      status text not null default 'running',
+      trigger text not null,
+      notes text
+    )
+    """,
+    """
+    create table if not exists observations (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text,
+      source text not null,
+      kind text not null,
+      payload_json text not null,
+      observed_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    """
+    create table if not exists states (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text not null,
+      market_regime text,
+      volatility_state text,
+      liquidity_state text,
+      sentiment_pressure text,
+      event_risk text,
+      execution_quality text,
+      confidence real,
+      payload_json text not null,
+      created_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    """
+    create table if not exists narratives (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text not null,
+      summary text not null,
+      key_drivers_json text not null,
+      risk_flags_json text not null,
+      uncertainties_json text not null,
+      confidence real,
+      created_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    """
+    create table if not exists decisions (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text not null,
+      mode text not null,
+      action text not null,
+      target_strategy text,
+      target_policy_json text,
+      reason_summary text,
+      confidence real,
+      requires_action integer not null default 0,
+      created_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    """
+    create table if not exists actions (
+      id text primary key,
+      decision_id text not null,
+      target text not null,
+      command text not null,
+      request_json text not null,
+      response_json text,
+      status text not null default 'pending',
+      executed_at text,
+      foreign key(decision_id) references decisions(id)
+    )
+    """,
+    """
+    create table if not exists coverage_gaps (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text,
+      gap_type text not null,
+      summary text not null,
+      recommendation_json text not null,
+      status text not null default 'open',
+      created_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    """
+    create table if not exists regime_samples (
+      id text primary key,
+      cycle_id text not null,
+      concern_id text not null,
+      timeframe text not null,
+      regime_json text not null,
+      captured_at text not null,
+      foreign key(cycle_id) references cycles(id),
+      foreign key(concern_id) references concerns(id)
+    )
+    """,
+    "create index if not exists idx_observations_cycle on observations(cycle_id)",
+    "create index if not exists idx_observations_concern on observations(concern_id)",
+    "create index if not exists idx_states_cycle on states(cycle_id)",
+    "create index if not exists idx_states_concern on states(concern_id)",
+    "create index if not exists idx_narratives_cycle on narratives(cycle_id)",
+    "create index if not exists idx_decisions_cycle on decisions(cycle_id)",
+    "create index if not exists idx_actions_decision on actions(decision_id)",
+    "create index if not exists idx_gaps_cycle on coverage_gaps(cycle_id)",
+    "create index if not exists idx_regime_samples_concern on regime_samples(concern_id)",
+]
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
 
 def _connect() -> sqlite3.Connection:
     DATA_DIR.mkdir(parents=True, exist_ok=True)
     conn = sqlite3.connect(DB_PATH)
     conn.row_factory = sqlite3.Row
+    conn.execute("pragma foreign_keys = on")
     return conn
 
 
 def init_db() -> None:
     with _connect() as conn:
+        for stmt in SCHEMA_STATEMENTS:
+            conn.execute(stmt)
         conn.execute(
             """
             create table if not exists state (
@@ -27,7 +174,12 @@ def init_db() -> None:
               updated_at text not null default current_timestamp
             )
             """
-        )
+            )
+
+        concern_columns = {row[1] for row in conn.execute("pragma table_info(concerns)").fetchall()}
+        for column in ("base_currency", "quote_currency"):
+            if column not in concern_columns:
+                conn.execute(f"alter table concerns add column {column} text")
 
 
 def get_state() -> dict[str, Any]:
@@ -38,7 +190,7 @@ def get_state() -> dict[str, Any]:
             return {
                 "status": "stub",
                 "thinking": "Hermes is scaffolded and waiting for integrations.",
-                "layers": ["overview", "signals", "features", "narrative", "decision", "explanation"],
+                "layers": ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps"],
             }
         return json.loads(row["value"])
 
@@ -50,3 +202,141 @@ def put_state(payload: dict[str, Any]) -> None:
             "insert into state(key, value, updated_at) values(?, ?, current_timestamp) on conflict(key) do update set value=excluded.value, updated_at=current_timestamp",
             ("snapshot", json.dumps(payload)),
         )
+
+
+def upsert_concern(*, id: str, account_id: str | None, market_symbol: str | None, base_currency: str | None = None, quote_currency: str | None = None, strategy_id: str | None, source: str, status: str = "active", notes: str | None = None) -> None:
+    init_db()
+    now = _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into concerns(id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, created_at, updated_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              account_id=excluded.account_id,
+              market_symbol=excluded.market_symbol,
+              base_currency=excluded.base_currency,
+              quote_currency=excluded.quote_currency,
+              strategy_id=excluded.strategy_id,
+              source=excluded.source,
+              status=excluded.status,
+              notes=excluded.notes,
+              updated_at=excluded.updated_at
+            """,
+            (id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, now, now),
+        )
+
+
+def list_concerns() -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from concerns order by updated_at desc").fetchall()
+    return [dict(r) for r in rows]
+
+
+def prune_older_than(days: int) -> dict[str, int]:
+    init_db()
+    cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
+    cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
+    with _connect() as conn:
+        deleted = {}
+        for table in ("actions", "decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"):
+            if table == "actions":
+                where = "executed_at is not null and executed_at < ?"
+            elif table in {"decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"}:
+                where = "created_at < ?" if table != "cycles" else "started_at < ?"
+            else:
+                continue
+            cur = conn.execute(f"delete from {table} where {where}", (cutoff_iso,))
+            deleted[table] = cur.rowcount if cur.rowcount is not None else 0
+    return deleted
+
+
+def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    seen: set[str] = set()
+    synced: list[dict[str, Any]] = []
+    for s in strategies:
+        account_id = str(s.get("account_id") or "").strip() or None
+        market_symbol = str(s.get("market_symbol") or "").strip() or None
+        base_currency = str(s.get("base_currency") or "").strip() or None
+        quote_currency = str(s.get("counter_currency") or s.get("quote_currency") or "").strip() or None
+        strategy_id = str(s.get("id") or s.get("name") or s.get("strategy_type") or "").strip() or None
+        if not account_id or not market_symbol:
+            continue
+        concern_id = f"{account_id}:{market_symbol}"
+        if concern_id in seen:
+            continue
+        seen.add(concern_id)
+        upsert_concern(
+            id=concern_id,
+            account_id=account_id,
+            market_symbol=market_symbol,
+            base_currency=base_currency,
+            quote_currency=quote_currency,
+            strategy_id=strategy_id,
+            source="trader_inventory",
+            status="active",
+            notes="mirrored from trader strategy inventory",
+        )
+        synced.append({"id": concern_id, "account_id": account_id, "market_symbol": market_symbol, "base_currency": base_currency, "quote_currency": quote_currency, "strategy_id": strategy_id})
+    return synced
+
+
+def table_counts() -> dict[str, int]:
+    init_db()
+    tables = ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps", "regime_samples"]
+    out: dict[str, int] = {}
+    with _connect() as conn:
+        for table in tables:
+            out[table] = int(conn.execute(f"select count(*) as n from {table}").fetchone()["n"])
+    return out
+
+
+def latest_cycle() -> dict[str, Any] | None:
+    init_db()
+    with _connect() as conn:
+        row = conn.execute("select * from cycles order by started_at desc limit 1").fetchone()
+    return dict(row) if row else None
+
+
+def upsert_cycle(*, id: str, started_at: str, finished_at: str | None, status: str, trigger: str, notes: str | None = None) -> None:
+    init_db()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into cycles(id, started_at, finished_at, status, trigger, notes)
+            values(?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              started_at=excluded.started_at,
+              finished_at=excluded.finished_at,
+              status=excluded.status,
+              trigger=excluded.trigger,
+              notes=excluded.notes
+            """,
+            (id, started_at, finished_at, status, trigger, notes),
+        )
+
+
+def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe: str, regime_json: str, captured_at: str) -> None:
+    init_db()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into regime_samples(id, cycle_id, concern_id, timeframe, regime_json, captured_at)
+            values(?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              cycle_id=excluded.cycle_id,
+              concern_id=excluded.concern_id,
+              timeframe=excluded.timeframe,
+              regime_json=excluded.regime_json,
+              captured_at=excluded.captured_at
+            """,
+            (id, cycle_id, concern_id, timeframe, regime_json, captured_at),
+        )
+
+
+def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]

+ 25 - 0
src/hermes_mcp/trader_client.py

@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from typing import Any
+from urllib.request import urlopen
+import json
+
+
+def list_strategies(base_url: str) -> list[dict[str, Any]]:
+    root = base_url.rstrip('/')
+    if root.endswith('/mcp/sse'):
+        root = root[:-8]
+    with urlopen(f"{root}/strategies", timeout=10) as resp:
+        payload = json.loads(resp.read().decode("utf-8"))
+    strategies = payload.get("configured", []) or []
+    return [s for s in strategies if isinstance(s, dict)]
+
+
+def list_accounts(base_url: str) -> list[dict[str, Any]]:
+    root = base_url.rstrip('/')
+    if root.endswith('/mcp/sse'):
+        root = root[:-8]
+    with urlopen(f"{root}/accounts", timeout=10) as resp:
+        payload = json.loads(resp.read().decode("utf-8"))
+    accounts = payload.get("accounts", []) or []
+    return [a for a in accounts if isinstance(a, dict)]

+ 17 - 0
tests/test_schema.py

@@ -0,0 +1,17 @@
+from hermes_mcp.store import init_db, table_counts
+
+
+def test_schema_tables_exist():
+    init_db()
+    counts = table_counts()
+    assert set(counts) == {
+        "concerns",
+        "cycles",
+        "observations",
+        "states",
+        "narratives",
+        "decisions",
+        "actions",
+        "coverage_gaps",
+        "regime_samples",
+    }