куб 4×4×4: 4 круга доверия, динамическая память D0–D3 с приватностью по кругу, голова depth

Куб Alex: круг доверия × тик (sec/min/hour/day) × глубина памяти —
одна таблица политик, не 64 программы.
- круги 4: 1 владелец (+система: тик без внешних входов), 2 правление,
  3 свои (портал, MCP-агенты, партнёры), 4 остальные; trust.norm для
  старых номеров (в сторе только 1/2 — смысл тот же); потолок памяти круга;
- harness/memory.py: индекс собственной истории (<home>/memory) — разговоры
  (сообщение + ответ; батч — ответ головы), свои рассылки (не ответы),
  итоги задач, дайджесты без заглушек; nomic search_document/query;
  КРУГ ПРИВАТНОСТИ записи = самый недоверенный участник чата (личка
  Alex — 1, «Правление» — 2, задача — её круг, дайджест — 1); поиск
  отдаёт только записи круга ≥ круга тика — тик с правлением не поднимет
  личку владельца; SIMBIOT_MEMORY=on|off (дефолт off — тесты герметичны);
- глубина: D0 односложные / D1 разговор / D2 + эпизоды (8) / D3 + архив
  (15): уровень (час D2, день D3), «помнишь/вчера/как договаривались» →
  D2, «за месяц/что делал/итоги» → D3, задачи → D2, reply на давнее (>48 ч)
  сообщение бота → D2; потолок круга (свои — D2, остальные — D1);
- DepthChosen по трассе (rag/копание в своём сторе/«не помню» = нехватка)
  → голова ядра depth (shadow; on — уверенный ученик поднимает до D2);
- выжимки нижних уровней: проекция не хранила stub — заглушки «LLM не
  запускался» забивали контекст; теперь отсеиваются;
- ядро: чтение чемпиона больше не создаёт каталоги, без чемпиона —
  без эмбеддингов (тесты создавали core/ в репо и ходили в Ollama).
Тесты: test_memory 30, test_trust 55 PASS; полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 10:43:12 +00:00
parent 17a5befd86
commit 969d57f14a
12 changed files with 816 additions and 84 deletions

4
.gitignore vendored
View file

@ -22,3 +22,7 @@ store.db
.simbiot-data/
harness/channel_*.json
harness/channel_*.json.tmp
# артефакты ядра/памяти, если SIMBIOT_HOME не задан (прод — в /var/lib/simbiot-harness)
/core/
/memory/

23
cli.py
View file

@ -202,12 +202,18 @@ def main():
p_cmp2.add_argument("--verify-archives", action="store_true",
help="проверить все архивы: sha256 против StoreCompacted + подпись каждой строки")
p_tr = sub.add_parser("trust", help="круги доверия: list | set <субъект> <круг 0-7> (только человек, не из тика)")
p_tr = sub.add_parser("trust", help="круги доверия: list | set <субъект> <круг 1-4> (только человек, не из тика)")
p_tr.add_argument("action", choices=["list", "set"])
p_tr.add_argument("subject", nargs="?", help="tg:<user_id> | agent:<имя> | portal:<user> | tui")
p_tr.add_argument("circle", nargs="?", type=int)
p_tr.add_argument("--note", default="")
p_mem = sub.add_parser("memory", help="память Коди: sync | stats | search \"запрос\" [--circle N]")
p_mem.add_argument("action", choices=["sync", "stats", "search"])
p_mem.add_argument("query", nargs="?", default="")
p_mem.add_argument("--circle", type=int, default=1)
p_mem.add_argument("--all", action="store_true", help="sync: догнать стор целиком (пачками)")
p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)")
p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback",
"reflect", "report", "retire"])
@ -669,7 +675,8 @@ def main():
if args.action == "list":
for r in trust.snapshot():
print(f"{r['circle']} {r['name']:20} {r['subject']:24} {r.get('note') or ''} (by {r.get('by')})")
print("по умолчанию: владелец tg — 1, tui — 1, агенты — 4, портал — 3, прочие tg — 6, чужие — 7")
print("круги: 1 владелец · 2 правление · 3 свои (портал, MCP-агенты, партнёры) · 4 остальные; "
"по умолчанию: владелец tg/tui — 1, агенты/портал — 3, прочие — 4")
else:
if args.subject is None or args.circle is None:
print("ERROR: cli.py trust set <субъект> <круг>")
@ -681,6 +688,18 @@ def main():
sys.exit(2)
print(f"OK: {args.subject} → круг {args.circle} ({trust.NAMES[args.circle]})")
elif args.cmd == "memory":
from harness import memory
es.init_db()
if args.action == "sync":
total = memory.sync_all() if args.all else memory.sync()
print(f"записей добавлено: {total}; индекс: {memory.stats()}")
elif args.action == "stats":
print(json.dumps(memory.stats(), ensure_ascii=False))
else:
for h in memory.search(args.query, args.circle, k=8):
print(f"{h['score']:.3f} [{h['kind']} {h['ts'][:10]} круг {h['circle']}] {h['text'][:160]!r}")
elif args.cmd == "core":
from harness import kodi_core
es.init_db()

View file

@ -452,8 +452,8 @@ def main():
level = payload.get("level") or "?"
# круг доверия тика (harness/trust.py): в env — его наследуют shell и
# cli.py (задачи штампуются кругом, trust set из тика запрещён)
circle = payload.get("circle")
circle = 1 if circle is None else int(circle)
from harness.trust import norm as _norm_circle
circle = _norm_circle(payload.get("circle"))
os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle)
try:
# RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup —

View file

@ -585,6 +585,9 @@ def _apply_event(model: dict, ev: dict):
"summary_ref": data.get("summary_ref"),
"text": data.get("text", ""),
"ts": ev["timestamp"],
# заглушки (gate:quiet / busy) — без LLM; build_context их фильтровал
# по stub, но поле не проецировалось — выжимки забивались «LLM не запускался»
"stub": bool(data.get("stub")),
}
# кап дайджестов: держим по 40 последних на уровень —
# «наверх идут только дайджесты» не значит «все дайджесты навсегда»

View file

