File size: 16,522 Bytes
4554903
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
"""Rivet Memory — persistent, per-user memory over SQLite.

Same shape as Ember's semantic memory (flat records + relevance recall)
but scoped for a code assistant and dependency-free: instead of an
embedding model, recall scores stored memories against the query with
TF-IDF-style keyword overlap, weighted by recency and how often a memory
has recurred. Good enough for "what has this user hit before" — not a
vector store.

Rivet decides what to remember (calls remember_*). Memory decides what
to surface (recall / session_brief ranks and returns it). Memory never
inspects code or talks to the model — it only stores and ranks text
Rivet hands it.

One SQLite file per deployment. Each user gets their own table
(memories_<user_id>) so one user's history never leaks into another's
recall results.
"""

import argparse
import hashlib
import math
import os
import re
import sqlite3
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path


DEFAULT_DB_PATH = Path(
    os.environ.get("RIVET_MEMORY_DB")
    or (Path(__file__).resolve().parent.parent / "data" / "rivet_memory.db")
)

KIND_FILE = "file"                # a file the user works on
KIND_QA = "qa"                    # a question asked + the answer given
KIND_PATTERN = "pattern"          # a recurring behavior (e.g. keeps proposing destructive migrations)
KIND_GATE = "gate_correction"     # a discipline-gate flag that fired on this user's work
KIND_CONTEXT = "context"          # single-value facts: current_branch, recent_pr, ...

_STOPWORDS = {
    "the", "a", "an", "is", "are", "was", "were", "to", "of", "in", "on",
    "for", "and", "or", "it", "this", "that", "with", "at", "as", "be",
    "by", "from", "how", "what", "why", "do", "does", "did", "i", "you",
    "we", "my", "me", "can", "will", "should", "please",
}

# Vocabulary from DESIGN.md's architecture map — matched against file
# paths so file memories carry cross-cutting tags ("auth", "migration")
# even when the path itself doesn't spell them out.
_DOMAIN_TAGS = {
    "auth": ("auth", "jwt", "session", "middleware"),
    "migration": ("migration", "migrations", "schema"),
    "webhook": ("webhook", "webhooks", "stripe"),
    "socket": ("socket", "sockets", "realtime", "pubsub"),
    "store": ("store", "stores", "zustand"),
    "route": ("route", "routes", "api"),
    "job": ("job", "jobs", "pg-boss", "queue"),
}


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _sanitize_user_id(user_id: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9_]", "_", user_id.strip())
    if not slug:
        raise ValueError("user_id must contain at least one alphanumeric character")
    return slug


def _tokenize(text: str) -> list:
    words = re.findall(r"[a-z0-9_./-]+", text.lower())
    return [w for w in words if w not in _STOPWORDS and len(w) > 1]


def _content_key(text: str) -> str:
    return hashlib.sha1(text.strip().lower().encode("utf-8")).hexdigest()[:16]


def _path_tags(file_path: str) -> list:
    lowered = file_path.lower()
    tags = set()
    for tag, needles in _DOMAIN_TAGS.items():
        if any(n in lowered for n in needles):
            tags.add(tag)
    return sorted(tags)


def _age_days(iso_timestamp: str, now: datetime) -> float:
    try:
        ts = datetime.fromisoformat(iso_timestamp)
    except ValueError:
        return 0.0
    if ts.tzinfo is None:
        ts = ts.replace(tzinfo=timezone.utc)
    return max(0.0, (now - ts).total_seconds() / 86400.0)


@dataclass
class MemoryEntry:
    id: int
    kind: str
    key: str
    content: str
    tags: str
    weight: float
    hit_count: int
    created_at: str
    updated_at: str
    score: float = 0.0

    @property
    def tag_list(self) -> list:
        return [t for t in self.tags.split(",") if t] if self.tags else []


def _to_entry(row: sqlite3.Row, score: float = 0.0) -> MemoryEntry:
    return MemoryEntry(
        id=row["id"], kind=row["kind"], key=row["key"], content=row["content"],
        tags=row["tags"] or "", weight=row["weight"], hit_count=row["hit_count"],
        created_at=row["created_at"], updated_at=row["updated_at"], score=score,
    )


class MemoryStore:
    """Per-user persistent memory backed by SQLite. One table per user."""

    def __init__(self, db_path=DEFAULT_DB_PATH):
        self.db_path = Path(db_path)
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._conn = sqlite3.connect(str(self.db_path))
        self._conn.row_factory = sqlite3.Row
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS users (
                user_id TEXT PRIMARY KEY,
                table_name TEXT NOT NULL,
                first_seen TEXT NOT NULL,
                last_seen TEXT NOT NULL
            )
        """)
        self._conn.commit()

    def close(self):
        self._conn.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

    # -- per-user table management ------------------------------------

    def _table_for(self, user_id: str) -> str:
        table = f"memories_{_sanitize_user_id(user_id)}"
        now = _now()
        row = self._conn.execute(
            "SELECT table_name FROM users WHERE user_id = ?", (user_id,)
        ).fetchone()
        if row is None:
            self._conn.execute(
                "INSERT INTO users (user_id, table_name, first_seen, last_seen) VALUES (?, ?, ?, ?)",
                (user_id, table, now, now),
            )
            self._conn.execute(f"""
                CREATE TABLE IF NOT EXISTS "{table}" (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    kind TEXT NOT NULL,
                    key TEXT NOT NULL,
                    content TEXT NOT NULL,
                    tags TEXT DEFAULT '',
                    weight REAL NOT NULL DEFAULT 1.0,
                    hit_count INTEGER NOT NULL DEFAULT 1,
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL,
                    UNIQUE(kind, key)
                )
            """)
            self._conn.commit()
        else:
            self._conn.execute("UPDATE users SET last_seen = ? WHERE user_id = ?", (now, user_id))
            self._conn.commit()
            table = row["table_name"]
        return table

    # -- writing (Rivet decides what to remember) ----------------------

    def remember(self, user_id: str, kind: str, content: str, key: str = None,
                 tags=None, weight: float = 1.0) -> None:
        """Store or reinforce a memory. Same (kind, key) upserts: content is
        replaced with the latest version, weight and hit_count accumulate so
        recurring evidence outranks one-off mentions."""
        table = self._table_for(user_id)
        key = key or _content_key(content)
        tag_str = ",".join(sorted(set(tags))) if tags else ""
        now = _now()
        self._conn.execute(f"""
            INSERT INTO "{table}" (kind, key, content, tags, weight, hit_count, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, 1, ?, ?)
            ON CONFLICT(kind, key) DO UPDATE SET
                content = excluded.content,
                tags = CASE WHEN excluded.tags != '' THEN excluded.tags ELSE tags END,
                weight = weight + excluded.weight,
                hit_count = hit_count + 1,
                updated_at = excluded.updated_at
        """, (kind, key, content, tag_str, weight, now, now))
        self._conn.commit()

    def remember_file(self, user_id: str, file_path: str, note: str = "") -> None:
        """A file the user touched, optionally with what they were doing there."""
        self.remember(user_id, KIND_FILE, note or file_path, key=file_path,
                      tags=_path_tags(file_path), weight=1.0)

    def remember_qa(self, user_id: str, intent: str, outcome: str, tags=None) -> None:
        """A question + answer, compressed to intent/outcome (not full transcript)."""
        content = f"Q: {intent.strip()}\nA: {outcome.strip()}"
        self.remember(user_id, KIND_QA, content, key=_content_key(intent), tags=tags, weight=1.0)

    def remember_pattern(self, user_id: str, pattern: str, detail: str = "", tags=None) -> None:
        """A recurring behavior worth flagging preemptively next time it shows up."""
        self.remember(user_id, KIND_PATTERN, detail or pattern, key=pattern,
                      tags=tags, weight=2.0)

    def remember_gate_correction(self, user_id: str, rule: str, message: str,
                                  severity: str = "WARN") -> None:
        """A discipline-gate flag that fired against this user's suggestion."""
        self.remember(user_id, KIND_GATE, message, key=f"{severity}:{rule}",
                      tags=[rule.lower(), severity.lower()], weight=1.5)

    def set_context(self, user_id: str, key: str, value: str) -> None:
        """Single-value session facts: current_branch, recent_pr, etc."""
        self.remember(user_id, KIND_CONTEXT, str(value), key=key, tags=[key], weight=1.0)

    def get_context(self, user_id: str, key: str, default=None):
        table = self._table_for(user_id)
        row = self._conn.execute(
            f'SELECT content FROM "{table}" WHERE kind = ? AND key = ?', (KIND_CONTEXT, key)
        ).fetchone()
        return row["content"] if row else default

    # -- reading (memory decides what to surface) -----------------------

    def recall(self, user_id: str, query: str, top_n: int = 8, kinds=None) -> list:
        """Rank stored memories against a free-text query.

        Scoring is TF-IDF-style keyword overlap (computed on the fly over
        this user's memories — no external embedding model), boosted by
        recency and by how often the memory has recurred.
        """
        table = self._table_for(user_id)
        sql = f'SELECT * FROM "{table}"'
        params = []
        if kinds:
            sql += f" WHERE kind IN ({','.join('?' for _ in kinds)})"
            params.extend(kinds)
        rows = self._conn.execute(sql, params).fetchall()
        if not rows:
            return []

        query_terms = _tokenize(query)
        if not query_terms:
            return self._rank_by_importance(rows, top_n)

        doc_freq = Counter()
        doc_terms = []
        for row in rows:
            terms = Counter(_tokenize(f"{row['key']} {row['content']} {row['tags']}"))
            doc_terms.append(terms)
            doc_freq.update(terms.keys())

        n_docs = len(rows)
        now = datetime.now(timezone.utc)
        scored = []
        for row, terms in zip(rows, doc_terms):
            overlap = sum(
                terms[qt] * math.log(1 + n_docs / doc_freq[qt])
                for qt in query_terms if terms.get(qt)
            )
            if overlap <= 0:
                continue
            recency = 1.0 / (1.0 + _age_days(row["updated_at"], now) / 14.0)
            importance = math.log(1 + row["hit_count"]) * row["weight"]
            score = overlap * (0.6 + 0.4 * recency) * (1.0 + 0.3 * importance)
            scored.append(_to_entry(row, score))

        scored.sort(key=lambda e: e.score, reverse=True)
        return scored[:top_n]

    def _rank_by_importance(self, rows, top_n: int) -> list:
        now = datetime.now(timezone.utc)
        scored = []
        for row in rows:
            recency = 1.0 / (1.0 + _age_days(row["updated_at"], now) / 14.0)
            importance = math.log(1 + row["hit_count"]) * row["weight"]
            scored.append(_to_entry(row, importance * recency))
        scored.sort(key=lambda e: e.score, reverse=True)
        return scored[:top_n]

    def top_files(self, user_id: str, n: int = 5) -> list:
        table = self._table_for(user_id)
        rows = self._conn.execute(
            f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY hit_count DESC, updated_at DESC LIMIT ?',
            (KIND_FILE, n),
        ).fetchall()
        return [_to_entry(r) for r in rows]

    def recent_patterns(self, user_id: str, n: int = 5) -> list:
        table = self._table_for(user_id)
        rows = self._conn.execute(
            f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY weight DESC, updated_at DESC LIMIT ?',
            (KIND_PATTERN, n),
        ).fetchall()
        return [_to_entry(r) for r in rows]

    def recent_gate_corrections(self, user_id: str, n: int = 5) -> list:
        table = self._table_for(user_id)
        rows = self._conn.execute(
            f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY updated_at DESC LIMIT ?',
            (KIND_GATE, n),
        ).fetchall()
        return [_to_entry(r) for r in rows]

    def session_brief(self, user_id: str, first_message: str = "", top_n: int = 8) -> dict:
        """Everything Rivet should know when a session starts: memories
        relevant to the first message, plus standing context that should
        always be visible (frequent files, recurring patterns, gate history,
        branch/PR state) regardless of what was asked."""
        return {
            "relevant": self.recall(user_id, first_message, top_n=top_n) if first_message else [],
            "top_files": self.top_files(user_id),
            "known_patterns": self.recent_patterns(user_id),
            "gate_history": self.recent_gate_corrections(user_id),
            "current_branch": self.get_context(user_id, "current_branch"),
            "recent_pr": self.get_context(user_id, "recent_pr"),
        }

    def to_system_block(self, user_id: str, first_message: str = "") -> str:
        """Format session_brief as a text block ready to inject into the
        model's system context, in the same spirit as RivetContext in
        context_loader.py."""
        brief = self.session_brief(user_id, first_message)
        parts = ["# USER MEMORY\n"]

        if brief["current_branch"] or brief["recent_pr"]:
            parts.append("## Current Work")
            if brief["current_branch"]:
                parts.append(f"- Branch: {brief['current_branch']}")
            if brief["recent_pr"]:
                parts.append(f"- Recent PR: {brief['recent_pr']}")
            parts.append("")

        if brief["top_files"]:
            parts.append("## Frequently Touched Files")
            parts.extend(f"- {e.key}: {e.content}" for e in brief["top_files"])
            parts.append("")

        if brief["known_patterns"]:
            parts.append("## Recurring Patterns (flag preemptively)")
            parts.extend(f"- {e.key}: {e.content}" for e in brief["known_patterns"])
            parts.append("")

        if brief["gate_history"]:
            parts.append("## Discipline Gate History")
            parts.extend(f"- {e.content}" for e in brief["gate_history"])
            parts.append("")

        if brief["relevant"]:
            parts.append("## Relevant To This Question")
            parts.extend(f"- {e.content}" for e in brief["relevant"])
            parts.append("")

        return "\n".join(parts).strip()


def _cli():
    parser = argparse.ArgumentParser(description="Rivet memory database utility")
    parser.add_argument("command", choices=["init", "demo"])
    parser.add_argument("--db", default=str(DEFAULT_DB_PATH))
    args = parser.parse_args()

    if args.command == "init":
        store = MemoryStore(args.db)
        print(f"Memory database ready at {store.db_path}")
        store.close()
        return

    # demo: quick smoke test exercising the write/recall paths
    store = MemoryStore(args.db)
    user = "demo_user"
    store.remember_file(user, "src/routes/auth.ts", "adding refresh-token rotation")
    store.remember_qa(user, "why does the webhook route 500 on missing signature",
                       "STRIPE_SECRET falls back to '' — Audit C2, reject instead of skip")
    store.remember_pattern(user, "proposes_destructive_migration",
                            "suggested DROP COLUMN twice — always steer to additive migrations")
    store.remember_gate_correction(user, "webhook_bypass",
                                    "Webhook signature bypass pattern (Audit C2) flagged and blocked",
                                    severity="BLOCK")
    store.set_context(user, "current_branch", "feat/refresh-token-rotation")

    print(store.to_system_block(user, first_message="can I touch the webhook signature check?"))
    store.close()


if __name__ == "__main__":
    _cli()