simbiot-harness/harness/memory.py
Simbiot Harness 969d57f14a куб 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>
2026-10-17 10:43:12 +00:00

355 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 {})