migrate_hermes: Гермес → Simbiot (t-261001124130) — память/персона/история/config/cron → пакет + PolicyChanged в стор целевого инстанса + RAG-индексация; dry-run по умолчанию
This commit is contained in:
parent
b9d7fd9d23
commit
61858a408b
1 changed files with 409 additions and 0 deletions
409
scripts/migrate_hermes.py
Normal file
409
scripts/migrate_hermes.py
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Simbiot Harness — миграция данных агента Гермес → Simbiot (t-261001124130).
|
||||
|
||||
Читает каталог Гермеса (~/.hermes): память (memories/MEMORY.md, USER.md),
|
||||
персону (SOUL.md), историю диалогов (state.db: sessions/messages), настройки
|
||||
(config.yaml), кроны (cron/jobs.json) — и собирает пакет для нового агента
|
||||
Simbiot:
|
||||
|
||||
OUT/identity.md — склейка персоны+памяти → policy.identity
|
||||
OUT/memory/MEMORY.md — факты памяти (как были, в формате Гермеса)
|
||||
OUT/memory/USER.md — профиль пользователя
|
||||
OUT/history/*.md — история диалогов, активные сессии (по файлу на сессию)
|
||||
OUT/history-index.md — индекс сессий
|
||||
OUT/config-hermes.yaml — настройка (секреты замаскированы)
|
||||
OUT/cron-map.json — джобы крона → эквиваленты Simbiot (задачи/таймеры)
|
||||
OUT/report.json — отчёт миграции (counts, notes, events)
|
||||
|
||||
Режимы:
|
||||
--apply — применить (по умолчанию dry-run: только отчёт, ничего
|
||||
не пишет в RAG/стор)
|
||||
--harness-dir DIR — при --apply: записать PolicyChanged/MemoryIngested в
|
||||
event store ЭТОГО инстанса Simbiot (агент получает
|
||||
память сразу в контекст тиков)
|
||||
--rag-db PATH --rag-py P --rag-script P — при --apply проиндексировать
|
||||
память/историю в RAG-базу TARGET-хоста
|
||||
|
||||
Идемпотентность: события с фиксированными aggregate_id (harness/policy-h-<hash>),
|
||||
RAG-индексация дедуплицируется по sha (вендор), файлы_OUT перезаписываются —
|
||||
повторный прогон безопасен.
|
||||
|
||||
Запуск: python3 scripts/migrate_hermes.py [--hermes /root/.hermes] [--apply ...]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
MAX_HISTORY_SESSIONS = 40 # сколько последних сессий с диалогом переносим
|
||||
MAX_HISTORY_MSGS_PER_SESSION = 40
|
||||
MAX_TOTAL_HISTORY_MSGS = 150
|
||||
SECRET_RE = re.compile(
|
||||
r"(sk-[A-Za-z0-9_\-]{16,}|sk-or-v1-[A-Za-z0-9_\-]{16,}|agt_[A-Za-z0-9_\-]{10,}|"
|
||||
r"[0-9]{8,10}:[A-Za-z0-9_\-]{30,}|xox[bpars]-[A-Za-z0-9_\-]{10,}|"
|
||||
r"gh[pousr]_[A-Za-z0-9_\-]{20,})")
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
return SECRET_RE.sub("<REDACTED>", text or "")
|
||||
|
||||
|
||||
def _jlog(msg: str):
|
||||
print(f"[migrate] {msg}", flush=True)
|
||||
|
||||
|
||||
# ─── источники ───────────────────────────────────────────────────────────────
|
||||
|
||||
def probe_sources(hermes_dir: str) -> dict:
|
||||
src = {"hermes_dir": hermes_dir, "files": {}, "state_db": {}, "cron": {}}
|
||||
cfg = os.path.join(hermes_dir, "config.yaml")
|
||||
src["files"]["config.yaml"] = os.path.getsize(cfg) if os.path.exists(cfg) else None
|
||||
for name, ext in (("MEMORY", "memories/MEMORY.md"), ("USER", "memories/USER.md"),
|
||||
("SOUL", "SOUL.md")):
|
||||
p = os.path.join(hermes_dir, ext)
|
||||
src["files"][ext] = os.path.getsize(p) if os.path.exists(p) else None
|
||||
state = os.path.join(hermes_dir, "state.db")
|
||||
src["files"]["state.db"] = os.path.getsize(state) if os.path.exists(state) else None
|
||||
if os.path.exists(state):
|
||||
db = sqlite3.connect(f"file:{state}?mode=ro", uri=True)
|
||||
db.row_factory = sqlite3.Row
|
||||
for t in ("messages", "sessions", "system_prompts"):
|
||||
try:
|
||||
src["state_db"][t] = db.execute(f"SELECT COUNT(*) c FROM {t}").fetchone()["c"]
|
||||
except sqlite3.Error:
|
||||
src["state_db"][t] = None
|
||||
try:
|
||||
r = db.execute("SELECT COUNT(*) c FROM messages WHERE active=1 "
|
||||
"AND role IN ('user','assistant')").fetchone()
|
||||
src["state_db"]["messages_active_user_assistant"] = r["c"]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
db.close()
|
||||
jobs = os.path.join(hermes_dir, "cron", "jobs.json")
|
||||
if os.path.exists(jobs):
|
||||
try:
|
||||
with open(jobs) as f:
|
||||
data = json.load(f)
|
||||
jlist = data.get("jobs") if isinstance(data, dict) else data
|
||||
src["cron"]["jobs_total"] = len(jlist or [])
|
||||
src["cron"]["jobs_enabled"] = sum(1 for j in (jlist or []) if j.get("enabled"))
|
||||
except Exception as e:
|
||||
src["cron"]["error"] = str(e)[:200]
|
||||
return src
|
||||
|
||||
|
||||
def extract_memory(hermes_dir: str) -> dict:
|
||||
"""Персона + память целиком (§-факты Гермеса и профиль юзера)."""
|
||||
out = {}
|
||||
parts_id = []
|
||||
soul = os.path.join(hermes_dir, "SOUL.md")
|
||||
if os.path.exists(soul):
|
||||
with open(soul, encoding="utf-8", errors="replace") as f:
|
||||
out["SOUL_raw_md"] = f.read()
|
||||
parts_id.append("## Персона (SOUL, из Гермеса)\n" + out["SOUL_raw_md"])
|
||||
mem = os.path.join(hermes_dir, "memories", "MEMORY.md")
|
||||
if os.path.exists(mem):
|
||||
with open(mem, encoding="utf-8", errors="replace") as f:
|
||||
out["MEMORY_raw_md"] = f.read()
|
||||
parts_id.append("## Память (MEMORY, из Гермеса, § = разделитель фактов)\n" + out["MEMORY_raw_md"])
|
||||
user = os.path.join(hermes_dir, "memories", "USER.md")
|
||||
if os.path.exists(user):
|
||||
with open(user, encoding="utf-8", errors="replace") as f:
|
||||
out["USER_raw_md"] = f.read()
|
||||
parts_id.append("## Профиль пользователя (USER, из Гермеса)\n" + out["USER_raw_md"])
|
||||
out["identity_md"] = "\n\n".join(parts_id)
|
||||
return out
|
||||
|
||||
|
||||
def extract_history(hermes_dir: str, out_dir: str) -> dict:
|
||||
"""Активные сессии с реальным диалогом (user+assistant) → markdown по файлам.
|
||||
|
||||
Крон-сессии (по 1 сообщению-запуску) не тянут — это шум; живые чаты
|
||||
(telegram/matrix/cli/subagent) переносим с капами (капы в шапке).
|
||||
"""
|
||||
state = os.path.join(hermes_dir, "state.db")
|
||||
res = {"sessions_total": 0, "sessions_written": 0, "messages_written": 0,
|
||||
"skipped_single_ping": 0, "files": []}
|
||||
if not os.path.exists(state):
|
||||
res["error"] = "state.db не найден"
|
||||
return res
|
||||
db = sqlite3.connect(f"file:{state}?mode=ro", uri=True)
|
||||
db.row_factory = sqlite3.Row
|
||||
sessions = db.execute("""
|
||||
SELECT s.id, s.source, s.chat_type, s.display_name, s.title,
|
||||
s.started_at, s.last_activity_at, COUNT(m.id) n
|
||||
FROM sessions s JOIN messages m ON m.session_id = s.id
|
||||
WHERE m.active = 1 AND m.role IN ('user', 'assistant')
|
||||
GROUP BY s.id
|
||||
HAVING n >= 2
|
||||
ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC
|
||||
LIMIT ?""", (MAX_HISTORY_SESSIONS,)).fetchall()
|
||||
res["sessions_total"] = len(sessions)
|
||||
hdir = os.path.join(out_dir, "history")
|
||||
os.makedirs(hdir, exist_ok=True)
|
||||
idx_lines = ["# История диалогов Гермеса → Simbiot",
|
||||
"", "сессии с реальным диалогом (active=1, ≥2 сообщения),",
|
||||
f"капы: {MAX_HISTORY_MSGS_PER_SESSION}/сессия, {MAX_TOTAL_HISTORY_MSGS} всего", ""]
|
||||
total = 0
|
||||
for s in sessions:
|
||||
if total >= MAX_TOTAL_HISTORY_MSGS:
|
||||
idx_lines.append(f"- (усечено: достигнут глобальный кап {MAX_TOTAL_HISTORY_MSGS} сообщений)")
|
||||
break
|
||||
msgs = db.execute("""SELECT role, content, timestamp FROM messages
|
||||
WHERE session_id=? AND active=1
|
||||
AND role IN ('user','assistant')
|
||||
ORDER BY timestamp ASC""",
|
||||
(s["id"],)).fetchall()
|
||||
if len(msgs) < 2:
|
||||
res["skipped_single_ping"] += 1
|
||||
continue
|
||||
msgs = msgs[-MAX_HISTORY_MSGS_PER_SESSION:]
|
||||
title = (s["title"] or s["display_name"] or s["chat_type"] or s["id"])[:90]
|
||||
when = datetime.fromtimestamp(s["started_at"] or 0, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
body = [f"# {title}", "",
|
||||
f"session: {s['id']} · source: {s['source'] or '?'} · "
|
||||
f"type: {s['chat_type'] or '-'} · начать: {when} UTC", ""]
|
||||
for m in msgs:
|
||||
ts = datetime.fromtimestamp(m["timestamp"] or 0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
body.append(f"**[{ts} UTC] {m['role']}:** {redact(m['content'] or '')}")
|
||||
body.append("")
|
||||
fname = f"{s['id']}.md"
|
||||
path = os.path.join(hdir, fname)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(body))
|
||||
res["files"].append(path)
|
||||
res["sessions_written"] += 1
|
||||
res["messages_written"] += len(msgs)
|
||||
total += len(msgs)
|
||||
idx_lines.append(f"- [{title}]({os.path.basename(path)}) — "
|
||||
f"{s['source'] or '?'}, {len(msgs)} сообщ., "
|
||||
f"последнее {datetime.fromtimestamp(s['last_activity_at'] or s['started_at'] or 0, tz=timezone.utc).strftime('%Y-%m-%d')}")
|
||||
db.close()
|
||||
with open(os.path.join(out_dir, "history-index.md"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(idx_lines) + "\n")
|
||||
return res
|
||||
|
||||
|
||||
def extract_config(hermes_dir: str, out_dir: str) -> dict:
|
||||
cfg = os.path.join(hermes_dir, "config.yaml")
|
||||
res = {"file": None, "keys": []}
|
||||
if not os.path.exists(cfg):
|
||||
res["error"] = "config.yaml не найден"
|
||||
return res
|
||||
with open(cfg, encoding="utf-8", errors="replace") as f:
|
||||
raw = f.read()
|
||||
safe = redact(raw)
|
||||
dest = os.path.join(out_dir, "config-hermes.yaml")
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write("# Настройка Гермеса (секреты замаскированы <REDACTED>)\n" + safe)
|
||||
res["file"] = dest
|
||||
res["keys"] = [ln.split(":", 1)[0] for ln in raw.splitlines()
|
||||
if ln and not ln[0].isspace() and not ln.startswith("#")
|
||||
and ln.endswith(":")]
|
||||
# маппинг в Simbiot (.env) — подсказки в отчёт
|
||||
mapping = []
|
||||
try:
|
||||
import yaml
|
||||
cfgd = yaml.safe_load(raw) or {}
|
||||
model = ((cfgd.get("model") or {}).get("default")
|
||||
if isinstance(cfgd.get("model"), dict) else None) or cfgd.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
mapping.append({"hermes": "model", "simbiot": "SIMBIOT_MODEL", "value": model})
|
||||
tz = cfgd.get("timezone")
|
||||
if tz:
|
||||
mapping.append({"hermes": "timezone", "simbiot": "(env TZ хоста)", "value": tz})
|
||||
except Exception as e:
|
||||
mapping.append({"warning": f"yaml не разобран: {str(e)[:150]}"})
|
||||
res["simbiot_env_mapping"] = mapping
|
||||
return res
|
||||
|
||||
|
||||
def extract_cron(hermes_dir: str, out_dir: str) -> dict:
|
||||
jobs_path = os.path.join(hermes_dir, "cron", "jobs.json")
|
||||
res = {"jobs_total": 0, "jobs_enabled": 0, "mapped": []}
|
||||
if not os.path.exists(jobs_path):
|
||||
res["error"] = "cron/jobs.json не найден"
|
||||
return res
|
||||
with open(jobs_path) as f:
|
||||
data = json.load(f)
|
||||
jlist = data.get("jobs") if isinstance(data, dict) else data
|
||||
res["jobs_total"] = len(jlist or [])
|
||||
rows = []
|
||||
for j in (jlist or []):
|
||||
if not j.get("enabled"):
|
||||
continue
|
||||
res["jobs_enabled"] += 1
|
||||
sched = j.get("schedule") or {}
|
||||
expr = sched.get("expr") or sched.get("display") or ""
|
||||
rows.append({
|
||||
"id": (j.get("id") or "")[:16], "name": j.get("name"),
|
||||
"cadence": "cron " + expr if expr else sched.get("kind"),
|
||||
"prompt_sha": hashlib.sha256((j.get("prompt") or "").encode()).hexdigest()[:12],
|
||||
"prompt_chars": len(j.get("prompt") or ""),
|
||||
"simbiot_equivalent": "задача на доске по такту / systemd timer — см. docs",
|
||||
})
|
||||
res["mapped"] = rows
|
||||
with open(os.path.join(out_dir, "cron-map.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(res, f, ensure_ascii=False, indent=2)
|
||||
return res
|
||||
|
||||
|
||||
def write_identity_files(mem: dict, out_dir: str) -> list:
|
||||
os.makedirs(os.path.join(out_dir, "memory"), exist_ok=True)
|
||||
written = []
|
||||
id_path = os.path.join(out_dir, "identity.md")
|
||||
with open(id_path, "w", encoding="utf-8") as f:
|
||||
f.write((mem.get("identity_md") or "").strip() + "\n")
|
||||
written.append(id_path)
|
||||
if mem.get("MEMORY_raw_md"):
|
||||
p = os.path.join(out_dir, "memory", "MEMORY.md")
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(mem["MEMORY_raw_md"])
|
||||
written.append(p)
|
||||
if mem.get("USER_raw_md"):
|
||||
p = os.path.join(out_dir, "memory", "USER.md")
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(mem["USER_raw_md"])
|
||||
written.append(p)
|
||||
return written
|
||||
|
||||
|
||||
def rag_index(files: list, rag_db: str, rag_py: str, rag_script: str) -> dict:
|
||||
"""Память/история/личность → целевая RAG-база (вендор дедупит по sha)."""
|
||||
res = {"indexed": [], "failed": []}
|
||||
if not (os.path.exists(rag_py) and os.path.exists(rag_script)):
|
||||
res["error"] = "RAG-вендор не найден (rag-venv/rag.py) — индексация пропущена"
|
||||
return res
|
||||
for path in files:
|
||||
env = dict(os.environ, RAG_DB=rag_db)
|
||||
import subprocess
|
||||
try:
|
||||
proc = subprocess.run([rag_py, rag_script, "add", path],
|
||||
capture_output=True, text=True, timeout=120, env=env)
|
||||
if proc.returncode == 0:
|
||||
res["indexed"].append(os.path.basename(path))
|
||||
else:
|
||||
res["failed"].append({"file": os.path.basename(path),
|
||||
"err": (proc.stderr or proc.stdout)[-200:]})
|
||||
except Exception as e:
|
||||
res["failed"].append({"file": os.path.basename(path), "err": str(e)[:200]})
|
||||
return res
|
||||
|
||||
|
||||
def apply_to_store(harness_dir: str, identity_md: str, counts: dict) -> dict:
|
||||
"""PolicyChanged с памятью Гермеса в identity → тики нового агента видят её."""
|
||||
res = {"events": [], "error": None}
|
||||
try:
|
||||
sys.path.insert(0, harness_dir)
|
||||
from harness import es as target_es
|
||||
pol_marker = "policy-hermes-" + hashlib.sha256(identity_md.encode()).hexdigest()[:10]
|
||||
existing = {}
|
||||
try:
|
||||
model = target_es.run_projections()
|
||||
existing = model.get("policy") or {}
|
||||
except Exception:
|
||||
pass
|
||||
if existing.get("policy_hermes_marker") == pol_marker:
|
||||
res["events"].append({"event": "PolicyChanged", "status": "skip-duplicate"})
|
||||
return res
|
||||
identity = (
|
||||
"Миграция Гермеса → Simbiot: у агента есть перенесённая память и история "
|
||||
f"(сессий: {counts.get('sessions_written', 0)}). Ниже — персона и память. "
|
||||
"Факты выше считаются проверенными данными организации.\n\n" + identity_md)
|
||||
target_es.append_event("harness", "policy", "PolicyChanged", {
|
||||
**(existing or {}),
|
||||
"identity": (identity or "")[:12000],
|
||||
"policy_hermes_marker": pol_marker,
|
||||
"hermes_migration": {"applied_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"history_sessions": counts.get("sessions_written", 0)},
|
||||
})
|
||||
res["events"].append({"event": "PolicyChanged", "status": "applied",
|
||||
"marker": pol_marker})
|
||||
except Exception as e:
|
||||
res["error"] = str(e)[:300]
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Гермес → Simbiot")
|
||||
ap.add_argument("--hermes", default=os.path.expanduser("~/.hermes"))
|
||||
ap.add_argument("--out", default=None, help="каталог пакета (default: OUT/hermes-migration)")
|
||||
ap.add_argument("--apply", action="store_true", help="писать (default: dry-run)")
|
||||
ap.add_argument("--harness-dir", default=None, help="инстанс Simbiot для события PolicyChanged")
|
||||
ap.add_argument("--rag-db", default=None)
|
||||
ap.add_argument("--rag-py", default="/root/rag-venv/bin/python")
|
||||
ap.add_argument("--rag-script", default="/root/rag/rag.py")
|
||||
args = ap.parse_args()
|
||||
|
||||
simbiot_repo = os.path.dirname(os.path.abspath(__file__))
|
||||
out_dir = args.out or os.path.join(simbiot_repo, "hermes-migration")
|
||||
if args.apply:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
report = {"hermes_dir": args.hermes, "mode": "apply" if args.apply else "dry-run",
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
||||
|
||||
# 0) dry-run: ничего не создаём, только отчёт-превью в stdout
|
||||
if not args.apply:
|
||||
report["sources"] = probe_sources(args.hermes)
|
||||
mem = extract_memory(args.hermes)
|
||||
report["memory_preview"] = {k: len(v) for k, v in mem.items()
|
||||
if isinstance(v, str)}
|
||||
report["note"] = "dry-run: запусти с --apply (и --harness-dir <симбиот-инсталл>)"
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# 1) карта источников (фиксируем ДО работы)
|
||||
report["sources"] = probe_sources(args.hermes)
|
||||
_jlog("sources: " + json.dumps(report["sources"], ensure_ascii=False)[:400])
|
||||
|
||||
# 2) память/личность
|
||||
mem = extract_memory(args.hermes)
|
||||
files = write_identity_files(mem, out_dir)
|
||||
_jlog(f"identity files: {len(files)}")
|
||||
|
||||
# 3) история
|
||||
hist = extract_history(args.hermes, out_dir)
|
||||
report["history"] = {k: v for k, v in hist.items() if k != "files"}
|
||||
_jlog(f"history: sessions={hist.get('sessions_written')}, msgs={hist.get('messages_written')}")
|
||||
files = files + hist.get("files", []) + [os.path.join(out_dir, "history-index.md")]
|
||||
|
||||
# 4) config.yaml
|
||||
report["config"] = extract_config(args.hermes, out_dir)
|
||||
if report["config"].get("file"):
|
||||
files.append(report["config"]["file"])
|
||||
|
||||
# 5) cron
|
||||
report["cron"] = extract_cron(args.hermes, out_dir)
|
||||
files.append(os.path.join(out_dir, "cron-map.json"))
|
||||
|
||||
# 6) RAG (память+история)
|
||||
if args.rag_db:
|
||||
report["rag"] = rag_index([f for f in files if f.endswith(".md")],
|
||||
args.rag_db, args.rag_py, args.rag_script)
|
||||
_jlog("rag: indexed=" + str(len(report["rag"].get("indexed", [])))
|
||||
+ " failed=" + str(len(report["rag"].get("failed", []))))
|
||||
|
||||
# 7) события нового инстанса
|
||||
counts = {"sessions_written": hist.get("sessions_written", 0)}
|
||||
report["store"] = apply_to_store(args.harness_dir if args.harness_dir
|
||||
else simbiot_repo, mem.get("identity_md", ""), counts)
|
||||
|
||||
# 8) отчёт
|
||||
report["written"] = {"files": len(files), "out_dir": out_dir,
|
||||
"identity_chars": len(mem.get("identity_md") or "")}
|
||||
with open(os.path.join(out_dir, "report.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
_jlog("report.json записан")
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue