Files
agent0/plugins/festinger/festinger/recollection.py
T

222 lines
7.7 KiB
Python

"""
Recollection engine — read path.
For each salient concept found in an intercepted prompt:
- Query URD edges (from in-memory cache, filtered by confidence)
- Rank by effective score (recency-decayed saliency + centrality bonus)
- Render hit or zero-hit block
- Inject the <recollection> block into the prompt
Scoring
-------
effective_score(c) = saliency(c) * recency_decay(c) + centrality_bonus(c)
recency_decay = exp(-days_since_last_seen / RECENCY_HALF_LIFE)
→ 1.0 if never seen (no penalty for brand-new concepts)
→ 0.5 at 30 days, 0.25 at 60 days, ~0.04 at 90 days
centrality_bonus = log1p(n_children)
→ 0 for leaf concepts
→ grows slowly with the number of concepts that use this
one as a parent (hub nodes surface first)
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timezone, timedelta
from typing import Optional
from . import cache
from .cache import SoasRow, UrdEdge
log = logging.getLogger("festinger.recollection")
ZERO_HIT_TEMPLATE = "You have no memory of {concept}. Try: memory_load query='{concept}' or gutask context {concept}"
# Half-life for recency decay: saliency halves after this many days of silence.
RECENCY_HALF_LIFE = 30.0
# Flat bonus added to effective_score for concepts found in the system message.
# Large enough to push grounding concepts above freshly-encountered domain words.
SESSION_BOOST = 2.0
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
def recency_decay(row: SoasRow) -> float:
"""
Exponential decay based on days since last_seen.
Returns 1.0 if last_seen is unknown (no penalty for fresh concepts).
"""
if row.last_seen is None:
return 1.0
now = datetime.now(tz=timezone.utc)
last = row.last_seen
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
days = (now - last).total_seconds() / 86400.0
return math.exp(-days / RECENCY_HALF_LIFE)
def centrality_bonus(concept_id: int) -> float:
"""
log1p of the number of concepts that reference this one as a parent.
Hub concepts (many children) rise to the top of the recollection block.
"""
n_children = len(cache.urd_by_parent.get(concept_id, []))
return math.log1p(n_children)
def effective_score(concept_id: int, session_boosted: bool = False) -> float:
"""Combined score used to rank concepts within the recollection block."""
token = cache.soas_by_id.get(concept_id, "")
row = cache.soas_by_token.get(token)
if row is None:
return 0.0
base = row.saliency * recency_decay(row) + centrality_bonus(concept_id)
return base + (SESSION_BOOST if session_boosted else 0.0)
# ---------------------------------------------------------------------------
# Query
# ---------------------------------------------------------------------------
def query_edges(concept_id: int, confidence_floor: float) -> list[UrdEdge]:
"""Return URD edges for *concept_id* above the confidence floor."""
return [
e for e in cache.urd_by_concept.get(concept_id, [])
if e.confidence >= confidence_floor
]
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def render_hit(
concept_token: str,
edges: list[UrdEdge],
concept_id: int,
score: float,
reverse_edges: list[UrdEdge] | None = None,
) -> str:
"""
Render one concept's recollection line, e.g.:
gnommoweb [s=3.2]: [type] repo [membership] glitch_university [type←] omega13
The score annotation helps with debugging.
Pending-conflict dimensions get a '?' suffix.
"""
has_pending = concept_id in cache.pending_conflicts
parts = []
for e in edges:
dim_label = e.dim_token + ("?" if has_pending else "")
parts.append(f"[{dim_label}] {e.parent_token}")
for e in (reverse_edges or [])[:3]:
child_token = cache.soas_by_id.get(e.concept_id, str(e.concept_id))
parts.append(f"[{e.dim_token}←] {child_token}")
score_str = f"{score:.1f}"
return f"{concept_token} [s={score_str}]: {' '.join(parts)}"
def render_zero_hit(concept_token: str, score: float) -> str:
line = ZERO_HIT_TEMPLATE.format(concept=concept_token)
soas_row = cache.soas_by_token.get(concept_token)
if soas_row and soas_row.first_seen_context:
line += f" [first seen: '{soas_row.first_seen_context}']"
return line
def build_recollection_block(
salient_concept_ids: list[int],
confidence_floor: float,
recency_days: int, # kept for API compat, no longer used for edge filtering
max_lines: int = 12,
session_boost_ids: set[int] | None = None,
) -> Optional[str]:
"""
Build the <recollection> block, ranked by effective_score (recency-decayed
saliency + centrality bonus + optional session boost).
Concepts with known edges are shown first; zero-hit lines fill the remainder.
session_boost_ids: concept IDs found in the system message — ranked higher
to surface grounding context (agent persona, project name) first.
"""
boosted = session_boost_ids or set()
# Deduplicate and score
seen: set[int] = set()
scored: list[tuple[float, int]] = []
for cid in salient_concept_ids:
if cid in seen:
continue
seen.add(cid)
scored.append((effective_score(cid, session_boosted=(cid in boosted)), cid))
# Highest score first
scored.sort(key=lambda x: x[0], reverse=True)
hit_lines: list[str] = []
zero_lines: list[str] = []
for score, cid in scored:
token = cache.soas_by_id.get(cid, str(cid))
edges = query_edges(cid, confidence_floor)
reverse_edges = cache.urd_by_parent.get(cid, [])
if edges or reverse_edges:
hit_lines.append(render_hit(token, edges, cid, score, reverse_edges))
else:
zero_lines.append(render_zero_hit(token, score))
lines = hit_lines[:max_lines]
remaining = max_lines - len(lines)
lines += zero_lines[:remaining]
if not lines:
return None
# Footer: list the concepts that have actual URD data so the agent knows
# it can dig deeper via gutask recall.
hit_tokens = [
cache.soas_by_id.get(cid, str(cid))
for _, cid in scored
if query_edges(cid, confidence_floor) or cache.urd_by_parent.get(cid)
][:6]
footer_lines = []
if hit_tokens:
footer_lines.append(
"To recall more: gutask recall <concept> brief|detailed|everything"
)
footer_lines.append(" concepts: " + ", ".join(hit_tokens))
body = "\n".join(lines)
if footer_lines:
body += "\n" + "\n".join(footer_lines)
return "<recollection>\n" + body + "\n</recollection>"
# ---------------------------------------------------------------------------
# Prompt injection
# ---------------------------------------------------------------------------
def inject_recollection(messages: list[dict], block: str) -> list[dict]:
"""
Prepend the recollection block to the first system message.
If no system message exists, insert one at position 0.
Returns a new messages list (does not mutate the input).
"""
messages = list(messages)
for i, msg in enumerate(messages):
if msg.get("role") == "system":
messages[i] = dict(msg)
messages[i]["content"] = block + "\n\n" + (msg.get("content") or "")
return messages
# No system message — insert one
messages.insert(0, {"role": "system", "content": block})
return messages