diff --git a/README.md b/README.md index d5624f4..2d3687c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,30 @@ Ритмичный агент: живёт тактами, как человек. CQRS + Event Sourcing на спине, Strands Harness SDK как исполнительное ядро, модели через 9router. +## Установка одной командой (t-261002193628-1d1e5f, как hermes) + +```bash +curl -fsSL https://raw.cil.by/simbiot/install.sh | bash +``` + +Что делает `scripts/install.sh`: клонирует репо в `~/simbiot-harness`, +создаёт venv (Python 3.11+), ставит зависимости, кладёт обёртку +`~/.local/bin/simbiot` (чистый env: unset PYTHONPATH/PYTHONHOME → +`exec venv/bin/python cli.py`), тихо инициализирует стор и проверяет +`simbiot status`. Флаги: `--dir DIR`, `--repo URL`, `--wizard` / +`--no-wizard`, `--systemd` (юнит, только root), `-q` (тихо). + +Первый запуск — визард настройки (токен TG-бота, ключ 9router, модель): + +```bash +simbiot wizard # интерактивно +simbiot wizard --noninteractive --router-key KEY --model auto +``` + +`.env` в корне репо читается стартапом (SIMBIOT_TG_TOKEN, +SIMBIOT_TG_ALLOWED, ROUTER_KEY, SIMBIOT_MODEL и др.); в systemd-режиме +`scripts/simbiot-harness.service` подхватывает его сам. + ## Главная цель (фиксация Alex, 2026-09-30) Метрика харнеса — **максимизация долгосрочной AUM** (доход/ресурс всех diff --git a/cli.py b/cli.py index 5301176..ec0c17d 100644 --- a/cli.py +++ b/cli.py @@ -121,6 +121,21 @@ def main(): p_init = sub.add_parser("init", help="инициализация стора") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") + # Визард первого запуска (t-261002193628-1d1e5f, аналогично hermes): + # интерактивный конфиг .env (токен TG, ключ 9router, модель) + init стора. + p_wiz = sub.add_parser("wizard", help="визард первого запуска: настройка .env и стора") + p_wiz.add_argument("--noninteractive", action="store_true", + help="без вопросов: значения из ENV/--флагов, init без TG-настройки") + p_wiz.add_argument("--tg-token", default=None, help="токен TG-бота (SIMBIOT_TG_TOKEN)") + p_wiz.add_argument("--tg-allowed", default=None, + help="разрешённые чат-ids через запятую (SIMBIOT_TG_ALLOWED)") + p_wiz.add_argument("--router-key", default=None, help="ключ 9router (ROUTER_KEY)") + p_wiz.add_argument("--router-base-url", default=None, + help="база 9router (ROUTER_BASE_URL, по умолчанию http://127.0.0.1:20128/v1)") + p_wiz.add_argument("--model", default=None, + help="модель уровней (по умолчанию auto — как в harness/levels.py)") + p_wiz.add_argument("--no-init", action="store_true", help="не инициализировать стор") + p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") p_rag.add_argument("question") p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") @@ -133,6 +148,9 @@ def main(): es.init_db() commands.set_policy(identity=args.identity) print("OK: стор инициализирован, политика записана") + + elif args.cmd == "wizard": + _run_wizard(args) elif args.cmd == "task": tid, size = commands.submit_task(args.title, args.details, args.minutes) if args.await_delivery: @@ -416,5 +434,115 @@ def main(): print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}") +# ============================================================================ +# Визард первого запуска (t-261002193628-1d1e5f, аналогично hermes): конфиг +# .env атомарно (0600) + init стора. Дополнен шагом «модель» — она реально +# переключает уровни через SIMBIOT_MODEL (env-оверрайд в harness/levels.py). +# ============================================================================ +def _mask(v: str) -> str: + v = (v or "").strip() + return "(не задано)" if not v else (v[:4] + "…" + v[-4:] if len(v) > 12 else "***") + + +def _dotenv_get(env_path: str, key: str) -> str: + """Значение key из .env (env процесса сильнее, как в runtime._env_or_file).""" + val = os.environ.get(key, "") + if val: + return val + try: + with open(env_path, encoding="utf-8") as f: + for line in f: + if line.startswith(key + "="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "" + + +def _dotenv_set(env_path: str, key: str, value: str) -> None: + """Атомарная запись key=value в .env: обновить/добавить, остальное — как было.""" + lines = [] + if os.path.exists(env_path): + with open(env_path, encoding="utf-8") as f: + lines = f.read().splitlines() + out, replaced = [], False + for ln in lines: + if ln.startswith(key + "="): + if not replaced: + out.append(f"{key}={value}") + replaced = True # повторные вхождения key выкидываем + else: + out.append(ln) + if not replaced: + out.append(f"{key}={value}") + tmp = env_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(out).rstrip("\n") + "\n") + os.replace(tmp, env_path) + os.chmod(env_path, 0o600) + + +def _ask(prompt: str, current: str, secret: bool = False) -> str: + """Ответ пользователя; Enter = оставить текущее; EOF (не-tty) = ничего.""" + hint = _mask(current) if secret else (current if current else "пусто") + try: + raw = input(f"{prompt} [{hint}]: ").strip() + except EOFError: + raw = "" + return raw if raw else current + + +def _tg_selftest(token: str) -> str: + """getMe-самотест (только проверка токена, ничего не отправляет).""" + if not token: + return "" + import urllib.request + try: + with urllib.request.urlopen( + f"https://api.telegram.org/bot{token}/getMe", timeout=5) as r: + who = json.loads(r.read().decode()).get("result", {}).get("username") + return f"bot @{who}" if who else "ok" + except Exception as e: + return f"ОШИБКА ({e.__class__.__name__}) — токен не принят, но запись в .env сделал" + + +def _run_wizard(args) -> None: + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + vals = { + "SIMBIOT_TG_TOKEN": args.tg_token if args.tg_token is not None else None, + "SIMBIOT_TG_ALLOWED": args.tg_allowed, + "ROUTER_KEY": args.router_key, + "ROUTER_BASE_URL": args.router_base_url, + "SIMBIOT_MODEL": args.model, + } + if not getattr(args, "noninteractive", False): + print("⬢ Simbiot Harness — визард первого запуска.") + print(" Enter = оставить текущее значение. Всё пишется в .env (0600).") + tok = _ask("Токен TG-бота", _dotenv_get(env_path, "SIMBIOT_TG_TOKEN"), secret=True) + allowed = _ask("Разрешённые chat-id через запятую", + _dotenv_get(env_path, "SIMBIOT_TG_ALLOWED")) + key = _ask("Ключ 9router", _dotenv_get(env_path, "ROUTER_KEY"), secret=True) + base = _ask("База 9router (URL)", + _dotenv_get(env_path, "ROUTER_BASE_URL") + or "http://127.0.0.1:20128/v1") + model = _ask("Модель уровней (или auto)", _dotenv_get(env_path, "SIMBIOT_MODEL") or "auto") + vals = {"SIMBIOT_TG_TOKEN": tok, "SIMBIOT_TG_ALLOWED": allowed, + "ROUTER_KEY": key, "ROUTER_BASE_URL": base, "SIMBIOT_MODEL": model} + if tok: + print(f" TG-токен: {_tg_selftest(tok)}") + for key, val in vals.items(): + if val not in (None, "",): + if key == "ROUTER_BASE_URL" and val == "http://127.0.0.1:20128/v1" \ + and _dotenv_get(env_path, "ROUTER_BASE_URL") == val: + continue # дефолт уже такой — .env не засоряем + _dotenv_set(env_path, key, str(val)) + print(f" .env ← {key}={_mask(str(val)) if 'KEY' in key or 'TOKEN' in key else val}") + if not args.no_init: + es.init_db() + print("OK: стор инициализирован.") + print("Дальше: simbiot status — проверить; запустить харнес: scripts/install.sh --systemd " + "или ./venv/bin/python -m harness.scheduler (см. README).") + + if __name__ == "__main__": main() \ No newline at end of file diff --git a/harness/levels.py b/harness/levels.py index 44de99b..a1e49df 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -3,6 +3,7 @@ Контекст ∝ масштабу: чем крупнее цикл, тем больше контекст и модель. """ from dataclasses import dataclass, field +import os SEC = 1 MIN = 60 @@ -14,6 +15,25 @@ QUARTER = 91 * DAY YEAR = 366 * DAY # округляем год как 366 дней для простоты выравнивания +def _cfg_model(default: str = "auto") -> str: + """SIMBIOT_MODEL (визард первого запуска, t-261002193628-1d1e5f): + одна модель для всех LLM-уровней вместо auto — env сильнее .env + (как runtime._env_or_file, без circular import — свой мини-ридер). + Пустое/absent значение = дефолт конфига (как было).""" + val = os.environ.get("SIMBIOT_MODEL", "").strip() + if val: + return val + try: + env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") + with open(env_path, encoding="utf-8") as f: + for line in f: + if line.startswith("SIMBIOT_MODEL="): + return line.split("=", 1)[1].strip().strip('"').strip("'").strip() or default + except OSError: + pass + return default + + @dataclass class CycleLevel: name: str diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..372c1d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# Simbiot Harness — runtime-зависимости (strands-ядро + телеметрия). +# Версии = эталонный venv кооператива (2026-10-02); установка: scripts/install.sh +strands-agents==1.57.1 +strands-harness==0.1.2 +openai==3.20.0 +pydantic==2.13.5 +PyJWT==2.15.1 +PyYAML==6.0.3 +# event store / CLI — stdlib (sqlite3, argparse); audit-цепочка: cryptography +cryptography==50.0.1 +# 9router-клиент (OpenAI-совместимый) + телеметрия Strands +botocore==1.43.104 +boto3==1.43.104 +opentelemetry-sdk==1.45.0 +opentelemetry-exporter-otlp-proto-http==1.45.0 +opentelemetry-semantic-conventions==0.66b0 +# дашборд и HTTP-эндпоинты +starlette==1.7.0 +uvicorn==0.54.0 +sse-starlette==3.5.0 +python-multipart==0.0.32 +py-spy==0.4.2 +watchdog==6.0.0 \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..6acf49c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# ============================================================================ +# Simbiot Harness — установка одной командой +# ============================================================================ +# curl -fsSL https://raw.cil.by/simbiot/install.sh | bash +# # или из клона репо: +# bash scripts/install.sh +# +# Что делает: +# 1. Клонирует (или обновляет) репо в ~/simbiot-harness +# 2. Создаёт Python 3.11+ venv, ставит зависимости (requirements.txt) +# 3. Кладёт обёртку `simbiot` в ~/.local/bin (аналог hermes: чистый env, +# exec venv/bin/python cli.py — вне репо работает отовсюду) +# 4. Флаги: --dir DIR (куда ставить, по умолчанию ~/simbiot-harness), +# --repo URL (по умолчанию https://git.cil.by/symbiot/simbiot-harness.git), +# --wizard (визард первого запуска даже в интерактиве не нужен), +# --no-wizard (тихий режим: без вопросов), -q (минимум вывода). +# Опционально ставит systemd-юниты (если root и systemd), иначе — как +# запустить руками, печатает подсказку симулятивно. +# ============================================================================ +set -euo pipefail + +REPO_URL="https://git.cil.by/symbiot/simbiot-harness.git" +INSTALL_DIR="$(eval echo '~')/simbiot-harness" +WIZARD_AUTO="" # "", "yes", "no" +QUIET=0 +FORCE_SYSTEMD="" +INTERACTIVE=0 +[ -t 0 ] && [ -t 1 ] && INTERACTIVE=1 + +while [ $# -gt 0 ]; do + case "$1" in + --dir) INSTALL_DIR="$2"; shift 2 ;; + --repo) REPO_URL="$2"; shift 2 ;; + --wizard) WIZARD_AUTO="yes"; shift ;; + --no-wizard) WIZARD_AUTO="no"; shift ;; + --systemd) FORCE_SYSTEMD="yes"; shift ;; + -q|--quiet) QUIET=1; shift ;; + -h|--help) grep '^#' "$0" | sed -n '2,20p' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Неизвестный аргумент: $1" >&2; exit 2 ;; + esac +done + +GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[0;33m'; NC='\033[0m' +say() { [ "$QUIET" = 1 ] || echo -e "$1"; } + +say "${CYAN}⬢ Simbiot Harness — установка${NC}" +say "" + +# ---------------------------------------------------------------------------- +# 1. Репозиторий +# ---------------------------------------------------------------------------- +if [ -d "$INSTALL_DIR/.git" ]; then + say "${CYAN}→${NC} Обновляю существующий клон: $INSTALL_DIR" + git -C "$INSTALL_DIR" fetch --depth=1 origin >/dev/null 2>&1 || true + git -C "$INSTALL_DIR" reset --hard origin/HEAD >/dev/null 2>&1 || \ + git -C "$INSTALL_DIR" reset --hard origin/main >/dev/null 2>&1 || true +else + say "${CYAN}→${NC} Клонирую репозиторий → $INSTALL_DIR" + mkdir -p "$(dirname "$INSTALL_DIR")" + for i in 1 2 3; do + git clone --depth=1 "$REPO_URL" "$INSTALL_DIR" && break + [ "$i" = 3 ] && { echo "Ошибка: клонирование не удалось ($REPO_URL)" >&2; exit 1; } + sleep 2 + done +fi +cd "$INSTALL_DIR" + +# ---------------------------------------------------------------------------- +# 2. Python venv + зависимости +# ---------------------------------------------------------------------------- +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 не найден. Установи Python 3.11+: apt install -y python3 python3-venv python3-pip" >&2 + exit 1 +fi + +PYV=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])') +PYMAJOR=${PYV%%.*}; PYMINOR=${PYV#*.} +if [ "$PYMAJOR" -lt 3 ] || { [ "$PYMAJOR" = 3 ] && [ "$PYMINOR" -lt 11 ]; }; then + echo "Нужен Python ≥ 3.11, найден $PYV" >&2 + exit 1 +fi + +if [ ! -x venv/bin/python ]; then + say "${CYAN}→${NC} Создаю venv (python $PYV)…" + python3 -m venv venv +fi +say "${CYAN}→${NC} Ставлю зависимости…" +./venv/bin/pip install --upgrade pip >/dev/null +./venv/bin/pip install -r requirements.txt >/dev/null 2>&1 || \ + ./venv/bin/pip install -r requirements.txt + +# ---------------------------------------------------------------------------- +# 3. Обёртка `simbiot` (аналог /usr/local/bin/hermes) +# ---------------------------------------------------------------------------- +BIN_DIR="$(bash -c 'echo -n "${XDG_BIN_HOME:-$HOME/.local/bin}"')" +mkdir -p "$BIN_DIR" +cat > "$BIN_DIR/simbiot" </dev/null 2>&1 && systemctl is-system-running >/dev/null 2>&1; then + cp "$INSTALL_DIR/scripts/simbiot-harness.service" /etc/systemd/system/ 2>/dev/null || sed -e "s|/root/simbiot-harness|$INSTALL_DIR|g" -e "s|root|$USER|g" "$INSTALL_DIR/scripts/simbiot-harness.service" > /etc/systemd/system/simbiot-harness.service + systemctl daemon-reload + systemctl enable --now simbiot-harness + say "${GREEN}→${NC} systemd: simbiot-harness запущен (systemctl status simbiot-harness)" + else + say "${YELLOW}→${NC} systemd-юнит пропущен (нужны root и systemd)." + fi +fi + +# ---------------------------------------------------------------------------- +# 6. Проверка одной проверяемой командой +# ---------------------------------------------------------------------------- +say "" +if simbiot status >/dev/null 2>&1; then + say "${GREEN}✅ Установка завершена: simbiot status работает.${NC}" +else + echo "⚠ simbiot status вернул ошибку — см. вывод выше." >&2 + exit 1 +fi +say "Дальше: simbiot wizard (настройка), simbiot --help (команды)." \ No newline at end of file diff --git a/scripts/simbiot-harness.service b/scripts/simbiot-harness.service new file mode 100644 index 0000000..bf9a5c2 --- /dev/null +++ b/scripts/simbiot-harness.service @@ -0,0 +1,15 @@ +[Unit] +Description=Simbiot Harness - tact ladder (Strands + CQRS/ES) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=__INSTALL_DIR__ +ExecStart=__INSTALL_DIR__/venv/bin/python -m harness.scheduler +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target \ No newline at end of file