feat: dream-cycle merge scan + pattern desk (leak recall)

Phases 5 & 6, completing the scouting desk.

Phase 5 — the nightly consolidation (dream cycle, coherence block) now runs
poker.scan_merge_candidates(), filing likely same-person merges to the review
queue off the hot path. Fail-safe: a scan error never sinks the cycle.

Phase 6 — "you've hit this leak before". Scar/confidence notes are embedded on
write; recall_similar_rituals() finds past ones close to the current spot,
excluding tonight. The scouting desk adds a pattern pass that surfaces them — but
ONLY on genuine strategy/tilt talk (a length + cue gate), so routine logging like
"stack 350" never pays for an embed. Respects the per-turn latency concern.

7 tests (deterministic embed stub to keep threshold assertions stable). Full
suite 153 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 23:01:53 +00:00
parent 056578ac75
commit d5c80f6153
4 changed files with 154 additions and 10 deletions
+40 -4
View File
@@ -173,7 +173,9 @@ def _c():
"ALTER TABLE poker_players ADD COLUMN descriptors TEXT",
"ALTER TABLE poker_players ADD COLUMN descriptor_embedding BLOB",
"ALTER TABLE poker_players ADD COLUMN distinctiveness REAL",
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1"):
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1",
# Embedded scar/confidence notes → "you've hit this leak before".
"ALTER TABLE poker_rituals ADD COLUMN embedding BLOB"):
try:
conn.execute(ddl)
except Exception:
@@ -502,16 +504,50 @@ def log_ritual(kind: str, content: str | None = None, classification: str | None
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
# Embed scar/confidence text so a similar spot later can recall it (Gap 3:
# "you've hit this leak before"). Other ritual kinds don't need it.
blob = None
if content and kind in ("scar", "confidence"):
vec = _embed_vec(content)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(sid, kind, content, classification, hand_id, _now()),
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, "
"embedding, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(sid, kind, content, classification, hand_id, blob, _now()),
)
return int(cur.lastrowid)
def recall_similar_rituals(text: str, kinds: tuple[str, ...] = ("scar", "confidence"),
k: int = 2, min_sim: float = 0.60,
exclude_session: int | None = None) -> list[dict]:
"""Past scar/confidence notes most similar to `text` — the leak/discipline
pattern recall. Excludes the current session so tonight doesn't echo itself."""
vec = _embed_vec(text)
if vec is None:
return []
ph = ",".join("?" * len(kinds))
rows = _c().execute(
f"SELECT r.id, r.kind, r.content, r.classification, r.session_id, r.embedding, "
f"s.started_at AS s_at, s.venue AS venue FROM poker_rituals r "
f"LEFT JOIN poker_sessions s ON s.id = r.session_id "
f"WHERE r.embedding IS NOT NULL AND r.kind IN ({ph})", tuple(kinds)
).fetchall()
scored = []
for row in rows:
if exclude_session and row["session_id"] == exclude_session:
continue
sim = _cos(vec, memory._from_blob(row["embedding"]))
if sim >= min_sim:
scored.append((sim, row))
scored.sort(key=lambda x: x[0], reverse=True)
return [{"kind": r["kind"], "content": r["content"], "classification": r["classification"],
"when": r["s_at"], "venue": r["venue"], "sim": round(s, 3)}
for s, r in scored[:k]]
def list_rituals(session_id: int | None = None,
kinds: tuple[str, ...] | None = None) -> list[dict]:
"""Ritual events for a session, oldest first; optionally filtered by kind."""