@ -90,9 +90,14 @@ def mode(head: str = HEAD) -> str:
return v if v in ("on", "shadow", "off") else "shadow"
def core_dir() -> str:
d = (os.environ.get("SIMBIOT_CORE_DIR", "").strip()
def _core_root() -> str:
return (os.environ.get("SIMBIOT_CORE_DIR", "").strip()
or os.path.join(_paths.home(), "core"))
def core_dir() -> str:
"""Корень ядра — с созданием (только для записи: кеш эмбеддингов, модели)."""
d = _core_root()
os.makedirs(d, mode=0o700, exist_ok=True)
return d
@ -110,10 +115,15 @@ def _cache():
return db
def embed(texts: list) -> list:
"""Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama."""
def embed(texts: list, prefix: str = "classification") -> list:
"""Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama.
prefix — задача nomic: classification (ученики ядра) / search_document,
search_query (память, harness/memory.py); ключ кеша включает префикс
(classification — прежний ключ, кеш не теряется)."""
import numpy as np
keys = [hashlib.sha1(f"{EMB_MODEL}|{t}".encode()).hexdigest() for t in texts]
keys = [hashlib.sha1((f"{EMB_MODEL}|{t}" if prefix == "classification"
else f"{EMB_MODEL}|{prefix}|{t}").encode()).hexdigest() for t in texts]
out = [None] * len(texts)
db = _cache()
try:
@ -125,7 +135,7 @@ def embed(texts: list) -> list:
for s in range(0, len(miss), 32):
chunk = miss[s:s + 32]
body = json.dumps({"model": EMB_MODEL, "keep_alive": "2h",
"input": [f"classification: {texts[i][:2000]}" for i in chunk]})
"input": [f"{prefix}: {texts[i][:2000]}" for i in chunk]})
req = urllib.request.Request(_ollama_base() + "/api/embed", data=body.encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
@ -349,7 +359,11 @@ def metrics(model, X, y, w):
return {"logloss": round(ll, 4), "acc": round(acc, 4), "n": int(len(y))}
def _model_path(name, head: str = HEAD):
def _model_path(name, head: str = HEAD, create: bool = True):
"""Путь файла головы; create=False — для чтения: каталоги не плодим
(чтение чемпиона из build_context создавало core/ в репо в тестах)."""
if not create:
return os.path.join(_core_root(), head, name)
d = os.path.join(core_dir(), head)
os.makedirs(d, mode=0o700, exist_ok=True)
return os.path.join(d, name)
@ -357,7 +371,7 @@ def _model_path(name, head: str = HEAD):
def _champion_meta(head: str = HEAD):
try:
with open(_model_path("champion.json", head)) as f:
with open(_model_path("champion.json", head, create=False)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
@ -371,7 +385,7 @@ def load_champion(head: str = HEAD):
st = _STATE.setdefault(head, {})
if st.get("sha") == meta.get("sha256") and st.get("model") is not None:
return st["model"]
path = _model_path(meta["file"], head)
path = _model_path(meta["file"], head, create=False)
with open(path, "rb") as f:
blob = f.read()
if hashlib.sha256(blob).hexdigest() != meta.get("sha256"):
@ -425,6 +439,8 @@ def _heads():
"ts": lambda it: it[0].get("ts")},
TASK_HEAD: {"dataset": task_dataset, "featurize": task_featurize,
"nf": len(TASK_FLAGS), "flags": TASK_FLAGS, "ts": lambda t: t.get("ts")},
DEPTH_HEAD: {"dataset": depth_dataset, "featurize": depth_featurize,
"nf": len(DEPTH_FLAGS), "flags": DEPTH_FLAGS, "ts": lambda it: it.get("ts")},
}
@ -593,9 +609,11 @@ def shadow(m: dict, prior: list, teacher_p, final: bool):
return None
try:
model = load_champion()
p, nov = None, 1.0 # без чемпиона эмбеддинги не нужны: всё «незнакомо»
if model is not None:
X, em = featurize([(m, prior)])
p = float(predict(model, X)[0]) if model is not None else None
nov = float(novelty(model, em)[0]) if model is not None else 1.0
p = float(predict(model, X)[0])
nov = float(novelty(model, em)[0])
except Exception as e:
return {"error": str(e)[:200]}
r = route(p, nov, teacher_p, model.get("nov_q") if model is not None else None)
@ -866,6 +884,63 @@ def task_shadow(task: dict, level: str):
return rec
# ────────────────────────── голова depth ──────────────────────────
# Нужна ли глубокая память (D2+) — harness/memory.py. Метки: решение правил
# (учитель, вес 1) и поправка из трассы тика: на D0/D1 агент полез в базу
# знаний / свои дайджесты / «не помню» → глубины не хватило (1, вес 3).
# shadow: прогноз пишется в DepthChosen; on: уверенное «нужно» поднимает D1→D2.
DEPTH_HEAD = "depth"
DEPTH_FLAGS = ("level", "circle", "past_words", "archive_words", "has_reply_full", "has_tasks")
_LEVEL_IDX = {"sec": 0.0, "min": 1 / 3, "hour": 2 / 3, "day": 1.0}
def depth_dataset():
rows = []
for ts, d in _q("SELECT timestamp, data FROM events WHERE event_type='DepthChosen' ORDER BY id"):
d = json.loads(d)
if d.get("level") != "min":
continue # час/день — D2/D3 по уровню, учиться нечему
item = {"query": d.get("query") or "", "level": d.get("level"),
"circle": d.get("circle") or 1, "ts": ts,
"reasons": " ".join(d.get("reasons") or [])}
if d.get("depth", 1) < 2 and d.get("under"):
rows.append((item, 1.0, 3.0, "under"))
else:
rows.append((item, 1.0 if d.get("depth", 1) >= 2 else 0.0, 1.0, "rules"))
return rows
def depth_flags(it: dict) -> list:
from . import memory
q = it.get("query") or ""
return [_LEVEL_IDX.get(it.get("level"), 1 / 3), (it.get("circle") or 1) / 4.0,
1.0 if memory.PAST_RE.search(q) else 0.0, 1.0 if memory.ARCHIVE_RE.search(q) else 0.0,
1.0 if "давнее" in (it.get("reasons") or "") else 0.0,
1.0 if "задачи" in (it.get("reasons") or "") else 0.0]
def depth_featurize(items: list):
vecs = embed([(it.get("query") or "(пусто)")[:1500] for it in items])
n = len(items)
em = np.stack(vecs) if n else np.zeros((0, EMB_DIM), np.float32)
fl = np.asarray([depth_flags(it) for it in items], dtype=np.float32).reshape(n, len(DEPTH_FLAGS))
return np.hstack([em, fl]), em
def depth_predict(item: dict):
"""(p_нужна_глубина, уверен?) или None — ученика нет/выключен."""
if mode(DEPTH_HEAD) == "off":
return None
model = load_champion(DEPTH_HEAD)
if model is None:
return None
X, em = depth_featurize([item])
p = float(predict(model, X)[0])
nov = float(novelty(model, em)[0])
return p, bool(nov < float(model["nov_q"][0]) and abs(p - .5) >= CONF)
# ────────────────────────── рефлексия и отчёт ──────────────────────────
# Как reflexion.py бенча: системная ошибка → не молча копить, а принести
# человеку правило. Без новых LLM-путей: досье уходит задачей часовому

355
harness/memory.py Normal file
View file

