File size: 10,089 Bytes
32112fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""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  # per second
    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()
        # Remove very common words
        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

        # Index keywords
        for kw in keywords:
            self._keyword_index[kw].add(ctx_id)

        # Auto-link to related contexts
        self._auto_link(ctx_id, keywords)

        # Save to DB
        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

        # Sort by overlap count and create links
        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)

            # Bidirectional 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:
                    # Apply link decay
                    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

        # Sort by relevance
        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),
        }