1110 lines
42 KiB
Python
1110 lines
42 KiB
Python
"""Lifecycle hooks — memory capture, decision detection, creative tracking."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import urllib.request
|
|
import urllib.error
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from . import state
|
|
from . import collapse as _collapse
|
|
|
|
# ── LLM extraction key ──
|
|
_OPENROUTER_KEY = (
|
|
os.environ.get("OPENROUTER_FULL_API_KEY", "")
|
|
or os.environ.get("OPENROUTER_DS_API_KEY", "")
|
|
or os.environ.get("OPENROUTER_API_KEY", "")
|
|
)
|
|
_DEEPSEEK_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
|
|
|
# Provider-agnostic endpoint resolution.
|
|
# Consumers call _resolve_llm_endpoint() and _resolve_llm_headers()
|
|
# instead of reading the globals or hardcoding URLs.
|
|
# Priority: 1) ICARUS_ENDPOINT + ICARUS_API_KEY_ENV override,
|
|
# 2) DEEPSEEK_API_KEY → api.deepseek.com,
|
|
# 3) OPENROUTER_API_KEY → openrouter.ai (existing behaviour).
|
|
_ICARUS_ENDPOINT = os.environ.get("ICARUS_ENDPOINT", "").strip().rstrip("/")
|
|
_ICARUS_API_KEY_ENV = os.environ.get("ICARUS_API_KEY_ENV", "").strip()
|
|
|
|
_EXTRACTION_MODEL = os.environ.get("ICARUS_EXTRACTION_MODEL", "deepseek/deepseek-v4-flash")
|
|
_EXTRACTION_MAX_TOKENS = int(os.environ.get("ICARUS_EXTRACTION_MAX_TOKENS", "1024"))
|
|
|
|
|
|
def _resolve_llm_endpoint() -> str:
|
|
"""Return the base URL for LLM chat completions.
|
|
|
|
Priority:
|
|
1. ``ICARUS_ENDPOINT`` env var (fully custom).
|
|
2. ``api.deepseek.com/v1/chat/completions`` when ``DEEPSEEK_API_KEY`` is set.
|
|
3. ``https://openrouter.ai/api/v1/chat/completions`` (default / legacy).
|
|
"""
|
|
if _ICARUS_ENDPOINT:
|
|
return _ICARUS_ENDPOINT
|
|
if _DEEPSEEK_KEY:
|
|
return "https://api.deepseek.com/v1/chat/completions"
|
|
return "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
|
|
def _resolve_llm_api_key() -> str:
|
|
"""Return the API key for the resolved endpoint.
|
|
|
|
Priority (mirrors _resolve_llm_endpoint):
|
|
1. The env var named by ``ICARUS_API_KEY_ENV``, if set.
|
|
2. ``DEEPSEEK_API_KEY`` when available.
|
|
3. ``OPENROUTER_API_KEY`` (default).
|
|
"""
|
|
if _ICARUS_API_KEY_ENV:
|
|
custom_key = os.environ.get(_ICARUS_API_KEY_ENV, "")
|
|
if custom_key:
|
|
return custom_key
|
|
if _DEEPSEEK_KEY:
|
|
return _DEEPSEEK_KEY
|
|
return _OPENROUTER_KEY
|
|
|
|
|
|
def _resolve_llm_headers(api_key: str) -> dict:
|
|
"""Return the HTTP headers for the resolved endpoint."""
|
|
endpoint = _resolve_llm_endpoint()
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
# OpenRouter requires referer + title headers
|
|
if "openrouter.ai" in endpoint:
|
|
headers["HTTP-Referer"] = "https://hermes-agent.local"
|
|
headers["X-Title"] = "Icarus Session Extraction"
|
|
return headers
|
|
|
|
|
|
def _resolve_llm_model() -> str:
|
|
"""Return the model name compatible with the resolved endpoint.
|
|
|
|
OpenRouter uses ``provider/model`` slugs (e.g. ``deepseek/deepseek-v4-flash``).
|
|
Direct API endpoints (DeepSeek, custom) use bare model names.
|
|
"""
|
|
model = _EXTRACTION_MODEL
|
|
endpoint = _resolve_llm_endpoint()
|
|
if "openrouter.ai" not in endpoint and "/" in model:
|
|
# Strip the provider prefix for direct API calls
|
|
_, bare = model.split("/", 1)
|
|
return bare
|
|
return model
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Truncation limits (env-configurable) ──
|
|
_RESULT_MAX = int(os.environ.get("ICARUS_RESULT_MAX_CHARS", "500"))
|
|
_TASK_MAX = int(os.environ.get("ICARUS_TASK_MAX_CHARS", "300"))
|
|
|
|
# ── System injection detection ──
|
|
_SYSTEM_PREFIXES = (
|
|
"[IMPORTANT:",
|
|
"[SYSTEM:",
|
|
"You are running as a scheduled",
|
|
)
|
|
|
|
|
|
def _is_system_injection(text):
|
|
"""Return True if text starts with a known orchestrator/system preamble."""
|
|
stripped = text.strip()
|
|
return any(stripped.startswith(p) for p in _SYSTEM_PREFIXES)
|
|
|
|
# use shared regexes from state for decision/outcome/completion detection
|
|
# keep local regexes only for creative tracking (broader set)
|
|
_THEME_RE = re.compile(
|
|
r"(?i)\b(decided|resolved|completed|fixed|deployed|shipped|reviewed|approved|rejected|built|created)\b"
|
|
)
|
|
_EVAL_RE = re.compile(
|
|
r"(?i)\b(worked well|didn't work|failed|succeeded|learned|noticed|realized|discovered|finding|insight|improvement)\b"
|
|
)
|
|
_QUESTION_RE = re.compile(
|
|
r"(?i)\b(what if|wonder|curious about|want to try|experiment with|explore|investigate|test whether)\b"
|
|
)
|
|
_STOPWORDS = frozenset(
|
|
"this that with from have been were will about would could should their there "
|
|
"these them then when what which some other more also just like very into only "
|
|
"than over such make made most each does done being".split()
|
|
)
|
|
|
|
# ── Topic overlap tracking ──
|
|
_last_query_tokens: set = set()
|
|
|
|
# ── Per-session injection dedup (reset on session start) ──
|
|
_injected_fabric: set = set()
|
|
_injected_qdrant: set = set()
|
|
_injected_sessions: set = set()
|
|
|
|
|
|
def _tokenize(text):
|
|
words = set(re.findall(r"[a-z0-9]+", text.lower()))
|
|
return words - {"the", "a", "an", "is", "was", "are", "to", "of", "in", "for",
|
|
"on", "with", "it", "and", "or", "not", "i", "you", "can", "do",
|
|
"this", "that", "what", "how", "please", "help", "me", "my"}
|
|
|
|
|
|
def _extract_theme(text):
|
|
words = re.findall(r"\b[a-z]{4,}\b", text.lower())
|
|
filtered = [w for w in words[:30] if w not in _STOPWORDS][:3]
|
|
return " ".join(filtered) if filtered else ""
|
|
|
|
|
|
def _sanitize_learning(s: str) -> str:
|
|
"""Remove unpaired backticks that would produce orphaned markdown."""
|
|
if s.count('`') % 2 != 0:
|
|
s = s.replace('`', '')
|
|
if s.count('```') % 2 != 0:
|
|
s = s.replace('```', '')
|
|
return s.strip()
|
|
|
|
|
|
def _extract_sentence(text, pattern):
|
|
for s in re.split(r"[.!?\n]+", text):
|
|
s = s.strip()
|
|
if len(s) > 15 and pattern.search(s):
|
|
return s[:120]
|
|
return ""
|
|
|
|
|
|
# ── Hooks ────────────────────────────────────────────────
|
|
|
|
def on_session_start(session_id="", platform="", **kwargs):
|
|
"""Load context: SOUL + pending handoffs + recent entries + creative state."""
|
|
global _last_query_tokens
|
|
_last_query_tokens = set()
|
|
_injected_fabric.clear()
|
|
_injected_qdrant.clear()
|
|
_injected_sessions.clear()
|
|
state.session_id = session_id
|
|
state.exchanges = []
|
|
state._recall_log = []
|
|
|
|
creative = state.load_creative()
|
|
creative["cycle"] += 1
|
|
state.save_creative(creative)
|
|
|
|
parts = []
|
|
|
|
soul = state.load_soul()
|
|
if soul:
|
|
parts.append(soul.strip())
|
|
|
|
# pending work (handoff-aware)
|
|
open_tasks, reviews, open_tickets = state.read_pending()
|
|
if open_tasks:
|
|
parts.append(f"[fabric] {len(open_tasks)} item(s) assigned to you:")
|
|
for t in open_tasks[:5]:
|
|
src = t.get("agent", "?")
|
|
entry_id = t.get("id", "?")
|
|
etype = t.get("type", "task")
|
|
parts.append(f" - {src}: {t.get('summary', '?')} ({etype}, id {entry_id})")
|
|
parts.append(" If reviewing, set review_of. If revising, set revises. Otherwise just complete the work.")
|
|
|
|
if reviews:
|
|
parts.append(f"[fabric] {len(reviews)} review(s) of your work:")
|
|
for r in reviews[:5]:
|
|
reviewer = r.get("agent", "?")
|
|
entry_id = r.get("id", "?")
|
|
ref = r.get("review_of", "")
|
|
parts.append(f" - {reviewer}: {r.get('summary', '?')} (review id {entry_id}, of {ref})")
|
|
parts.append(" When you fix the issues, set revises to your original entry's agent:id.")
|
|
|
|
if open_tickets:
|
|
parts.append(f"[fabric] {len(open_tickets)} ticket(s) assigned to you:")
|
|
for t in open_tickets[:5]:
|
|
cid = t.get("customer_id", "?")
|
|
src = t.get("agent", "?")
|
|
entry_id = t.get("id", "?")
|
|
parts.append(f" - [{cid}] {t.get('summary', '?')} (from {src}, id {entry_id})")
|
|
parts.append(" Carry customer_id forward when you resolve these.")
|
|
|
|
# cross-agent feedback (non-pending items)
|
|
if not open_tasks and not reviews:
|
|
feedback = state.read_cross_agent(3)
|
|
if feedback:
|
|
parts.append("[fabric] from other agents:")
|
|
for f in feedback:
|
|
parts.append(f" {f}")
|
|
|
|
# recent entries
|
|
entries = state.read_recent(limit=5)
|
|
if entries:
|
|
parts.append("[fabric] recent activity:")
|
|
for e in entries:
|
|
ts = e["timestamp"][:16] if e["timestamp"] else "?"
|
|
parts.append(f" [{ts}] {e['agent']}: {e['summary']}")
|
|
|
|
# creative state
|
|
if creative["questions"]:
|
|
parts.append(f"[fabric] open questions: {'; '.join(creative['questions'][-3:])}")
|
|
if creative["learnings"]:
|
|
parts.append(f"[fabric] learnings: {'; '.join(creative['learnings'][-3:])}")
|
|
|
|
context = "\n".join(parts)
|
|
return {"context": context} if context else None
|
|
|
|
|
|
# ── Qdrant context injection ──────────────────────────────
|
|
|
|
_SOCIAL_CLOSERS = frozenset({
|
|
"ok", "obrigado", "valeu", "beleza", "blz", "tks", "thanks",
|
|
"👍", "👌", "✅", "feito", "certo", "confirmo", "entendido",
|
|
"certo", "isso", "sim", "não", "claro", "perfeito", "ótimo"
|
|
})
|
|
|
|
|
|
def _is_social_close(text):
|
|
"""Return True if message is a social closer that shouldn't trigger search."""
|
|
stripped = text.strip().lower()
|
|
if stripped in _SOCIAL_CLOSERS:
|
|
return True
|
|
# Very short ASCII-only without technical markers
|
|
if len(stripped) < 6 and stripped.isascii() and not any(
|
|
c in stripped for c in "://.@#$_?"
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _search_qdrant(query, top_k=2, threshold=0.72):
|
|
"""Search Qdrant knowledge_base via context_enhancer pipeline.
|
|
|
|
Returns list of result dicts with keys: id, score, title,
|
|
content_preview, source, tags.
|
|
Returns empty list on any failure (fail-open).
|
|
"""
|
|
old_api_key = os.environ.get("OPENROUTER_API_KEY")
|
|
try:
|
|
# context_enhancer reads OPENROUTER_API_KEY at module level;
|
|
# set it temporarily, then restore in finally.
|
|
if _OPENROUTER_KEY and not old_api_key:
|
|
os.environ["OPENROUTER_API_KEY"] = _OPENROUTER_KEY
|
|
|
|
from scripts.context_enhancer import (
|
|
embed_query, embed_query_sparse, search_with_fallback
|
|
)
|
|
dense = embed_query(query)
|
|
sparse = embed_query_sparse(query)
|
|
results, _level, _qdrant_ms, _fallback_ms = search_with_fallback(
|
|
dense_vector=dense,
|
|
sparse_vector=sparse,
|
|
query_text=query,
|
|
top_k=top_k,
|
|
score_threshold=threshold,
|
|
)
|
|
return results
|
|
except Exception:
|
|
return []
|
|
finally:
|
|
# Restore original env state — never leave a mutation behind.
|
|
if old_api_key is None:
|
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
|
else:
|
|
os.environ["OPENROUTER_API_KEY"] = old_api_key
|
|
|
|
|
|
# ── Session history search (FTS5 over state.db) ──────────────
|
|
|
|
|
|
def _resolve_state_db():
|
|
"""Locate the Hermes session DB. Prefer state.HERMES_HOME, fall back to ~/.hermes."""
|
|
import sqlite3 # noqa: F401 (ensure available)
|
|
candidates = []
|
|
home = getattr(state, "HERMES_HOME", None)
|
|
if home:
|
|
candidates.append(Path(home) / "state.db")
|
|
candidates.append(Path.home() / ".hermes" / "state.db")
|
|
for c in candidates:
|
|
if c and c.exists():
|
|
return c
|
|
return None
|
|
|
|
|
|
def _search_sessions(query, current_session_id="", top_k=2):
|
|
"""FTS5 search over prior session messages in state.db.
|
|
|
|
Returns list of {session_id, title, when, snippet}, excluding the
|
|
current session. Fail-open: returns [] on any error.
|
|
"""
|
|
import sqlite3
|
|
db = _resolve_state_db()
|
|
if not db:
|
|
return []
|
|
|
|
# Build an FTS5 OR-query from meaningful tokens (avoids AND over-filtering)
|
|
toks = [t for t in _tokenize(query) if len(t) >= 4]
|
|
if not toks:
|
|
return []
|
|
fts_query = " OR ".join(toks[:8])
|
|
|
|
try:
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
cur = con.cursor()
|
|
# NOTE: snippet() cannot be combined with GROUP BY in the same SELECT
|
|
# (FTS5 raises "unable to use function snippet in the requested
|
|
# context"). Fetch top-ranked rows and dedup by session in Python.
|
|
rows = cur.execute(
|
|
"""
|
|
SELECT m.session_id AS session_id,
|
|
s.title AS title,
|
|
s.started_at AS started_at,
|
|
snippet(messages_fts, 0, '', '', '…', 12) AS snip
|
|
FROM messages_fts
|
|
JOIN messages m ON m.id = messages_fts.rowid
|
|
LEFT JOIN sessions s ON s.id = m.session_id
|
|
WHERE messages_fts MATCH ?
|
|
AND m.session_id != ?
|
|
AND m.role IN ('user','assistant')
|
|
ORDER BY rank
|
|
LIMIT 20
|
|
""",
|
|
(fts_query, current_session_id),
|
|
).fetchall()
|
|
con.close()
|
|
except Exception:
|
|
return []
|
|
|
|
out = []
|
|
seen = set()
|
|
for r in rows:
|
|
sid = r["session_id"]
|
|
if sid in seen:
|
|
continue
|
|
seen.add(sid)
|
|
# started_at is a Unix timestamp (float); format to a readable date.
|
|
when = ""
|
|
sa = r["started_at"]
|
|
if sa:
|
|
try:
|
|
when = datetime.fromtimestamp(float(sa)).strftime("%Y-%m-%d %H:%M")
|
|
except (ValueError, TypeError, OSError):
|
|
when = str(sa)[:16]
|
|
out.append({
|
|
"session_id": sid,
|
|
"title": r["title"],
|
|
"when": when,
|
|
"snippet": (r["snip"] or "").replace("\n", " "),
|
|
})
|
|
if len(out) >= top_k:
|
|
break
|
|
return out
|
|
|
|
|
|
# ── fact_store search (FTS5 over memory_store.db) ────────────
|
|
|
|
def _search_facts(query, top_k=3):
|
|
"""FTS5 search over durable facts in memory_store.db.
|
|
|
|
Returns list of fact content strings. Fail-open: returns [] on error.
|
|
"""
|
|
import sqlite3
|
|
db = Path.home() / ".hermes" / "memory_store.db"
|
|
home = getattr(state, "HERMES_HOME", None)
|
|
if home and (Path(home) / "memory_store.db").exists():
|
|
db = Path(home) / "memory_store.db"
|
|
if not db.exists():
|
|
return []
|
|
|
|
toks = [t for t in _tokenize(query) if len(t) >= 4]
|
|
if not toks:
|
|
return []
|
|
fts_query = " OR ".join(toks[:8])
|
|
|
|
try:
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
cur = con.cursor()
|
|
rows = cur.execute(
|
|
"""
|
|
SELECT f.content AS content, f.trust_score AS trust
|
|
FROM facts_fts
|
|
JOIN facts f ON f.fact_id = facts_fts.rowid
|
|
WHERE facts_fts MATCH ?
|
|
ORDER BY rank
|
|
LIMIT ?
|
|
""",
|
|
(fts_query, top_k),
|
|
).fetchall()
|
|
con.close()
|
|
except Exception:
|
|
return []
|
|
|
|
return [r["content"][:200] for r in rows if r["content"]]
|
|
|
|
|
|
# ── Prompt injection sanitization ────────────────────────────
|
|
|
|
_INJECTION_PATTERNS = [
|
|
# "ignore all previous/prior instructions/directives"
|
|
(re.compile(r"(?i)\bignore\s+all\s+(previous|prior)\s+(instructions|directives|commands|messages|prompts|context)"),
|
|
"[REDACTED]"),
|
|
# "you are/will now become/act/acting as (a/an) AI/assistant..."
|
|
(re.compile(r"(?i)\byou\s+(are|will\s+now)\s+(now\s+)?(become|act|acting)\s+as\s+(a\s+|an\s+)?(AI\s+assistant|assistant|AI|agent|LLM|chatbot|model|system)"),
|
|
"[REDACTED]"),
|
|
# "new instructions/directives/commands follow/above/below"
|
|
(re.compile(r"(?i)\bnew\s+(instructions|directives|commands)\s+(follow|above|below)"),
|
|
"[REDACTED]"),
|
|
# Template injection: {{...}}, ${...}
|
|
(re.compile(r"\{\{.*?\}\}|\$\{.*?\}"), "[REDACTED]"),
|
|
# Triple-backtick code fences
|
|
(re.compile(r"```"), "[code]"),
|
|
# Markdown/javascript data: URLs in links and images
|
|
(re.compile(r"(?i)(javascript|data)\s*:"), "sanitized:"),
|
|
# XML/HTML injection: <script>, event handlers, iframes
|
|
(re.compile(r"<\s*script[\s>]|on\w+\s*=|<\s*iframe[\s>]"), "[sanitized]"),
|
|
# Known system prefixes
|
|
(re.compile(r"(?i)\[IMPORTANT:.*?\]|\[SYSTEM:.*?\]|\[OVERRIDE:.*?\]"), "[REDACTED]"),
|
|
# Control characters (keep newlines and tabs)
|
|
(re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]"), ""),
|
|
# Zero-width and invisible Unicode
|
|
(re.compile(r"[\u200b-\u200f\u2028-\u202f\u2060-\u2064\ufeff]"), ""),
|
|
]
|
|
|
|
|
|
def _validate_safe_content(text: str) -> str:
|
|
"""Catch unknown attack patterns via heuristic:
|
|
high density of directive/imperative language in a short span.
|
|
Falls back to [SANITIZED] placeholder if heuristic triggers.
|
|
"""
|
|
if not text or len(text) < 20:
|
|
return text
|
|
try:
|
|
# Count directive-style phrases per character
|
|
directivess = len(re.findall(
|
|
r"(?i)\b(ignore|forget|disregard|override|replace|pretend|act\s+as|you\s+(are|must|will|shall))\b",
|
|
text
|
|
))
|
|
if directivess >= 3 and directivess / max(len(text), 1) > 0.02:
|
|
return "[SANITIZED]"
|
|
return text
|
|
except Exception:
|
|
return text
|
|
|
|
|
|
def _sanitize_context_text(text: str, max_len: int = 600) -> str:
|
|
"""Sanitize retrieved text before it enters the agent's context.
|
|
Strips known injection patterns, validates safety, truncates.
|
|
Fail-open: returns truncated original on error.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
try:
|
|
result = str(text)
|
|
for pattern, replacement in _INJECTION_PATTERNS:
|
|
result = pattern.sub(replacement, result)
|
|
# Safety heuristic catch
|
|
result = _validate_safe_content(result)
|
|
# Normalize excessive whitespace
|
|
result = re.sub(r"\n{4,}", "\n\n\n", result)
|
|
result = re.sub(r" {8,}", " ", result)
|
|
return result.strip()[:max_len]
|
|
except Exception:
|
|
return str(text)[:max_len]
|
|
|
|
|
|
# ── Non-bijunctive recall collapse ───────────
|
|
# Master switch + tunables. Set ICARUS_COLLAPSE=0 to restore stock per-source
|
|
# emission (legacy behavior). All values fall back to collapse.DEFAULTS.
|
|
#
|
|
# Env parsing is hardened: a malformed value falls back to the default instead
|
|
# of raising at import time. Without this, a bad ICARUS_COLLAPSE_BUDGET would
|
|
# crash the entire hooks module on import — defeating the fail-open contract
|
|
# that only protects _apply_collapse. (tri-brain Codex BLOCKING, 2026-06-04)
|
|
def _env_num(name, default, cast):
|
|
"""Parse a numeric env var, falling back to ``default`` on any error."""
|
|
raw = os.environ.get(name)
|
|
if raw is None or raw.strip() == "":
|
|
return default
|
|
try:
|
|
return cast(raw)
|
|
except (TypeError, ValueError):
|
|
logger.warning("icarus: invalid %s=%r — using default %r", name, raw, default)
|
|
return default
|
|
|
|
|
|
_COLLAPSE_ON = os.environ.get("ICARUS_COLLAPSE", "1").strip().lower() not in (
|
|
"0", "false", "no", "off"
|
|
)
|
|
_COLLAPSE_BUDGET = _env_num("ICARUS_COLLAPSE_BUDGET", _collapse.DEFAULTS["budget"], int)
|
|
_COLLAPSE_PRUNE = _env_num("ICARUS_COLLAPSE_PRUNE_RATIO", _collapse.DEFAULTS["prune_ratio"], float)
|
|
# Tunables for the lexical/source balance. Raise overlap_weight toward 1.0 to
|
|
# favor query-token overlap; lower it to let each source's own ranking (recency,
|
|
# FTS, vector score, encoded via rank_decay) carry more weight — the lever for
|
|
# the "strong-but-low-overlap hit gets starved" tradeoff. (tri-brain Grok)
|
|
_COLLAPSE_DUP = _env_num("ICARUS_COLLAPSE_DUP_OVERLAP", _collapse.DEFAULTS["dup_overlap"], float)
|
|
_COLLAPSE_WEIGHT = _env_num("ICARUS_COLLAPSE_OVERLAP_WEIGHT", _collapse.DEFAULTS["overlap_weight"], float)
|
|
_COLLAPSE_DECAY = _env_num("ICARUS_COLLAPSE_RANK_DECAY", _collapse.DEFAULTS["rank_decay"], float)
|
|
# Hebbian cross-source amplify knobs (corroboration boosts salience).
|
|
_COLLAPSE_CORRO = _env_num("ICARUS_COLLAPSE_CORRO_OVERLAP", _collapse.DEFAULTS["corroboration_overlap"], float)
|
|
_COLLAPSE_GAIN = _env_num("ICARUS_COLLAPSE_AMPLIFY_GAIN", _collapse.DEFAULTS["amplify_gain"], float)
|
|
_COLLAPSE_CAP = _env_num("ICARUS_COLLAPSE_AMPLIFY_CAP", _collapse.DEFAULTS["amplify_cap"], float)
|
|
# Observability: ICARUS_COLLAPSE_DEBUG=1 logs the salience-ranked pool (what
|
|
# survived vs pruned, scores, cross-source corroboration) and a physical-entropy
|
|
# attestation hash over the survivor set — making a recall decision auditable
|
|
# and tamper-evident instead of a black box. (answers tri-brain Grok's
|
|
# "unobservable new surface" concern, 2026-06-04)
|
|
_COLLAPSE_DEBUG = os.environ.get("ICARUS_COLLAPSE_DEBUG", "0").strip().lower() in (
|
|
"1", "true", "yes", "on"
|
|
)
|
|
|
|
|
|
def _fabric_text(e):
|
|
return e.get("summary") or e.get("_body") or e.get("body") or ""
|
|
|
|
|
|
def _qdrant_text(r):
|
|
# Cover the common payload field names — a strong hit whose text lives in
|
|
# `content`/`body`/`text` must not be mis-scored as weak because we only
|
|
# looked at title+preview. Tokenize is set-based, so overlap between
|
|
# content_preview and content is harmless. (tri-brain Codex SHOULD-FIX)
|
|
fields = ("title", "content_preview", "content", "body", "text", "summary")
|
|
return " ".join(str(r.get(f, "")) for f in fields if r.get(f)).strip()
|
|
|
|
|
|
def _session_text(s):
|
|
return f"{s.get('title', '')} {s.get('snippet', '')}".strip()
|
|
|
|
|
|
def _log_collapse_debug(candidates, qtokens, survivors):
|
|
"""Log the salience-ranked pool + a physical-entropy attestation over the
|
|
survivor set. Best-effort: never raises into the hot path."""
|
|
try:
|
|
kept_keys = {c.get("key") for c in survivors}
|
|
# Use the SAME tunables the real collapse used, or the debug log would
|
|
# report different salience/corroboration than the actual decision.
|
|
ranked = _collapse.score_all(
|
|
candidates, qtokens,
|
|
overlap_weight=_COLLAPSE_WEIGHT, rank_decay=_COLLAPSE_DECAY,
|
|
corroboration_overlap=_COLLAPSE_CORRO,
|
|
amplify_gain=_COLLAPSE_GAIN, amplify_cap=_COLLAPSE_CAP,
|
|
)
|
|
ranked.sort(key=lambda r: r["salience"], reverse=True)
|
|
logger.info("icarus collapse: %d candidates -> %d survivors",
|
|
len(candidates), len(survivors))
|
|
for r in ranked:
|
|
c = r["candidate"]
|
|
mark = "KEEP" if c.get("key") in kept_keys else "prune"
|
|
logger.info(" [%-5s] %-8s sal=%.3f corro=%d %s",
|
|
mark, str(c.get("source")), r["salience"],
|
|
r["corroboration"], str(c.get("text", ""))[:48])
|
|
att = _collapse.attest(survivors)
|
|
logger.info(" attestation: %s (nonce %s…, %d survivors, %s)",
|
|
att["hash"][:16], att["nonce"][:12], att["count"], att["algo"])
|
|
except Exception:
|
|
logger.debug("icarus: collapse debug logging failed", exc_info=True)
|
|
|
|
|
|
def _apply_collapse(query, fabric, qdrant, sessions, facts):
|
|
"""Run non-bijunctive collapse across all four source lists.
|
|
|
|
Builds one unified candidate pool (each tagged with source + within-source
|
|
rank), collapses it to a single salience-ranked budget, then filters each
|
|
source list down to the survivors — preserving the exact dict shapes the
|
|
emission code below already expects.
|
|
|
|
Fail-open: on ANY error, returns the inputs unchanged so a collapse bug can
|
|
never suppress memory injection. This is the whole safety contract.
|
|
"""
|
|
try:
|
|
qtokens = _collapse.tokenize(query)
|
|
candidates = []
|
|
|
|
for i, e in enumerate(fabric):
|
|
candidates.append({
|
|
"key": ("fabric", i), "source": "fabric",
|
|
"text": _fabric_text(e), "score": None, "rank": i,
|
|
})
|
|
for i, r in enumerate(qdrant):
|
|
sc = r.get("score")
|
|
candidates.append({
|
|
"key": ("qdrant", i), "source": "qdrant",
|
|
"text": _qdrant_text(r),
|
|
"score": float(sc) if isinstance(sc, (int, float)) else None,
|
|
"rank": i,
|
|
})
|
|
for i, s in enumerate(sessions):
|
|
candidates.append({
|
|
"key": ("sessions", i), "source": "sessions",
|
|
"text": _session_text(s), "score": None, "rank": i,
|
|
})
|
|
for i, f in enumerate(facts):
|
|
candidates.append({
|
|
"key": ("facts", i), "source": "facts",
|
|
"text": str(f), "score": None, "rank": i,
|
|
})
|
|
|
|
if not candidates:
|
|
return fabric, qdrant, sessions, facts
|
|
|
|
survivors = _collapse.collapse(
|
|
candidates, qtokens,
|
|
budget=_COLLAPSE_BUDGET, prune_ratio=_COLLAPSE_PRUNE,
|
|
dup_overlap=_COLLAPSE_DUP, overlap_weight=_COLLAPSE_WEIGHT,
|
|
rank_decay=_COLLAPSE_DECAY,
|
|
corroboration_overlap=_COLLAPSE_CORRO,
|
|
amplify_gain=_COLLAPSE_GAIN, amplify_cap=_COLLAPSE_CAP,
|
|
)
|
|
keep = {c["key"] for c in survivors}
|
|
|
|
if _COLLAPSE_DEBUG:
|
|
_log_collapse_debug(candidates, qtokens, survivors)
|
|
|
|
# Defensive: if collapse returned nothing despite real candidates, do
|
|
# NOT suppress everything — fall back to unchanged inputs.
|
|
if not keep:
|
|
return fabric, qdrant, sessions, facts
|
|
|
|
# Known limitation (tri-brain Grok SHOULD-FIX, accepted as tradeoff):
|
|
# survivors are filtered again by the per-session _injected_* dedup sets
|
|
# during emission below. A survivor that's already been injected this
|
|
# session consumes a budget slot here and is then skipped at emission,
|
|
# so the net injected count can be < budget. We accept this rather than
|
|
# replicate the emission keying here (which would risk key drift); the
|
|
# overlap-gate + per-session dedup already bound re-injection in practice.
|
|
new_fabric = [e for i, e in enumerate(fabric) if ("fabric", i) in keep]
|
|
new_qdrant = [r for i, r in enumerate(qdrant) if ("qdrant", i) in keep]
|
|
new_sessions = [s for i, s in enumerate(sessions) if ("sessions", i) in keep]
|
|
new_facts = [f for i, f in enumerate(facts) if ("facts", i) in keep]
|
|
return new_fabric, new_qdrant, new_sessions, new_facts
|
|
except Exception:
|
|
# Fail-open: never let a collapse error block memory injection. Logged at
|
|
# WARNING so a silently-disabled collapse is detectable in production
|
|
# rather than only inferable from "did the right memories appear?".
|
|
logger.warning("icarus: recall collapse failed — injecting unchanged",
|
|
exc_info=True)
|
|
return fabric, qdrant, sessions, facts
|
|
|
|
|
|
def pre_llm_call(session_id="", user_message="", is_first_turn=False, **kwargs):
|
|
"""Inject relevant memories when topic changes (fabric + Qdrant)."""
|
|
global _last_query_tokens
|
|
if not user_message:
|
|
return None
|
|
|
|
tokens = _tokenize(user_message)
|
|
if not tokens:
|
|
return None
|
|
|
|
# Overlap gate: only suppress on NEAR-LITERAL repetition of the previous
|
|
# turn (>0.85). The old 0.6 gate killed all injection in long single-topic
|
|
# sessions — exactly when accumulated context matters most. We now keep
|
|
# injecting and rely on per-source dedup (_injected_* sets) to avoid
|
|
# repeating identical results turn after turn.
|
|
if _last_query_tokens:
|
|
overlap = len(tokens & _last_query_tokens) / max(len(tokens), 1)
|
|
if overlap > 0.85:
|
|
return None
|
|
|
|
_last_query_tokens = tokens
|
|
|
|
is_social = _is_social_close(user_message)
|
|
|
|
agent = state.AGENT_NAME or "agent"
|
|
results = state.recall(user_message, max_results=5, agent=agent)
|
|
|
|
# log fabric recall for telemetry (even if empty)
|
|
if results:
|
|
state.log_recall(user_message, results, source="pre_llm_call")
|
|
|
|
# ── Qdrant search (independent of fabric) ──
|
|
# Threshold lowered 0.72 → 0.55: legitimate queries scored 0.57-0.63 and
|
|
# were silently filtered out by the old 0.72 gate.
|
|
qdrant_results = []
|
|
if not is_social:
|
|
qdrant_results = _search_qdrant(user_message, top_k=2, threshold=0.55)
|
|
|
|
# ── Session history (FTS5 over state.db) — the layer that holds
|
|
# "this was already built in a prior session". No automatic injection
|
|
# existed before; this is new. ──
|
|
session_results = []
|
|
if not is_social:
|
|
session_results = _search_sessions(user_message, session_id, top_k=2)
|
|
|
|
# ── fact_store probe (durable user/environment facts) — first turn only,
|
|
# to avoid per-turn cost. ──
|
|
fact_results = []
|
|
if is_first_turn and not is_social:
|
|
fact_results = _search_facts(user_message, top_k=3)
|
|
|
|
# ── Bail if nothing from any source ──
|
|
if not results and not qdrant_results and not session_results and not fact_results:
|
|
return None
|
|
|
|
# ── Non-bijunctive collapse ──
|
|
# Unify all four sources into one salience-ranked pool, prune weak paths
|
|
# relative to the strongest, amplify the strong, and spend a single
|
|
# cross-source budget. Replaces the stock "emit every per-source quota"
|
|
# behavior. Fail-open: _apply_collapse returns inputs unchanged on error.
|
|
if _COLLAPSE_ON:
|
|
results, qdrant_results, session_results, fact_results = _apply_collapse(
|
|
user_message, results, qdrant_results, session_results, fact_results
|
|
)
|
|
|
|
parts = []
|
|
|
|
# Fabric context (dedup against previously injected entry ids)
|
|
if results:
|
|
lines = ["[fabric] relevant to your request:"]
|
|
emitted = 0
|
|
for e in results:
|
|
summary = _sanitize_context_text(
|
|
e.get("summary") or e.get("_body", e.get("body", "")), max_len=80
|
|
)
|
|
eid = str(e.get("id", "")) or summary[:60]
|
|
if eid in _injected_fabric:
|
|
continue
|
|
_injected_fabric.add(eid)
|
|
ts = str(e.get("timestamp", ""))[:16] or "?"
|
|
lines.append(f" [{ts}] {e.get('agent', '?')}: {summary}")
|
|
emitted += 1
|
|
if emitted:
|
|
parts.append("\n".join(lines))
|
|
|
|
# Qdrant context (dedup against previously injected point ids)
|
|
if qdrant_results:
|
|
lines = ["[qdrant] knowledge base:"]
|
|
emitted = 0
|
|
for r in qdrant_results:
|
|
rid = str(r.get("id", "")) or str(r.get("content_preview", ""))[:40]
|
|
if rid in _injected_qdrant:
|
|
continue
|
|
_injected_qdrant.add(rid)
|
|
source = r.get("source", "?")
|
|
title = r.get("title", "")
|
|
score = r.get("score", 0)
|
|
label = f"{source}"
|
|
if title:
|
|
label = f"{source}: {title[:60]}"
|
|
content = _sanitize_context_text(r.get("content_preview", ""))
|
|
lines.append(f" ### {label} (score: {score:.2f})\n {content}")
|
|
emitted += 1
|
|
if emitted:
|
|
parts.append("\n".join(lines))
|
|
|
|
# Session history context (dedup against previously injected session ids)
|
|
if session_results:
|
|
lines = ["[sessions] prior conversations on this topic:"]
|
|
emitted = 0
|
|
for s in session_results:
|
|
sid = s.get("session_id", "")
|
|
if sid in _injected_sessions:
|
|
continue
|
|
_injected_sessions.add(sid)
|
|
title = s.get("title") or "(untitled)"
|
|
snippet = _sanitize_context_text(s.get("snippet", ""), max_len=200)
|
|
when = s.get("when", "")
|
|
lines.append(f" [{when}] {title}: {snippet}")
|
|
emitted += 1
|
|
if emitted:
|
|
parts.append("\n".join(lines))
|
|
|
|
# fact_store context (first turn only)
|
|
if fact_results:
|
|
lines = ["[facts] durable facts about the user/environment:"]
|
|
for f in fact_results:
|
|
lines.append(f" - {_sanitize_context_text(f, max_len=200)}")
|
|
parts.append("\n".join(lines))
|
|
|
|
if not parts:
|
|
return None
|
|
|
|
return {"context": "\n\n".join(parts)}
|
|
|
|
|
|
def post_llm_call(session_id="", user_message="", assistant_response="", platform="", **kwargs):
|
|
"""Capture high-value decisions + creative tracking."""
|
|
if not assistant_response:
|
|
return
|
|
|
|
state.exchanges.append({
|
|
"user": (user_message or "")[:200],
|
|
"assistant": assistant_response[:500],
|
|
})
|
|
|
|
agent = state.AGENT_NAME or "agent"
|
|
plat = platform or "cli"
|
|
|
|
# capture decisions: requires decision + outcome in response, AND a substantial
|
|
# user request (>50 chars) to ground the claim
|
|
user_text = (user_message or "").strip()
|
|
if (state.DECISION_RE.search(assistant_response)
|
|
and state.OUTCOME_RE.search(assistant_response)
|
|
and len(assistant_response) > 200
|
|
and len(user_text) > 50):
|
|
body = f"Task: {user_text[:_TASK_MAX]}\n\nResult: {assistant_response[:_RESULT_MAX]}"
|
|
summary = assistant_response[:80].replace("\n", " ")
|
|
entry_status = "completed" if state.COMPLETION_RE.search(assistant_response) else ""
|
|
state.write_entry("decision", body, summary,
|
|
platform=plat, status=entry_status, training_value="high")
|
|
|
|
# creative tracking (uses broader _THEME_RE, doesn't write entries)
|
|
creative = state.load_creative()
|
|
changed = False
|
|
|
|
if _THEME_RE.search(assistant_response):
|
|
theme = _extract_theme(assistant_response)
|
|
if theme and theme not in creative["themes"]:
|
|
creative["themes"].append(theme)
|
|
creative["themes"] = creative["themes"][-20:]
|
|
changed = True
|
|
|
|
if _EVAL_RE.search(assistant_response):
|
|
learning = _extract_sentence(assistant_response, _EVAL_RE)
|
|
if learning:
|
|
learning = _sanitize_learning(learning)
|
|
if learning and learning not in creative["learnings"]:
|
|
creative["learnings"].append(learning)
|
|
creative["learnings"] = creative["learnings"][-15:]
|
|
changed = True
|
|
|
|
if _QUESTION_RE.search(assistant_response):
|
|
question = _extract_sentence(assistant_response, _QUESTION_RE)
|
|
if question and question not in creative["questions"]:
|
|
creative["questions"].append(question)
|
|
creative["questions"] = creative["questions"][-15:]
|
|
changed = True
|
|
|
|
if changed:
|
|
state.save_creative(creative)
|
|
|
|
|
|
# ── LLM-powered session extraction ────────────────────────
|
|
|
|
def _parse_json_robust(raw):
|
|
"""Extract JSON array/object from LLM output with markdown tolerances.
|
|
|
|
Handles: ```json fences, leading text, trailing commas, whitespace.
|
|
Returns parsed value on success, None on failure.
|
|
"""
|
|
if not raw or not raw.strip():
|
|
return None
|
|
|
|
text = raw.strip()
|
|
|
|
# Strip markdown code fences
|
|
for fence in ("```json", "```"):
|
|
if text.startswith(fence):
|
|
text = text[len(fence):].lstrip()
|
|
if text.endswith("```"):
|
|
text = text[:-3].rstrip()
|
|
|
|
# Find first JSON structure character
|
|
for start_char in ("[", "{"):
|
|
idx = text.find(start_char)
|
|
if idx != -1:
|
|
text = text[idx:]
|
|
break
|
|
|
|
# Attempt parse; progressively strip trailing characters on failure
|
|
attempts = 0
|
|
while attempts < 20:
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
# Strip last char and try again (handles trailing commas, extra })
|
|
if text:
|
|
text = text[:-1]
|
|
attempts += 1
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def _build_transcript(exchanges):
|
|
"""Build a compact transcript from session exchanges for LLM analysis."""
|
|
lines = []
|
|
for i, ex in enumerate(exchanges):
|
|
user = (ex.get("user") or "").strip()
|
|
assistant = (ex.get("assistant") or "").strip()
|
|
if user:
|
|
lines.append(f"[Turn {i+1} — User]\n{user[:500]}")
|
|
if assistant:
|
|
lines.append(f"[Turn {i+1} — Agent]\n{assistant[:800]}")
|
|
return "\n\n".join(lines)
|
|
|
|
|
|
def _llm_extract_entries(transcript):
|
|
"""Use LLM to extract significant entries from session transcript.
|
|
|
|
Returns list of dicts: {type, summary, content, training_value}
|
|
Returns empty list on failure or if nothing worth preserving.
|
|
"""
|
|
api_key = _resolve_llm_api_key()
|
|
if not api_key:
|
|
logger.warning("icarus: no LLM API key found (checked ICARUS_API_KEY_ENV, "
|
|
"DEEPSEEK_API_KEY, OPENROUTER_API_KEY) — skipping LLM extraction")
|
|
return []
|
|
|
|
prompt = (
|
|
"You are a session archivist for an AI agent. Analyze this agent session "
|
|
"transcript and extract ONLY significant entries worth preserving in a "
|
|
"cross-agent knowledge base. Skip trivial sessions, greetings, and routine chatter.\n\n"
|
|
"For each significant entry, provide:\n"
|
|
"- type: \"decision\" (technical decision with rationale), "
|
|
"\"resolution\" (bug fix or problem solved), "
|
|
"or \"note\" (discovery or learning)\n"
|
|
"- summary: one line, max 80 chars, in the original language of the session\n"
|
|
"- content: structured markdown with ## Context, ## Action/Decision, and ## Outcome. "
|
|
"Include concrete details: commands, paths, error messages, decisions made.\n"
|
|
"- training_value: \"high\" (outcome verified, artifact produced, decision with evidence), "
|
|
"\"normal\" (useful context or progress), "
|
|
"or \"low\" (marginal, but not zero)\n\n"
|
|
"If the session contains NOTHING worth preserving across sessions, "
|
|
"return an empty array: []\n\n"
|
|
"Return ONLY valid JSON array, no other text:\n"
|
|
'[{"type": "decision", "summary": "...", "content": "...", "training_value": "high"}, ...]'
|
|
)
|
|
|
|
payload = json.dumps({
|
|
"model": _resolve_llm_model(),
|
|
"messages": [
|
|
{"role": "system", "content": prompt},
|
|
{"role": "user", "content": transcript[:8000]}
|
|
],
|
|
"max_tokens": _EXTRACTION_MAX_TOKENS,
|
|
"temperature": 0.2
|
|
}).encode("utf-8")
|
|
|
|
try:
|
|
endpoint = _resolve_llm_endpoint()
|
|
req = urllib.request.Request(
|
|
endpoint,
|
|
data=payload,
|
|
headers=_resolve_llm_headers(api_key)
|
|
)
|
|
resp = urllib.request.urlopen(req, timeout=45)
|
|
body = json.loads(resp.read().decode("utf-8"))
|
|
raw = body["choices"][0]["message"]["content"]
|
|
|
|
# Parse JSON from response (robust — handles markdown fences, null)
|
|
if raw is None:
|
|
raise ValueError("DeepSeek returned content:null (response_format bug)")
|
|
extracted = _parse_json_robust(raw)
|
|
if isinstance(extracted, dict):
|
|
# Some models return {entries: [...]} — unwrap
|
|
for key in ("entries", "results", "items"):
|
|
if key in extracted and isinstance(extracted[key], list):
|
|
extracted = extracted[key]
|
|
break
|
|
else:
|
|
# Single entry wrapped in dict
|
|
if "type" in extracted:
|
|
extracted = [extracted]
|
|
else:
|
|
extracted = []
|
|
|
|
if not isinstance(extracted, list):
|
|
logger.warning("icarus: LLM extraction returned non-list: %s", type(extracted))
|
|
return []
|
|
|
|
# Validate and filter
|
|
valid = []
|
|
allowed_types = {"decision", "resolution", "note"}
|
|
for entry in extracted:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
etype = entry.get("type", "")
|
|
summary = entry.get("summary", "")
|
|
content = entry.get("content", "")
|
|
if etype not in allowed_types:
|
|
continue
|
|
if len(summary) < 10 or len(content) < 60:
|
|
continue
|
|
valid.append({
|
|
"type": etype,
|
|
"summary": summary[:80],
|
|
"content": content[:2000],
|
|
"training_value": entry.get("training_value", "normal")
|
|
})
|
|
|
|
return valid
|
|
|
|
except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError, ValueError,
|
|
ConnectionError, TimeoutError, OSError) as e:
|
|
logger.warning("icarus: LLM extraction failed (%s) — falling back to legacy", type(e).__name__)
|
|
return []
|
|
|
|
|
|
def _legacy_session_write(platform, scores):
|
|
"""Fallback: original truncated session write (pre-LLM behavior)."""
|
|
plat = platform or "cli"
|
|
parts = []
|
|
|
|
first_user = next(
|
|
(
|
|
ex["user"] for ex in state.exchanges
|
|
if len(ex.get("user", "").strip()) > 50
|
|
and not _is_system_injection(ex.get("user", ""))
|
|
),
|
|
None
|
|
)
|
|
if first_user:
|
|
parts.append(f"## Task\n{first_user[:_TASK_MAX]}")
|
|
|
|
for ex in state.exchanges:
|
|
resp = ex.get("assistant", "")
|
|
if state.DECISION_RE.search(resp) and len(resp) > 100:
|
|
parts.append(f"## Decision\n{resp[:500]}")
|
|
break
|
|
|
|
substantive = [ex for ex in state.exchanges if len(ex.get("assistant", "").strip()) > 100]
|
|
if substantive:
|
|
parts.append(f"## Result\n{substantive[-1]['assistant'][:_RESULT_MAX]}")
|
|
|
|
content = "\n\n".join(parts) if parts else state.exchanges[-1].get("assistant", "")[:500]
|
|
|
|
if substantive:
|
|
result_text = substantive[-1]['assistant']
|
|
else:
|
|
result_text = content
|
|
summary = re.sub(r"\s+", " ", result_text.replace("\n", " ")).strip()[:80]
|
|
summary = re.sub(r"-{2,}", "—", summary) # sanitize: prevent YAML frontmatter breakage
|
|
|
|
if scores["total"] >= 0.6:
|
|
tv = "high"
|
|
elif scores["total"] >= 0.3:
|
|
tv = "normal"
|
|
else:
|
|
tv = "low"
|
|
|
|
state.write_entry("session", content, summary, platform=plat,
|
|
training_value=tv, status="completed")
|
|
|
|
|
|
def on_session_end(session_id="", platform="", completed=False, **kwargs):
|
|
"""Score session, extract entries via LLM, fall back to legacy truncation."""
|
|
creative = state.load_creative()
|
|
state.write_memory_file(creative)
|
|
|
|
if not state.exchanges:
|
|
return
|
|
|
|
scores = state.score_session()
|
|
if scores["total"] < 0.2:
|
|
return
|
|
|
|
plat = platform or "cli"
|
|
|
|
# ── LLM extraction (primary) ──
|
|
transcript = _build_transcript(state.exchanges)
|
|
entries = _llm_extract_entries(transcript)
|
|
|
|
if entries:
|
|
for entry in entries:
|
|
state.write_entry(
|
|
entry["type"],
|
|
entry["content"],
|
|
entry["summary"],
|
|
platform=plat,
|
|
training_value=entry.get("training_value", "normal"),
|
|
status="completed"
|
|
)
|
|
logger.info("icarus: LLM extracted %d entries from session", len(entries))
|
|
else:
|
|
# ── Legacy fallback ──
|
|
logger.info("icarus: LLM extraction produced nothing — using legacy truncation")
|
|
_legacy_session_write(platform, scores)
|