@ -0,0 +1,355 @@
"""Simbiot Harness — динамическая память Коди: глубина D0–D3 и подгрузка по смыслу.
Третья ось куба 4×4×4 (круг доверия × тик × глубина). Раньше память тика —
12 последних реплик чата + свои сообщения за 48 ч + 1 запрос к базе знаний
кооператива: на «чем ты занимался последний месяц» Коди отвечал по базе
кооператива и наугад — поиска по СОБСТВЕННОЙ истории не было.
Индекс (<home>/memory/index.sqlite) — инкрементально из стора:
conv — сообщение + ответ Коди (ReplySent; батч — ответ головы);
out — собственные рассылки бота (OutboundMessage, не ответы);
task — задача и её итог (TaskCompleted);
digest — содержательные дайджесты час/день (заглушки — нет).
Каждая запись хранит КРУГ ПРИВАТНОСТИ: сказанное в чате видно всем его
участникам → круг записи = самый недоверенный участник чата (личка Alex —
1, «Правление» — 2); задача — её круг; дайджест — 1 (внутреннее). Поиск
отдаёт только записи с кругом ≥ круга тика: тик с правлением не поднимет
личку владельца. Эмбеддинги — nomic-embed-text (search_document/query).
Глубина (choose_depth): D0 — только текущее (односложные «ок/спасибо»);
D1 — разговор (как было); D2 — + эпизоды по смыслу (8 записей); D3 —
+ архив (15 записей + последние дайджесты). Правила: уровень (час — D2,
день — D3), слова о прошлом («помнишь», «вчера», «за месяц», «как
договаривались»…), ответ на старое сообщение бота, задачи в батче;
потолок — круг (trust.max_depth). Решение пишется (DepthChosen) — сырьё
для головы ядра «depth».
"""
import json
import os
import re
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from . import es
from . import paths as _paths
try:
import numpy as np
except ImportError:
np = None
KINDS = ("conv", "out", "task", "digest")
BUDGET = {2: (8, 5000), 3: (15, 12000)} # глубина → (записей, символов)
SYNC_BATCH = 400
MIN_SCORE = float(os.environ.get("SIMBIOT_MEMORY_MIN_SCORE", "0.45")) # ниже — шум
_LOCK = threading.Lock()
_LAST = {} # уровень → последнее решение о глубине (для DepthChosen)
PAST_RE = re.compile(
r"помни|вспомн|вчера|позавчера|на прошлой|прошл(ый|ую|ой|ом|ые)\s+(раз|недел|месяц|встреч)|"
r"как\s+договаривал|мы\s+же|мы\s+обсуждал|ранее|раньше|в\s+прошлый|"
r"за\s+(последн\w+\s+)?(месяц|недел|дн)|что\s+(ты\s+)?(делал|сделал|было)|"
r"история|итог[иа]?\b|на\s+чём\s+останов|напомни", re.IGNORECASE)
ARCHIVE_RE = re.compile(r"за\s+(последн\w+\s+)?(месяц|недел|квартал|год)|"
r"чем\s+(ты\s+)?занимал|что\s+(ты\s+)?делал|отч[её]т|итоги|"
r"стратег|план\s+на", re.IGNORECASE)
TRIVIAL_RE = re.compile(r"^\W*(ок|окей|ok|ага|да|нет|спасибо|спс|понял|принято|супер|"
r"класс|норм|👍|🙏|❤️|🔥)\W*$", re.IGNORECASE)
# ────────────────────────── хранилище ──────────────────────────
def enabled() -> bool:
"""SIMBIOT_MEMORY=on (env > .env инстанса); дефолт off — тесты герметичны."""
from . import decision
return decision._cfg("SIMBIOT_MEMORY", "off").lower() == "on"
def _dir() -> str:
d = (os.environ.get("SIMBIOT_MEMORY_DIR", "").strip()
or os.path.join(_paths.home(), "memory"))
os.makedirs(d, mode=0o700, exist_ok=True)
return d
def _db():
db = sqlite3.connect(os.path.join(_dir(), "index.sqlite"), timeout=30)
db.execute("CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, kind TEXT, ts TEXT, "
"chat_id TEXT, circle INTEGER, who TEXT, text TEXT, emb BLOB)")
db.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)")
return db
def _store_ro():
return sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
def _embed(texts, prefix):
from . import kodi_core
return kodi_core.embed(texts, prefix)
# ────────────────────────── круг приватности записи ──────────────────────────
def chat_circle(chat_id, model: dict, senders: dict) -> int:
"""Круг чата = самый недоверенный известный участник; личка — круг собеседника."""
from . import trust
known = senders.get(str(chat_id)) or set()
if not known:
try:
cid = int(chat_id)
if cid > 0:
return trust.circle_of(f"tg:{cid}", model)
except (TypeError, ValueError):
pass
return trust.circle_of(trust.subject_of({"chat_id": chat_id}), model)
return max(trust.circle_of(s, model) for s in known)
# ────────────────────────── синхронизация ──────────────────────────
def sync(limit: int = SYNC_BATCH, log=None) -> int:
"""Новые события стора → записи индекса с эмбеддингами. Возвращает число записей."""
return _sync_once(limit, log)[1]
def sync_all(log=None) -> int:
"""Догнать стор целиком (бэкфилл): пачками, пока есть события."""
total = 0
while True:
nrows, nitems = _sync_once(SYNC_BATCH, log)
total += nitems
if nrows == 0:
return total
def _sync_once(limit: int, log=None) -> tuple:
"""(событий обработано, записей добавлено)."""
from . import trust
with _LOCK:
db = _db()
try:
last = int((db.execute("SELECT v FROM meta WHERE k='last'").fetchone() or ["0"])[0])
src = _store_ro()
try:
rows = src.execute(
"SELECT id, aggregate_id, event_type, timestamp, data FROM events WHERE id > ? "
"AND event_type IN ('MessageReceived','ReplySent','OutboundMessage',"
"'TaskSubmitted','TaskCompleted','DigestWritten') ORDER BY id LIMIT ?",
(last, limit)).fetchall()
if not rows:
return 0, 0
model = es.run_projections()
senders = json.loads((db.execute("SELECT v FROM meta WHERE k='senders'")
.fetchone() or ["{}"])[0])
senders = {k: set(v) for k, v in senders.items()}
pending = [] # (id, kind, ts, chat, circle, who, text)
for eid, aid, et, ts, d in rows:
d = json.loads(d)
if et == "MessageReceived":
if d.get("chat_id") is not None:
senders.setdefault(str(d["chat_id"]), set()).add(trust.subject_of(d))
elif et == "ReplySent":
rec = _conv_item(src, db, aid, d, ts, model, senders, pending)
if rec:
pending.append(rec)
# исходящее с тем же текстом — это ответ, не рассылка
db.execute("DELETE FROM items WHERE kind='out' AND chat_id=? "
"AND substr(text,1,200)=?",
(str(rec[3]), (d.get("text") or "")[:200]))
pending[:] = [p for p in pending if not (
p[1] == "out" and p[3] == rec[3]
and p[6][:200] == (d.get("text") or "")[:200])]
elif et == "OutboundMessage":
t = (d.get("text") or "").strip()
# ответ на входящее (ReplySent мог прийти раньше) — это conv, не рассылка
is_reply = any(p[1] == "conv" and p[3] == str(d.get("chat_id"))
and p[6].split("\nКоди: ", 1)[-1][:200] == t[:200]
for p in pending) or db.execute(
"SELECT 1 FROM items WHERE kind='conv' AND chat_id=? AND "
"instr(text, ?) > 0 LIMIT 1", (str(d.get("chat_id")),
"\nКоди: " + t[:200])).fetchone()
if t and not t.startswith("(сводный") and not is_reply:
pending.append((f"out:{aid}", "out", ts, str(d.get("chat_id")),
chat_circle(d.get("chat_id"), model, senders),
"Коди", t[:1500]))
elif et == "TaskCompleted":
t = (model.get("tasks") or {}).get(aid) or {}
ans = (d.get("answer") or d.get("result") or "")
if t.get("title") or ans:
pending.append((f"task:{aid}", "task", ts, "", trust.norm(t.get("circle")),
"задача", f"Задача: {t.get('title') or aid}\n"
f"Итог: {str(ans)[:1200]}"))
elif et == "DigestWritten":
txt = (d.get("text") or "").strip()
if (d.get("level") in ("hour", "day") and not d.get("stub") and txt
and not txt.startswith("[gate:quiet]") and "LLM не запускался" not in txt):
pending.append((f"digest:{d.get('level')}:{d.get('period')}", "digest",
ts, "", 1, f"дайджест {d.get('level')}", txt[:1500]))
finally:
src.close()
if pending:
vecs = _embed([p[6] for p in pending], "search_document")
db.executemany("INSERT OR REPLACE INTO items VALUES (?,?,?,?,?,?,?,?)",
[(*p, np.asarray(v, np.float32).tobytes())
for p, v in zip(pending, vecs)])
db.execute("INSERT OR REPLACE INTO meta VALUES ('last', ?)", (str(rows[-1][0]),))
db.execute("INSERT OR REPLACE INTO meta VALUES ('senders', ?)",
(json.dumps({k: sorted(v) for k, v in senders.items()}),))
db.commit()
if log and pending:
log(f"memory: +{len(pending)} записей")
return len(rows), len(pending)
finally:
db.close()
def _conv_item(src, db, aid, d, ts, model, senders, pending=()):
row = src.execute("SELECT data FROM events WHERE aggregate='inbox' AND aggregate_id=? "
"AND event_type='MessageReceived' LIMIT 1", (aid,)).fetchone()
if not row:
return None
m = json.loads(row[0])
text = (m.get("text") or "").strip()
if not text:
return None
reply = d.get("text") or ""
if reply.startswith("(сводный"):
head = ((d.get("batch") or {}).get("head"))
hp = next((p[6] for p in pending if p[0] == f"conv:{head}"), None) # голова в этой пачке
hr = (hp,) if hp else db.execute("SELECT text FROM items WHERE id=?", (f"conv:{head}",)).fetchone()
reply = (hr[0].split("\nКоди: ", 1)[-1] if hr else "(общий ответ на несколько сообщений)")
who = m.get("first_name") or m.get("username") or str(m.get("user_id"))
return (f"conv:{aid}", "conv", ts, str(m.get("chat_id")),
chat_circle(m.get("chat_id"), model, senders), who,
f"{who}: {text[:700]}\nКоди: {reply[:700]}")
# ────────────────────────── поиск ──────────────────────────
def search(query: str, circle: int, k: int = 8, kinds=KINDS, exclude_chat=None,
exclude_since: str = "") -> list:
"""Записи по смыслу, видимые кругу тика (круг записи ≥ круга тика)."""
if np is None or not query.strip():
return []
db = _db()
try:
rows = db.execute(
f"SELECT id, kind, ts, chat_id, circle, who, text, emb FROM items WHERE circle >= ? "
f"AND kind IN ({','.join('?' * len(kinds))})", (int(circle), *kinds)).fetchall()
finally:
db.close()
if exclude_chat is not None and exclude_since:
# свежий разговор этого чата уже в контексте (D1) — не дублируем
rows = [r for r in rows if not (r[3] == str(exclude_chat) and r[2] >= exclude_since)]
if not rows:
return []
q = np.asarray(_embed([query[:2000]], "search_query")[0], np.float32)
M = np.stack([np.frombuffer(r[7], np.float32) for r in rows])
sims = M @ q
order = np.argsort(-sims)[:k]
return [{"id": rows[i][0], "kind": rows[i][1], "ts": rows[i][2], "circle": rows[i][4],
"text": rows[i][6], "score": round(float(sims[i]), 3)} for i in order]
def stats() -> dict:
db = _db()
try:
by = dict(db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind").fetchall())
circ = dict(db.execute("SELECT circle, COUNT(*) FROM items GROUP BY circle").fetchall())
last = (db.execute("SELECT v FROM meta WHERE k='last'").fetchone() or ["0"])[0]
finally:
db.close()
return {"items": by, "by_circle": circ, "last_event": int(last)}
# ────────────────────────── глубина ──────────────────────────
def choose_depth(level: str, inbox: list, batch: list, circle: int) -> tuple:
"""(глубина 0..3, причины). Правила + потолок круга; голова ядра — в тени."""
from . import trust
reasons = []
base = {"min": 1, "hour": 2, "day": 3}.get(level, 1)
d = base
reasons.append(f"уровень {level} → D{base}")
texts = [(m.get("text") or "") for m in inbox or []]
if inbox and not batch and all(TRIVIAL_RE.match(t.strip() or "x") for t in texts) \
and not any(m.get("reply_to_full") for m in inbox):
d = 0
reasons = ["односложные реплики → D0"]
joined = "\n".join(texts + [f"{t.get('title') or ''} {t.get('details') or ''}" for t in batch or []])
if PAST_RE.search(joined):
d = max(d, 2)
reasons.append("отсылка к прошлому → D2")
if ARCHIVE_RE.search(joined):
d = max(d, 3)
reasons.append("обзор периода → D3")
old = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
for m in inbox or []:
if m.get("reply_to_full") and (m.get("reply_to_ts") or "") < old:
d = max(d, 2)
reasons.append("ответ на давнее сообщение бота → D2")
if batch:
d = max(d, 2)
reasons.append("задачи в батче → D2 (прошлые похожие)")
# голова ядра «depth» (harness/kodi_core.py): shadow — прогноз в причинах;
# on — уверенное «нужна глубина» поднимает D0/D1 до D2
student = None
try:
from . import kodi_core
student = kodi_core.depth_predict({"query": joined[:1500], "level": level,
"circle": circle, "reasons": " ".join(reasons)})
except Exception:
student = None
if student:
p, sure = student
reasons.append(f"ученик depth: p={p:.2f}{' уверен' if sure else ''}")
if sure and p >= .5 and d < 2 and kodi_core.mode(kodi_core.DEPTH_HEAD) == "on":
d = 2
reasons.append("ученик → D2")
cap = trust.max_depth(circle)
if d > cap:
reasons.append(f"потолок круга {circle} → D{cap}")
d = cap
_LAST[level] = {"depth": d, "reasons": reasons, "circle": circle, "query": joined[:500]}
return d, reasons
def context_block(depth: int, level: str, inbox: list, batch: list, circle: int) -> str:
"""Текст памяти для контекста тика на глубине ≥ 2 (пусто — для D0/D1)."""
if depth < 2 or not enabled():
return ""
k, budget = BUDGET[min(depth, 3)]
query = "\n".join([(m.get("text") or "") + " " + (m.get("reply_to_full") or "")[:300]
for m in inbox or []]
+ [f"{t.get('title') or ''}\n{(t.get('details') or '')[:400]}" for t in batch or []])
if not query.strip():
query = {"hour": "итоги последнего часа, задачи, решения",
"day": "итоги дня, решения, задачи, договорённости"}.get(level, "недавние дела")
chat = inbox[0].get("chat_id") if inbox else None
since = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
try:
hits = search(query, circle, k=k, exclude_chat=chat, exclude_since=since)
except Exception as e:
return f"ПАМЯТЬ: поиск недоступен ({str(e)[:80]}) — отвечай из контекста тика."
lines, used = [], 0
for h in hits:
if h["score"] < MIN_SCORE:
continue
line = f"- [{h['kind']} {h['ts'][:10]}] {h['text'][:900]}"
if used + len(line) > budget:
break
lines.append(line)
used += len(line)
if _LAST.get(level):
_LAST[level]["hits"] = len(lines)
if not lines:
return ""
head = (f"ПАМЯТЬ — НАЙДЕНО ПО СМЫСЛУ (глубина D{depth}; записи, видимые кругу {circle}; "
"это прошлое — опирайся, но проверяй актуальность):")
return head + "\n" + "\n".join(lines)
def last_choice(level: str) -> dict:
return dict(_LAST.get(level) or {})

View file

@ -157,6 +157,13 @@ def _own_messages(chat_ids, replied_hist, hours: int = 48, cap: int = 6) -> list
return list(reversed(out))
def _is_stub_digest(d: dict) -> bool:
"""Заглушка дайджеста (тихий/занятый такт без LLM) — не выжимка."""
t = (d.get("text") or "").lstrip()
return bool(d.get("stub")) or t.startswith(("[gate:quiet]", "[absorbed", "[busy")) \
or "LLM не запускался" in t
def _est_minutes(t: dict) -> int:
"""Оценка длительности: из TaskSubmitted или дефолт по классу.
@ -216,7 +223,7 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False):
from . import trust as _trust
ready = [t for t in (model.get("tasks") or {}).values()
if t.get("status") == "ready" and t.get("size") in classes
and _trust.level_serves(level_cfg.name, int(t.get("circle") or 1))]
and _trust.level_serves(level_cfg.name, _trust.norm(t.get("circle")))]
ready.sort(key=lambda t: t.get("created") or "")
if prioritize and len(ready) > 1:
from . import decision
@ -226,8 +233,8 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False):
if ready:
# один круг на батч: задача правления рядом с задачей владельца
# снизила бы права всего тика (taint) — круги не смешиваем
c0 = int(ready[0].get("circle") or 1)
ready = [t for t in ready if int(t.get("circle") or 1) == c0]
c0 = _trust.norm(ready[0].get("circle"))
ready = [t for t in ready if _trust.norm(t.get("circle")) == c0]
cap = _batch_cap(level_cfg)
budget_chars = int(level_cfg.ctx_tokens * CHARS_PER_TOKEN * TASK_BUDGET_SHARE)
picked, used = [], 0
@ -368,6 +375,21 @@ def build_context(level_cfg, model: dict, batch=None):
"""
parts = [level_cfg.mandate]
# динамическая память (harness/memory.py): глубина D0–D3 по входам тика,
# потолок — круг доверия; индекс догоняет стор перед поиском
depth, mem_circle = 1, 1
try:
from . import memory as _memory, trust as _trust
_inb = _pending_inbox(model)
_bat = batch if batch is not None else []
mem_circle = _trust.tick_circle(model, _inb, _bat)
depth, _why = _memory.choose_depth(level_cfg.name, _inb, _bat, mem_circle)
if depth >= 2 and _memory.enabled():
_memory.sync()
except Exception as e:
_log(f"memory: глубина/синк не удались ({str(e)[:120]}) — D1")
depth = 1
# идентичность/цели v0.1 — статично из policy (если есть);
# north-star метрика AUM (t-260929161138) — тоже policy, инжект всем уровням
policy = model.get("policy")
@ -383,7 +405,7 @@ def build_context(level_cfg, model: dict, batch=None):
digests = []
for lvl in lower:
for key, d in model["digests"].items():
if d["level"] == lvl and not d.get("stub"):
if d["level"] == lvl and not _is_stub_digest(d):
digests.append(d)
digests.sort(key=lambda d: d["ts"], reverse=True)
if digests:
@ -496,7 +518,9 @@ def build_context(level_cfg, model: dict, batch=None):
if m.get("replied") and not m.get("ignored")
and m.get("chat_id") in chat_ids
and (m.get("text") or "").strip()]
own = _own_messages(chat_ids, replied_hist)
if depth == 0:
replied_hist = [] # D0: односложные реплики — память разговора не нужна
own = _own_messages(chat_ids, replied_hist) if depth >= 1 else []
if own:
parts.append("ТВОИ НЕДАВНИЕ СООБЩЕНИЯ В ЭТОТ ЧАТ (сам отправил: итоги задач, "
"уведомления, предложения — Alex может отвечать на них):\n"
@ -558,6 +582,16 @@ def build_context(level_cfg, model: dict, batch=None):
lines = [f"- {t['id']}: {t['title'][:80]} [{t['size']}] {t['status']}" for t in tasks_board[:15]]
parts.append("ДОСКА ЗАДАЧ:\n" + "\n".join(lines))
if depth >= 2:
try:
from . import memory as _memory
block = _memory.context_block(depth, level_cfg.name, _pending_inbox(model),
batch or [], mem_circle)
if block:
parts.append(block)
except Exception as e:
_log(f"memory: блок памяти не собран ({str(e)[:120]})")
return "\n\n".join(parts)
@ -635,6 +669,39 @@ def _core_task_fit(batch: list) -> list:
return keep
_UNDER_RE = __import__("re").compile(
r"не\s+помню|не\s+нашёл|не\s+нашел|нет\s+(данных|в\s+памяти|информации)|"
r"не\s+знаю,?\s+(что|о\s+чём)|не\s+вижу\s+(в\s+контексте|истори)", __import__("re").I)
def _depth_record(level_cfg, trace_id, text):
"""DepthChosen: решение о глубине + признаки нехватки памяти по трассе
тика (поиск по базе, копание в своём сторе/дайджестах, «не помню») —
сырьё головы ядра «depth»."""
try:
from . import memory as _memory
ch = _memory.last_choice(level_cfg.name)
if not ch or not trace_id:
return
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
try:
calls = [json.loads(d) for (d,) in db.execute(
"SELECT data FROM events WHERE event_type='ToolCalled' "
"AND json_extract(data,'$.trace_id')=?", (trace_id,))]
finally:
db.close()
rag = sum(1 for c in calls if c.get("tool") == "rag_lookup")
dig = sum(1 for c in calls if c.get("tool") == "shell" and __import__("re").search(
r"harness\.db|cli\.py (events|tg-list|escalations)|docs/digests|sqlite3", c.get("input") or ""))
under = bool(rag or dig or _UNDER_RE.search(text or ""))
es.append_event("trace", f"depth:{trace_id}", "DepthChosen", {
"level": level_cfg.name, "trace_id": trace_id, "depth": ch["depth"],
"reasons": ch.get("reasons"), "circle": ch.get("circle"), "hits": ch.get("hits"),
"query": ch.get("query"), "rag_calls": rag, "self_digging": dig, "under": under})
except Exception:
pass # трасса — best effort
def _trust_followup(trace_id, batch, circle):
"""Страж отказал в тике с задачами круга ≥ 2 → владельцу кнопки
«разрешить полные права» по каждой такой задаче (раз на задачу)."""
@ -652,7 +719,7 @@ def _trust_followup(trace_id, batch, circle):
why = json.loads(rows[0][0]).get("reason") or "права круга"
from . import trust as _trust
for t in batch or []:
if int(t.get("circle") or 1) >= 2:
if _trust.norm(t.get("circle")) >= 2:
_trust.ask_owner_elevation(t, why)
except Exception as e:
_log(f"trust: кнопка владельцу не ушла ({e})")
@ -829,6 +896,7 @@ def run_level_tick(level_cfg, seq: int, model: dict):
circle=circle,
)
_trust_followup(trace_id, batch, circle)
_depth_record(level_cfg, trace_id, text)
except Exception as e:
# e-260930162600-cbb7e2/cbb7e2-семейство: TimeoutExpired от isolated_runner
# пролетал МИМО блока text-is-None ниже — claimed-задачи висели в running

