Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
"""Lucid moment orchestration -- (D-13 Option A).
|
|
|
|
The "main insight of the day": exactly ONE `claude -p` subprocess call per
|
|
night, at the end of the last REM cycle. The prompt is built from 3 locally-
|
|
extracted schema patterns + 1 surprising episode; Claude distils them into a
|
|
single unifying insight of 1-2 sentences which we store as a semantic-tier
|
|
record tagged `overnight_insight`.
|
|
|
|
Constitutional guards:
|
|
- LOCAL is the primary worker. This module owns the single surgical
|
|
Claude call; all other consolidation work is pure-numpy/NetworkX/TF-IDF.
|
|
- the call goes through host_cli.invoke_host_once which scrubs
|
|
the paid-API env var and validates the credentials.json subscription mode
|
|
before spawning the subprocess. This module NEVER references the paid-API
|
|
env var by name.
|
|
- pre-flight budget gate via BudgetTracker.can_spend. A call that
|
|
would exceed the daily cap (overflow into weekly buffer) is silently
|
|
skipped, queued implicitly for the next night.
|
|
- Bug #43333: cost_usd > 0 from invoke_host_once is recorded by the wrapper
|
|
(BudgetTracker.disable_host). This module short-circuits on host_disabled
|
|
so the bad call never repeats.
|
|
- / C5: the inserted MemoryRecord is assembled once from Claude's
|
|
text response; we do NOT rewrite literal_surface after insert.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from iai_mcp.host_cli import (
|
|
BudgetTracker,
|
|
invoke_host_once,
|
|
verify_credentials_subscription,
|
|
)
|
|
from iai_mcp.daemon_state import load_state
|
|
from iai_mcp.events import query_events, write_event
|
|
from iai_mcp.schema import induce_schemas_tier0
|
|
from iai_mcp.tz import load_user_tz
|
|
from iai_mcp.types import MemoryRecord
|
|
|
|
# Option A prompt template. The fragments "3 locally-found patterns",
|
|
# "1 surprising episode", "unifying insight", and "1-2 sentences" are verbatim
|
|
# per the locked decision; grep tests assert they appear unmodified.
|
|
INSIGHT_PROMPT_TEMPLATE: str = (
|
|
"Here are 3 locally-found patterns from today + 1 surprising episode. "
|
|
"What is the unifying insight? Reply in 1-2 sentences.\n\n"
|
|
"Patterns:\n{patterns}\n\n"
|
|
"Surprise:\n{surprise}"
|
|
)
|
|
|
|
# Conservative pre-flight token estimate for the one nightly call -- covers
|
|
# the prompt frame + patterns + surprise payload. Actual spend is recorded
|
|
# post-call via BudgetTracker.record(tokens_in, tokens_out).
|
|
PROMPT_ESTIMATE_TOKENS: int = 500
|
|
|
|
# Kinds of events considered "surprising" for the prompt.
|
|
_SURPRISE_KINDS: frozenset[str] = frozenset({
|
|
"art_gate_high_novelty",
|
|
"contradiction_detected",
|
|
"s4_contradiction",
|
|
"s5_drift",
|
|
})
|
|
|
|
|
|
def _gather_patterns(store) -> list[str]:
|
|
"""Top-3 recent schema candidates by confidence. Graceful on empty."""
|
|
try:
|
|
schemas = induce_schemas_tier0(store) or []
|
|
except Exception: # noqa: BLE001 -- pattern extraction must never crash insight
|
|
schemas = []
|
|
|
|
def _conf(s: Any) -> float:
|
|
# SchemaCandidate has .confidence; dicts may use the same key.
|
|
val = getattr(s, "confidence", None)
|
|
if val is None and isinstance(s, dict):
|
|
val = s.get("confidence")
|
|
try:
|
|
return float(val or 0.0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
def _text(s: Any) -> str:
|
|
# SchemaCandidate exposes .pattern; dicts use "pattern" / "description".
|
|
for attr in ("pattern", "description", "summary"):
|
|
val = getattr(s, attr, None)
|
|
if val:
|
|
return str(val)
|
|
if isinstance(s, dict) and s.get(attr):
|
|
return str(s[attr])
|
|
return str(s)
|
|
|
|
schemas_sorted = sorted(schemas, key=_conf, reverse=True)
|
|
top3 = schemas_sorted[:3]
|
|
if not top3:
|
|
return ["[no patterns yet]"]
|
|
return [_text(s) for s in top3]
|
|
|
|
|
|
def _gather_surprise(store) -> str:
|
|
"""Most recent surprising event over the last 24h. Graceful on empty."""
|
|
try:
|
|
since = datetime.now(timezone.utc).replace(
|
|
hour=0, minute=0, second=0, microsecond=0,
|
|
)
|
|
candidates = query_events(store, since=since, limit=1000) or []
|
|
except Exception: # noqa: BLE001 -- event query must never crash insight
|
|
candidates = []
|
|
|
|
for event in candidates:
|
|
if event.get("kind") in _SURPRISE_KINDS:
|
|
data = event.get("data") or event
|
|
return str(data)[:500]
|
|
return "[no surprise yet]"
|
|
|
|
|
|
async def generate_overnight_insight(store, session_id: str) -> dict:
|
|
"""Orchestrate the Option A Claude call.
|
|
|
|
Returns a structured dict. Shape (always present): ok (bool), reason
|
|
(str | None), text (str | None). Success result also carries
|
|
tokens_in / tokens_out for the caller's bookkeeping.
|
|
|
|
Pre-flight gate sequence (every one MUST pass before spawning subprocess):
|
|
1. verify_credentials_subscription (bug #43333 layer 2)
|
|
2. BudgetTracker.host_disabled_after_billing_event (bug #43333 layer 3)
|
|
3. BudgetTracker.can_spend(PROMPT_ESTIMATE_TOKENS) (D-15 budget)
|
|
"""
|
|
creds = verify_credentials_subscription()
|
|
if not creds.get("ok"):
|
|
return {
|
|
"ok": False,
|
|
"reason": "credentials_check_failed",
|
|
"text": None,
|
|
"details": creds,
|
|
}
|
|
|
|
state = load_state()
|
|
tracker = BudgetTracker(state)
|
|
|
|
try:
|
|
tz = load_user_tz()
|
|
except Exception: # noqa: BLE001 -- tz lookup never crashes the call path
|
|
tz = timezone.utc # naive fallback; reset_if_new_day handles both
|
|
|
|
now = datetime.now(timezone.utc)
|
|
tracker.reset_if_new_day(now, tz)
|
|
|
|
if tracker.host_disabled_after_billing_event():
|
|
return {"ok": False, "reason": "host_disabled_c3", "text": None}
|
|
|
|
if not tracker.can_spend(PROMPT_ESTIMATE_TOKENS):
|
|
return {"ok": False, "reason": "budget_exceeded", "text": None}
|
|
|
|
patterns = _gather_patterns(store)
|
|
surprise = _gather_surprise(store)
|
|
prompt = INSIGHT_PROMPT_TEMPLATE.format(
|
|
patterns="\n".join(f"- {p}" for p in patterns),
|
|
surprise=surprise,
|
|
)
|
|
|
|
result = await invoke_host_once(prompt, model="haiku")
|
|
|
|
# Record any tokens the call actually spent (host_cli returns tokens
|
|
# even on non-ok paths when the subprocess completed).
|
|
tokens_in = int(result.get("tokens_in", 0) or 0)
|
|
tokens_out = int(result.get("tokens_out", 0) or 0)
|
|
if tokens_in + tokens_out > 0:
|
|
tracker.record(tokens_in, tokens_out, now)
|
|
|
|
if not result.get("ok"):
|
|
return {
|
|
"ok": False,
|
|
"reason": result.get("reason", "claude_call_failed"),
|
|
"text": None,
|
|
"details": {k: v for k, v in result.items() if k != "data"},
|
|
}
|
|
|
|
data = result.get("data") or {}
|
|
insight_text = str(data.get("result", "")).strip()
|
|
if not insight_text:
|
|
return {"ok": False, "reason": "empty_insight", "text": None}
|
|
|
|
# Build the L1-tier record. MemoryRecord requires a large
|
|
# set of fields per schema; we default every non-essential field
|
|
# to a neutral value so the shield/crypto pipeline treats the insight as
|
|
# a plain semantic record subject to S4/S5 on-read contradiction.
|
|
embed_dim = getattr(store, "embed_dim", None) or 384
|
|
record = MemoryRecord(
|
|
id=uuid4(),
|
|
tier="semantic",
|
|
literal_surface=insight_text,
|
|
aaak_index="",
|
|
embedding=[0.0] * int(embed_dim),
|
|
community_id=None,
|
|
centrality=0.0,
|
|
detail_level=2,
|
|
pinned=False,
|
|
stability=0.0,
|
|
difficulty=0.0,
|
|
last_reviewed=None,
|
|
never_decay=False,
|
|
never_merge=False,
|
|
provenance=[{
|
|
"ts": now.isoformat(),
|
|
"cue": "overnight_insight",
|
|
"session_id": session_id,
|
|
}],
|
|
created_at=now,
|
|
updated_at=now,
|
|
tags=["overnight_insight"],
|
|
language="en", # the prompt is English-framed; insight is English.
|
|
)
|
|
# Dataclass has `tags` (list) not `tag` (scalar); we also expose `tag`
|
|
# via attribute assignment for callers that prefer the scalar form. This
|
|
# is NOT a literal_surface mutation so it does not violate C5 MEM-01.
|
|
try:
|
|
object.__setattr__(record, "tag", "overnight_insight")
|
|
except Exception: # noqa: BLE001 -- attribute attach is best-effort
|
|
pass
|
|
|
|
try:
|
|
# R4 (researcher finding #3): wrap bare-sync store.insert
|
|
# to avoid blocking the asyncio event loop. Reached from
|
|
# dream.run_rem_cycle when claude_enabled=True (last cycle of REM).
|
|
# store.insert touches LanceDB write + encryption — not safe-fast.
|
|
await asyncio.to_thread(store.insert, record)
|
|
except Exception as exc: # noqa: BLE001 -- store errors must not crash daemon
|
|
try:
|
|
write_event(
|
|
store,
|
|
"overnight_insight_store_error",
|
|
{"error": str(exc)[:500]},
|
|
severity="warning",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"ok": False,
|
|
"reason": "store_insert_failed",
|
|
"text": insight_text,
|
|
"error": str(exc)[:500],
|
|
}
|
|
|
|
try:
|
|
write_event(
|
|
store,
|
|
"overnight_insight_generated",
|
|
{
|
|
"session_id": session_id,
|
|
"text_len": len(insight_text),
|
|
"tokens_in": tokens_in,
|
|
"tokens_out": tokens_out,
|
|
},
|
|
)
|
|
except Exception: # noqa: BLE001 -- event emission failure is non-fatal
|
|
pass
|
|
|
|
return {
|
|
"ok": True,
|
|
"text": insight_text,
|
|
"reason": None,
|
|
"tokens_in": tokens_in,
|
|
"tokens_out": tokens_out,
|
|
}
|