| """Recursive Link Memory — knowledge graph for conversation contexts. |
| |
| Every conversation turn is stored as a linked context. |
| Related contexts are auto-linked (keyword overlap, semantic similarity). |
| During inference: inject linked context as compressed prefix. |
| Links decay over time, reinforced by co-access. |
| Persistent storage in SQLite. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import logging |
| import os |
| import sqlite3 |
| import time |
| from collections import defaultdict, deque |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass |
| class Context: |
| """A conversation context stored in the recursive link graph.""" |
| id: str |
| user_message: str |
| assistant_response: str |
| session_id: str = "" |
| channel: str = "cli" |
| timestamp: float = field(default_factory=time.time) |
| access_count: int = 0 |
| keywords: list[str] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class Link: |
| """A link between two contexts in the recursive link graph.""" |
| source_id: str |
| target_id: str |
| strength: float = 1.0 |
| created_at: float = field(default_factory=time.time) |
| last_accessed: float = field(default_factory=time.time) |
| access_count: int = 0 |
|
|
|
|
| class RecursiveLinkGraph: |
| """Recursive link graph for conversation memory. |
| |
| Features: |
| - Context storage (every conversation turn) |
| - Auto-linking based on keyword overlap |
| - Link traversal (find related contexts) |
| - Link decay (stale links fade over time) |
| - Link reinforcement (co-access strengthens links) |
| - Persistent storage in SQLite |
| """ |
|
|
| DECAY_RATE = 0.001 |
| MIN_STRENGTH = 0.01 |
| MAX_LINKS_PER_CONTEXT = 50 |
|
|
| def __init__(self, db_path: str | None = None) -> None: |
| self.db_path = db_path |
| self._contexts: dict[str, Context] = {} |
| self._links: dict[str, list[Link]] = defaultdict(list) |
| self._keyword_index: dict[str, set[str]] = defaultdict(set) |
| self._stats = { |
| "contexts_stored": 0, |
| "links_created": 0, |
| "links_traversed": 0, |
| "contexts_injected": 0, |
| } |
|
|
| if db_path: |
| os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) |
| self._init_db() |
| self._load_from_db() |
|
|
| def _init_db(self) -> None: |
| """Initialize SQLite database.""" |
| with sqlite3.connect(self.db_path) as conn: |
| conn.executescript(""" |
| CREATE TABLE IF NOT EXISTS contexts ( |
| id TEXT PRIMARY KEY, |
| user_message TEXT, |
| assistant_response TEXT, |
| session_id TEXT, |
| channel TEXT, |
| timestamp REAL, |
| access_count INTEGER, |
| keywords TEXT |
| ); |
| CREATE TABLE IF NOT EXISTS links ( |
| source_id TEXT, |
| target_id TEXT, |
| strength REAL, |
| created_at REAL, |
| last_accessed REAL, |
| access_count INTEGER, |
| PRIMARY KEY (source_id, target_id) |
| ); |
| CREATE INDEX IF NOT EXISTS idx_keywords ON contexts(keywords); |
| """) |
|
|
| def _load_from_db(self) -> None: |
| """Load contexts and links from SQLite.""" |
| with sqlite3.connect(self.db_path) as conn: |
| for row in conn.execute("SELECT * FROM contexts"): |
| ctx = Context( |
| id=row[0], user_message=row[1], assistant_response=row[2], |
| session_id=row[3], channel=row[4], timestamp=row[5], |
| access_count=row[6], |
| keywords=row[7].split(",") if row[7] else [], |
| ) |
| self._contexts[ctx.id] = ctx |
| for kw in ctx.keywords: |
| self._keyword_index[kw].add(ctx.id) |
|
|
| for row in conn.execute("SELECT * FROM links"): |
| link = Link( |
| source_id=row[0], target_id=row[1], strength=row[2], |
| created_at=row[3], last_accessed=row[4], access_count=row[5], |
| ) |
| self._links[link.source_id].append(link) |
|
|
| logger.info("Loaded %d contexts, %d links from DB", len(self._contexts), sum(len(v) for v in self._links.values())) |
|
|
| def _save_context(self, ctx: Context) -> None: |
| if not self.db_path: |
| return |
| with sqlite3.connect(self.db_path) as conn: |
| conn.execute( |
| "INSERT OR REPLACE INTO contexts VALUES (?,?,?,?,?,?,?,?)", |
| (ctx.id, ctx.user_message, ctx.assistant_response, ctx.session_id, |
| ctx.channel, ctx.timestamp, ctx.access_count, ",".join(ctx.keywords)) |
| ) |
|
|
| def _save_link(self, link: Link) -> None: |
| if not self.db_path: |
| return |
| with sqlite3.connect(self.db_path) as conn: |
| conn.execute( |
| "INSERT OR REPLACE INTO links VALUES (?,?,?,?,?,?)", |
| (link.source_id, link.target_id, link.strength, |
| link.created_at, link.last_accessed, link.access_count) |
| ) |
|
|
| def _extract_keywords(self, text: str) -> list[str]: |
| """Extract keywords from text (simple word frequency).""" |
| words = text.lower().split() |
| |
| stop = {"the", "a", "an", "is", "are", "was", "were", "be", "been", |
| "have", "has", "had", "do", "does", "did", "will", "would", |
| "could", "should", "may", "might", "can", "to", "of", "in", |
| "on", "at", "by", "for", "with", "about", "as", "into", "like", |
| "through", "after", "over", "between", "out", "against", |
| "during", "without", "before", "under", "around", "among"} |
| keywords = [w for w in words if len(w) > 2 and w not in stop] |
| return list(set(keywords))[:20] |
|
|
| def add_context(self, user_message: str, assistant_response: str, |
| session_id: str = "", channel: str = "cli") -> str: |
| """Add a conversation context to the graph and auto-link it.""" |
| ctx_id = hashlib.sha256( |
| f"{user_message}:{assistant_response}:{time.time()}".encode() |
| ).hexdigest()[:16] |
|
|
| keywords = self._extract_keywords(user_message + " " + assistant_response) |
| ctx = Context( |
| id=ctx_id, user_message=user_message, assistant_response=assistant_response, |
| session_id=session_id, channel=channel, keywords=keywords, |
| ) |
|
|
| self._contexts[ctx_id] = ctx |
| self._stats["contexts_stored"] += 1 |
|
|
| |
| for kw in keywords: |
| self._keyword_index[kw].add(ctx_id) |
|
|
| |
| self._auto_link(ctx_id, keywords) |
|
|
| |
| self._save_context(ctx) |
|
|
| return ctx_id |
|
|
| def _auto_link(self, ctx_id: str, keywords: list[str]) -> None: |
| """Automatically create links to contexts with overlapping keywords.""" |
| related: dict[str, int] = defaultdict(int) |
|
|
| for kw in keywords: |
| for other_id in self._keyword_index.get(kw, set()): |
| if other_id != ctx_id: |
| related[other_id] += 1 |
|
|
| |
| for other_id, overlap in sorted(related.items(), key=lambda x: -x[1])[:self.MAX_LINKS_PER_CONTEXT]: |
| strength = min(1.0, overlap / max(len(keywords), 1)) |
| link = Link(source_id=ctx_id, target_id=other_id, strength=strength) |
| self._links[ctx_id].append(link) |
| self._stats["links_created"] += 1 |
| self._save_link(link) |
|
|
| |
| rev_link = Link(source_id=other_id, target_id=ctx_id, strength=strength) |
| self._links[other_id].append(rev_link) |
| self._save_link(rev_link) |
|
|
| def find_related(self, message: str, max_results: int = 3) -> list[Context]: |
| """Find contexts related to the given message.""" |
| keywords = self._extract_keywords(message) |
| related: dict[str, float] = defaultdict(float) |
|
|
| for kw in keywords: |
| for ctx_id in self._keyword_index.get(kw, set()): |
| if ctx_id in self._contexts: |
| |
| ctx = self._contexts[ctx_id] |
| age = time.time() - ctx.timestamp |
| decayed_strength = max(self.MIN_STRENGTH, 1.0 - age * self.DECAY_RATE) |
| related[ctx_id] += decayed_strength |
|
|
| |
| sorted_ids = sorted(related.items(), key=lambda x: -x[1])[:max_results] |
| results = [] |
| for ctx_id, score in sorted_ids: |
| ctx = self._contexts[ctx_id] |
| ctx.access_count += 1 |
| self._stats["links_traversed"] += 1 |
| results.append(ctx) |
|
|
| return results |
|
|
| def get_injection_context(self, message: str) -> str: |
| """Get related context text to inject as a prefix for inference.""" |
| related = self.find_related(message, max_results=3) |
| if not related: |
| return "" |
|
|
| self._stats["contexts_injected"] += 1 |
| parts = [] |
| for ctx in related: |
| parts.append(f"Previous: Q: {ctx.user_message[:100]} A: {ctx.assistant_response[:100]}") |
| return " | ".join(parts) |
|
|
| def decay_links(self) -> None: |
| """Apply time-based decay to all links. Call periodically.""" |
| now = time.time() |
| for ctx_id, links in self._links.items(): |
| for link in links: |
| age = now - link.last_accessed |
| link.strength = max(self.MIN_STRENGTH, link.strength - age * self.DECAY_RATE) |
|
|
| def get_stats(self) -> dict[str, Any]: |
| return { |
| **self._stats, |
| "total_contexts": len(self._contexts), |
| "total_links": sum(len(v) for v in self._links.values()), |
| "keyword_index_size": len(self._keyword_index), |
| } |
|
|