View file

@ -99,31 +99,34 @@ def _strip_html(s: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", s or "")).strip()
def resolve_bot_message(chat_id, message_id, prefix: str = "") -> str:
def resolve_bot_message(chat_id, message_id, prefix: str = "", with_ts: bool = False):
"""Полный текст НАШЕГО сообщения, на которое ответили: журнал исходящих по
(chat, message_id); для старых — по совпадению начала с доставками/ответами."""
(chat, message_id); для старых — по совпадению начала с доставками/ответами.
with_ts=True → (текст, время отправки) — для глубины памяти (давнее → D2)."""
import sqlite3
found = ("", "")
try:
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
try:
row = db.execute("SELECT data FROM events WHERE aggregate='outbound' AND aggregate_id=? "
"LIMIT 1", (f"{chat_id}:{message_id}",)).fetchone()
row = db.execute("SELECT data, timestamp FROM events WHERE aggregate='outbound' "
"AND aggregate_id=? LIMIT 1", (f"{chat_id}:{message_id}",)).fetchone()
if row:
return json.loads(row[0]).get("text") or ""
found = (json.loads(row[0]).get("text") or "", row[1])
else:
want = _strip_html(prefix)[:150]
if len(want) < 20:
return ""
for (d,) in db.execute(
"SELECT data FROM events WHERE event_type IN ('DeliverySent','ReplySent',"
"'FollowupSent','OutboundMessage') ORDER BY id DESC LIMIT 3000"):
if len(want) >= 20:
for d, ts in db.execute(
"SELECT data, timestamp FROM events WHERE event_type IN ('DeliverySent',"
"'ReplySent','FollowupSent','OutboundMessage') ORDER BY id DESC LIMIT 3000"):
t = json.loads(d).get("text") or ""
if _strip_html(t).startswith(want):
return t
found = (t, ts)
break
finally:
db.close()
except Exception:
pass
return ""
return found if with_ts else found[0]
def set_reaction(chat_id, message_id: int, emoji: str = "👀", is_big: bool = False):
@ -455,10 +458,11 @@ def receive_message(update: dict):
data["reply_to_first_name"] = rfrom.get("first_name")
data["mentions_bot"] = _mentions_bot(msg)
if data.get("reply_to_is_bot") and data.get("reply_to_message_id"):
full = resolve_bot_message(chat.get("id"), data["reply_to_message_id"],
data.get("reply_to_text") or "")
full, rts = resolve_bot_message(chat.get("id"), data["reply_to_message_id"],
data.get("reply_to_text") or "", with_ts=True)
if full:
data["reply_to_full"] = full[:4000]
data["reply_to_ts"] = rts
es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data)
return f"m-{update['update_id']}"

View file

@ -1,5 +1,10 @@
"""Simbiot Harness — круги доверия (вторая ось к лестнице времени).
Куб 4×4×4 (Alex, 2026-10-17): 4 круга × 4 тика (sec/min/hour/day) × 4
глубины памяти (harness/memory.py D0–D3) — одна таблица политик, не 64
программы. Круги: 1 владелец (+система), 2 правление, 3 свои (члены,
MCP-агенты, партнёры), 4 остальные.
Время (sec…year) отвечает «как часто и как глубоко думать», доверие —
«что можно с этим человеком». Оси независимы: 8 циклов × 8 кругов — не 64
программы, а один код + таблица политик POLICY (уровни, инструменты).
@ -30,9 +35,17 @@ from . import es
OWNER_TG = os.environ.get("SIMBIOT_OWNER_TG", "7569223131") # Alex
NAMES = {0: "система", 1: "владелец", 2: "правление", 3: "члены кооператива",
4: "агенты кооператива", 5: "партнёры и клиенты", 6: "знакомые без роли",
7: "чужие"}
NAMES = {1: "владелец", 2: "правление", 3: "свои", 4: "остальные"}
def norm(c) -> int:
"""Круг в 1..4: тик без внешних входов (система) — 1; до 4 кругов были
номера 0..7 — в сторе только 1/2, смысл которых не изменился."""
try:
c = int(c)
except (TypeError, ValueError):
return 1
return min(max(c, 1), 4)
_REPLY = {"tg-reply", "decide"}
_WORK = _REPLY | {"task", "task-await", "complete", "escalate", "block"}
@ -43,22 +56,20 @@ _MCP_BOARD = {"booking_slots", "booking_create", "booking_cancel", "list_calls",
"contact_get", "contact_list", "feature_backlog", "feature_request"}
_MCP_MEMBER = {"booking_slots", "booking_create", "projects_list", "whoami", "feature_request",
"feature_backlog"}
_MCP_AGENT = {"projects_list", "list_agents", "whoami", "list_events", "feature_backlog"}
# уровни времени × круги: кого какой уровень обслуживает (задачи круга вне
# списка уровня он не берёт); инструменты — по кругу
# тики × круги: какие уровни обслуживают круг (задачи круга вне списка
# уровень не берёт); инструменты и максимальная глубина памяти — по кругу.
# «Свои» (3) — члены портала, MCP-агенты, партнёры: запись на звонки,
# проекты, фичи; казна/member_create/деньги — нет.
POLICY = {
0: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True},
1: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True},
1: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True,
"max_depth": 3},
2: {"levels": ("min", "hour", "day"), "shell": _BOARD, "files": "read_safe",
"mcp": _MCP_BOARD, "rag": True},
3: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_MEMBER, "rag": True},
4: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_AGENT, "rag": True},
5: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none",
"mcp": {"booking_slots", "booking_create"}, "rag": False},
6: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", "mcp": set(),
"rag": False},
7: {"levels": (), "shell": set(), "files": "none", "mcp": set(), "rag": False},
"mcp": _MCP_BOARD, "rag": True, "max_depth": 3},
3: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_MEMBER,
"rag": True, "max_depth": 2},
4: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", "mcp": set(),
"rag": False, "max_depth": 1},
}
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -87,42 +98,43 @@ def default_circle(subject: str) -> int:
return 1 # локальный терминал = доступ к серверу
if subject == f"tg:{OWNER_TG}":
return 1
if subject.startswith("agent:"):
return 4
if subject.startswith("portal:"):
return 3
if subject.startswith("tg:"):
return 6 # прошёл ALLOWED, но роли нет
return 7
if subject.startswith(("agent:", "portal:")):
return 3 # свои: MCP-агенты и члены портала
return 4 # прочие tg (прошли ALLOWED без роли) и чужие
def circle_of(subject: str, model: dict = None) -> int:
trust = ((model or {}).get("trust") or {})
if subject in trust:
return int(trust[subject]["circle"])
return norm(trust[subject]["circle"])
return default_circle(subject)
def tick_circle(model: dict, inbox: list, tasks: list) -> int:
"""Самый недоверенный вход контекста тика; без внешних входов — 0."""
c = 0
"""Самый недоверенный вход контекста тика; без внешних входов — 1 (система)."""
c = 1
for m in inbox or []:
c = max(c, circle_of(subject_of(m), model))
for t in tasks or []:
c = max(c, int(t.get("circle", 1) if t.get("circle") is not None else 1))
c = max(c, norm(t.get("circle")))
return c
def level_serves(level: str, circle: int) -> bool:
lv = POLICY[circle]["levels"]
lv = POLICY[norm(circle)]["levels"]
return lv is None or level in lv
def max_depth(circle: int) -> int:
"""Потолок глубины памяти круга (D0–D3, harness/memory.py)."""
return POLICY[norm(circle)]["max_depth"]
# ────────────────────────── изменение круга (только человек) ──────────────────────────
def set_circle(subject: str, circle: int, by: str, note: str = "") -> None:
if circle not in NAMES:
raise ValueError(f"круг {circle} вне 0..7")
raise ValueError(f"круг {circle} вне 1..4")
if os.environ.get("SIMBIOT_TICK_CIRCLE"):
raise PermissionError("круг доверия меняет только человек — не из LLM-тика")
es.append_event("trust", subject, "TrustSet",
@ -152,7 +164,8 @@ def _strip_cd(cmd: str) -> str:
def check_shell(cmd: str, circle: int):
"""(ok, причина). Полный shell — круги 0/1; иначе одна команда cli.py из списка."""
"""(ok, причина). Полный shell — круг 1; иначе одна команда cli.py из списка."""
circle = norm(circle)
if re.search(r"cli\.py\s+trust\b", cmd):
return False, "менять круги доверия из тика нельзя — только человек"
pol = POLICY[circle]["shell"]
@ -178,13 +191,14 @@ def check_shell(cmd: str, circle: int):
if sub == "tg-deliver-file":
args = toks[toks.index(sub) + 1:] if sub in toks else []
path = next((a for a in args if not a.startswith("-")), "")
ok, why = check_path(path, "read", 2 if circle <= 2 else circle)
ok, why = check_path(path, "read", 2)
if not ok:
return False, "файл: " + why
return True, ""
def check_path(path: str, op: str, circle: int):
circle = norm(circle)
pol = POLICY[circle]["files"]
if pol == "full":
return True, ""
@ -209,6 +223,7 @@ def check_path(path: str, op: str, circle: int):
def check_tool(name: str, inp: dict, circle: int):
"""Решение стража по вызову инструмента LLM-тика: (ok, причина)."""
inp = inp or {}
circle = norm(circle)
if name == "shell":
return check_shell(str(inp.get("command") or ""), circle)
if name in ("read", "write", "edit"):
@ -230,6 +245,7 @@ def check_tool(name: str, inp: dict, circle: int):
def mandate_line(circle: int) -> str:
circle = norm(circle)
pol = POLICY[circle]
if pol["shell"] == "full":
return f"КРУГ ДОВЕРИЯ ТИКА: {circle} ({NAMES[circle]}) — полные права."
@ -253,7 +269,7 @@ def ask_owner_elevation(task: dict, reason: str) -> bool:
chat = tg_inbox._default_chat_id()
if chat is None:
return False
c = int(task.get("circle") or 1)
c = norm(task.get("circle"))
text = (f"🔐 Задача из круга «{NAMES.get(c, c)}» упёрлась в права:\n"
f"«{(task.get('title') or '')[:200]}»\n\nЧто не дали: {reason[:300]}\n\n"
"Разрешить выполнить с полными правами?")

View file

@ -163,7 +163,7 @@ os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core")
kodi_core._forget("task_fit")
st = kodi_core.status()
check("статус: обе головы", set(st) == {"addressing", "task_fit"} and st["task_fit"]["champion"], st.keys())
check("статус: все головы", set(st) == {"addressing", "task_fit", "depth"} and st["task_fit"]["champion"], st.keys())
# ── rule-ветка (рефлексия t-261017093718): внешние системы → не влезет ──
# чемпиона нет (пустой core-dir, как снятый) → решает правило; route=rule:ext

184
tests/test_memory.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Динамическая память Коди (harness/memory.py) — hermetic.
Запуск: ./venv/bin/python tests/test_memory.py
Эмбеддинги — «мешок слов» (общие слова → близость), Ollama не трогается.
Проверяет: память выключена по умолчанию; синк: разговоры (батч — ответ
головы), свои рассылки (не ответы), итоги задач, дайджесты без заглушек;
круг приватности записи (личка Alex — 1, правление — 2, задача — её круг);
поиск не отдаёт тику правления записи владельца; свежий разговор чата не
дублируется; глубина: D1/D0/D2/D3 по правилам, потолок круга, давний
reply; контекст: блок памяти на D2+, D0 без памяти разговора; заглушки
дайджестов не в выжимках; DepthChosen с признаком нехватки памяти.
"""
import hashlib
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timedelta, timezone
sys.path.insert(0, "/root/simbiot-harness")
_tmp = tempfile.mkdtemp(prefix="simbiot_memory_")
os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db")
os.environ["SIMBIOT_MEMORY_DIR"] = os.path.join(_tmp, "memory")
os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core")
for _v in ("SIMBIOT_MEMORY", "SIMBIOT_DECISION"):
os.environ.pop(_v, None)
import numpy as np
from harness import es, kodi_core, memory, scheduler, trust
from harness.levels import LEVELS
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
def bow(texts, prefix="classification"):
out = []
for t in texts:
v = np.zeros(768, np.float32)
for w in re.findall(r"\w{4,}", t.lower()):
v[int(hashlib.md5(w.encode()).hexdigest(), 16) % 768] += 1
v += 1e-3
out.append(v / np.linalg.norm(v))
return out
kodi_core.embed = bow
check("память выключена по умолчанию", memory.enabled() is False)
os.environ["SIMBIOT_MEMORY"] = "on"
trust.bootstrap()
A, V, G = 7569223131, 156156021, -1004402779881
N = [0]
def msg(chat, uid, who, text, reply=None, batch=None):
N[0] += 1
mid = f"m-{N[0]}"
es.append_event("inbox", mid, "MessageReceived", {"update_id": N[0], "chat_id": chat,
"user_id": uid, "first_name": who, "text": text, "chat_type":
"private" if chat > 0 else "supergroup"})
if reply is not None:
d = {"to_chat": chat, "text": reply}
if batch:
d["batch"] = batch
es.append_event("inbox", mid, "ReplySent", d)
return mid
h = msg(A, A, "Alex", "секретный бюджет казны проекта альфа", "Бюджет альфа: 40 тысяч")
msg(G, V, "Vlad", "лендинг фильма мой срок дизайн обложки", "Сделаю лендинг фильма")
b1 = msg(A, A, "Alex", "проверь сервер миграцию", "Сервер мигрирован, всё ок",
batch={"head": None})
b2 = msg(A, A, "Alex", "и бэкапы тоже", "(сводный ответ батча b-1 — см. " + b1 + ")",
batch={"head": b1})
es.append_event("outbound", f"{A}:1", "OutboundMessage", {"chat_id": A, "message_id": 1,
"text": "🧠 Предложение: включить ученика адресации"})
es.append_event("outbound", f"{A}:2", "OutboundMessage", {"chat_id": A, "message_id": 2,
"text": "Сервер мигрирован, всё ок"}) # это ответ — уже в conv
es.append_event("task", "t-1", "TaskSubmitted", {"id": "t-1", "title": "Обложка фильма мой срок",
"size": "M", "circle": 2})
es.append_event("task", "t-1", "TaskCompleted", {"answer": "Обложка фильма готова: out/cover.png"})
es.append_event("cycle", "digest:hour:1", "DigestWritten", {"level": "hour", "period": "p1",
"text": "Час: обсудили бюджет проекта альфа, сколько там денег и сроки"})
es.append_event("cycle", "digest:hour:2", "DigestWritten", {"level": "hour", "period": "p2",
"text": "[gate:quiet] hour: LLM не запускался", "stub": True})
n = memory.sync_all()
st = memory.stats()
check("синк: разговоры/рассылка/задача/дайджест", st["items"] == {"conv": 4, "out": 1, "task": 1, "digest": 1}, st)
db = memory._db()
items = {r[0]: r for r in db.execute("SELECT id, kind, circle, text FROM items")}
db.close()
check("ответ батча — текст головы", "Сервер мигрирован" in items[f"conv:{b2}"][3])
check("рассылка-ответ не дублируется как out", not any(
r[1] == "out" and "мигрирован" in r[3] for r in items.values()))
check("личка Alex — круг 1", items[f"conv:{h}"][2] == 1)
check("группа с Vlad — круг 2", items["conv:m-2"][2] == 2)
check("задача правления — круг 2, дайджест — 1",
items["task:t-1"][2] == 2 and items["digest:hour:p1"][2] == 1)
check("заглушка дайджеста не в памяти", "digest:hour:p2" not in items)
check("повторный синк — без новых записей", memory.sync() == 0)
# ── поиск и приватность ──
s2 = memory.search("бюджет альфа казна", circle=2, k=10)
check("тик правления не видит личку владельца", all(x["circle"] >= 2 for x in s2)
and not any("секретный" in x["text"] for x in s2), s2)
s1 = memory.search("бюджет альфа казна", circle=1, k=3)
check("владелец находит по смыслу", s1 and "альфа" in s1[0]["text"], s1)
since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
s3 = memory.search("бюджет альфа", circle=1, k=10, exclude_chat=A, exclude_since=since)
check("свежий разговор чата не дублируется", not any(x["kind"] == "conv" and "альфа" in x["text"] for x in s3))
# ── глубина ──
cd = lambda lvl, inbox, batch=(), c=1: memory.choose_depth(lvl, list(inbox), list(batch), c)[0]
m_ = lambda t, **k: {"text": t, "chat_id": A, **k}
check("мин по умолчанию — D1", cd("min", [m_("как дела с лендингом")]) == 1)
check("«ок/спасибо» — D0", cd("min", [m_("ок")]) == 0 and cd("min", [m_("Спасибо!")]) == 0)
check("«помнишь…» — D2", cd("min", [m_("помнишь, что мы решили по бюджету?")]) == 2)
check("«что ты делал за месяц» — D3", cd("min", [m_("расскажи что ты делал за последний месяц")]) == 3)
check("задачи в батче — D2", cd("min", [], [{"title": "x"}]) == 2)
check("час — D2, день — D3", cd("hour", []) == 2 and cd("day", []) == 3)
check("потолок круга 4 — D1", cd("min", [m_("что было за месяц")], c=4) == 1)
check("потолок круга 3 — D2", cd("day", [], c=3) == 2)
old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat()
check("reply на давнее сообщение бота — D2", cd("min", [m_("давай", reply_to_full="x", reply_to_ts=old)]) == 2)
# ── контекст ──
q = msg(A, A, "Alex", "помнишь бюджет проекта альфа? сколько там")
ctx = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("контекст D2: блок памяти", "ПАМЯТЬ — НАЙДЕНО ПО СМЫСЛУ (глубина D2" in ctx)
es.append_event("inbox", q, "ReplySent", {"to_chat": A, "text": "40 тысяч"})
msg(G, V, "Vlad", "помнишь бюджет альфа?")
ctx2 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("тик правления: память без лички владельца", "ПАМЯТЬ" not in ctx2 or "секретный" not in ctx2)
es.append_event("inbox", f"m-{N[0]}", "ReplySent", {"to_chat": G, "text": "не скажу"})
msg(A, A, "Alex", "ок")
ctx3 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("D0: без памяти разговора", "НЕДАВНИЙ РАЗГОВОР" not in ctx3 and "ПАМЯТЬ" not in ctx3)
ctxh = scheduler.build_context(LEVELS["hour"], es.run_projections(), batch=[])
check("выжимки: заглушки дайджестов отсеяны", "LLM не запускался" not in ctxh)
# ── DepthChosen ──
memory.choose_depth("min", [m_("помнишь?")], [], 1)
es.append_event("trace", "min-77", "ToolCalled", {"trace_id": "min-77", "tool": "rag_lookup", "input": "{}"})
scheduler._depth_record(LEVELS["min"], "min-77", "ответ")
dc = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "DepthChosen"]
check("DepthChosen: глубина, причины, нехватка памяти", dc and dc[-1]["depth"] == 2
and dc[-1]["under"] is True and dc[-1]["rag_calls"] == 1, dc[-1:])
# ── голова ядра depth: учится на решениях правил + поправках «не хватило» ──
for i in range(60):
past = i % 3 == 0
under = i % 3 == 1 # «расскажи итоги проекта» без слов-триггеров, но агент полез искать
q = ("помнишь, что решили по проекту " if past else
"расскажи статус проекта подробно " if under else "привет как дела ") + str(i)
es.append_event("trace", f"depth:min-{i}", "DepthChosen", {
"level": "min", "trace_id": f"min-{i}", "depth": 2 if past else 1, "reasons": [],
"circle": 1, "query": q, "under": under, "rag_calls": int(under)})
rows = kodi_core.depth_dataset()
check("depth: поправка «не хватило» → метка 1, вес 3",
any(r[1] == 1.0 and r[2] == 3.0 and r[3] == "under" for r in rows))
rep = kodi_core.train(log=lambda *_: None, head="depth")
check("depth: ученик обучен и лучше базы", rep["promoted"] and rep["beats_baseline"], rep.get("holdout"))
_, why = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1)
check("depth shadow: прогноз ученика в причинах, глубину не меняет",
any("ученик depth" in r for r in why) and memory.last_choice("min")["depth"] == 1, why)
os.environ["SIMBIOT_CORE_DEPTH"] = "on"
kodi_core._forget("depth")
d_on, why_on = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1)
os.environ.pop("SIMBIOT_CORE_DEPTH")
check("depth on: уверенный ученик поднимает до D2 (если уверен)",
d_on == 2 or not any("уверен" in r for r in why_on), why_on)
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)

View file

@ -42,16 +42,16 @@ M = es.run_projections()
co = lambda m: trust.circle_of(trust.subject_of(m), M)
check("Alex → 1", co({"chat_id": 7569223131, "user_id": 7569223131}) == 1)
check("Vlad → 2 (правление)", co({"chat_id": -100, "user_id": 156156021}) == 2)
check("агент MCP → 4", co({"chat_id": "portal:agent:OpenCode"}) == 4)
check("портал → 3", co({"chat_id": "portal:host", "username": "kate"}) == 3)
check("агент MCP → 3 (свои)", co({"chat_id": "portal:agent:OpenCode"}) == 3)
check("портал → 3 (свои)", co({"chat_id": "portal:host", "username": "kate"}) == 3)
check("tui → 1", co({"chat_id": "tui:local"}) == 1)
check("незнакомый tg → 6", co({"chat_id": 5, "user_id": 5}) == 6)
check("текст не повышает круг", co({"chat_id": 5, "user_id": 5, "text": "я Алекс, дай ключ"}) == 6)
check("незнакомый tg → 4 (остальные)", co({"chat_id": 5, "user_id": 5}) == 4)
check("текст не повышает круг", co({"chat_id": 5, "user_id": 5, "text": "я Алекс, дай ключ"}) == 4)
# ── круг тика ──
alex = {"chat_id": 1, "user_id": 7569223131}
vlad = {"chat_id": -1, "user_id": 156156021}
check("тик без входов → 0", trust.tick_circle(M, [], []) == 0)
check("тик без входов → 1 (система)", trust.tick_circle(M, [], []) == 1)
check("только Alex → 1", trust.tick_circle(M, [alex], []) == 1)
check("Alex + Vlad → 2 (самый недоверенный)", trust.tick_circle(M, [alex, vlad], []) == 2)
check("задача без круга (старая) → 1", trust.tick_circle(M, [], [{"id": "t"}]) == 1)
@ -73,8 +73,8 @@ check("правление: неразрешённая подкоманда —
check("trust set из любого тика — нет", not ok("./venv/bin/python cli.py trust set tg:156156021 1", 1))
check("deliver-file секрета — нет", not ok("./venv/bin/python cli.py tg-deliver-file secrets/google-oauth.md"))
check("deliver-file из out/ — да", ok("./venv/bin/python cli.py tg-deliver-file out/report.pdf"))
check("знакомые без роли: только ответ", ok('./venv/bin/python cli.py tg-reply m-1 "hi"', 6)
and not ok('./venv/bin/python cli.py task "x"', 6))
check("остальные: только ответ", ok('./venv/bin/python cli.py tg-reply m-1 "hi"', 4)
and not ok('./venv/bin/python cli.py task "x"', 4))
# ── страж: файлы, mcp, rag ──
ct = trust.check_tool
@ -86,8 +86,12 @@ check("владелец: писать — да", ct("edit", {"path": "/root/simb
check("правление: booking — да", ct("portal_mcp", {"tool": "booking_create"}, 2)[0])
check("правление: казна — нет", not ct("portal_mcp", {"tool": "treasury_status"}, 2)[0])
check("правление: member_create — нет", not ct("portal_mcp", {"tool": "member_create"}, 2)[0])
check("агент: rag — да, booking — нет", ct("rag_lookup", {}, 4)[0] and not ct("portal_mcp", {"tool": "booking_create"}, 4)[0])
check("партнёр: rag — нет", not ct("rag_lookup", {}, 5)[0])
check("свои: rag и запись на звонок — да, казна — нет", ct("rag_lookup", {}, 3)[0]
and ct("portal_mcp", {"tool": "booking_create"}, 3)[0] and not ct("portal_mcp", {"tool": "treasury_status"}, 3)[0])
check("остальные: rag — нет", not ct("rag_lookup", {}, 4)[0])
check("старые номера кругов нормализуются (0→1, 7→4)", trust.norm(0) == 1 and trust.norm(7) == 4 and trust.norm(None) == 1)
check("потолок глубины памяти: 1/2 → D3, 3 → D2, 4 → D1",
[trust.max_depth(c) for c in (1, 2, 3, 4)] == [3, 3, 2, 1])
check("неизвестный инструмент у правления — нет", not ct("python_exec", {}, 2)[0])
check("мандат: строка круга", "правление" in trust.mandate_line(2) and "полные права" in trust.mandate_line(1))
@ -109,10 +113,10 @@ check("задача вне тика — без круга (= 1)", T[tid_a].get("
hour = LEVELS["hour"]
ids = scheduler.select_task_batch(hour, es.run_projections())
check("батч одного круга", len({int(es.run_projections()["tasks"][i].get("circle") or 1) for i in ids}) == 1, ids)
os.environ["SIMBIOT_TICK_CIRCLE"] = "5"
tid_p, _ = commands.submit_task("Партнёрская задача на час", "детали", 60)
os.environ["SIMBIOT_TICK_CIRCLE"] = "4"
tid_p, _ = commands.submit_task("Задача остальных на час", "детали", 60)
os.environ.pop("SIMBIOT_TICK_CIRCLE")
check("час не берёт задачи партнёров (круг 5 — только min)",
check("час не берёт задачи круга «остальные» (только min)",
tid_p not in scheduler.select_task_batch(hour, es.run_projections()))
# ── одобрение владельцем ──