luminary-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from luminary_memory.api import MemoryClient
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["MemoryClient", "__version__"]
luminary_memory/api.py ADDED
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from luminary_memory.backends import get_backend
6
+ from luminary_memory.budget import truncate
7
+ from luminary_memory.config import Settings
8
+ from luminary_memory.embeddings.fastembed import FastembedEngine
9
+ from luminary_memory.ingest.llm import LLMEnricher, NoopEnricher
10
+ from luminary_memory.ingest.whitelist import WhitelistFilter
11
+ from luminary_memory.types import Memory, RecallResult
12
+
13
+
14
+ def _try_index_graph(backend, memory: Memory) -> None:
15
+ import logging
16
+
17
+ from luminary_memory.recall.graph import index_memory_entities
18
+
19
+ try:
20
+ index_memory_entities(backend, memory)
21
+ except Exception: # noqa: BLE001 -- graph indexing is best-effort; never abort ingest
22
+ logging.getLogger(__name__).warning(
23
+ "graph indexing failed for memory %s (non-fatal)", memory.id
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from luminary_memory.backends.base import MemoryBackend
28
+
29
+
30
+ class MemoryClient:
31
+ def __init__(
32
+ self,
33
+ settings: Settings | None = None,
34
+ db_path: str | None = None,
35
+ ingest_whitelist: list[str] | None = None,
36
+ enricher: LLMEnricher | None = None,
37
+ engine: FastembedEngine | None = None,
38
+ backend: MemoryBackend | None = None,
39
+ ):
40
+ self.settings = settings or Settings()
41
+ if db_path is not None:
42
+ self.settings.db_path = db_path
43
+ if ingest_whitelist is not None:
44
+ self.settings.ingest_whitelist = ingest_whitelist
45
+
46
+ self.backend = backend or get_backend(self.settings)
47
+ self.whitelist = WhitelistFilter(self.settings.ingest_whitelist)
48
+ self.engine = engine or FastembedEngine(model_name=self.settings.embedding_model)
49
+ self.enricher = enricher or (NoopEnricher() if not self.settings.ingest_llm else None)
50
+
51
+ def ingest(self, text: str, tags: list[str] | None = None,
52
+ source: str | None = None) -> int | None:
53
+ if not self.whitelist.accepts(text):
54
+ return None
55
+
56
+ content, summary, entities, extra_tags = text, None, [], []
57
+ if self.enricher is not None:
58
+ enriched = self.enricher.enrich(text)
59
+ content, summary, entities, extra_tags = (
60
+ enriched.content, enriched.summary, enriched.entities, enriched.tags,
61
+ )
62
+
63
+ metadata: dict = {}
64
+ if summary:
65
+ metadata["summary"] = summary
66
+ if entities:
67
+ metadata["entities"] = entities
68
+
69
+ m = Memory(
70
+ content=content,
71
+ metadata=metadata,
72
+ source=source,
73
+ tags=list(dict.fromkeys((tags or []) + extra_tags)),
74
+ ttl_seconds=self.settings.ttl_default_seconds,
75
+ embedding=self.engine.embed(content),
76
+ )
77
+ mid = self.backend.add(m)
78
+ m.id = mid
79
+ _try_index_graph(self.backend, m)
80
+ return mid
81
+
82
+ def get(self, id: int) -> Memory | None:
83
+ return self.backend.get(id)
84
+
85
+ def update(self, memory: Memory) -> None:
86
+ """Update an existing memory in place."""
87
+ self.backend.update(memory)
88
+
89
+ def delete(self, id: int) -> None:
90
+ """Delete a memory by id."""
91
+ self.backend.delete(id)
92
+
93
+ def list(self, limit: int = 100, offset: int = 0) -> list[Memory]:
94
+ """List memories, most recent first (datetime-aware sort)."""
95
+ limit = max(0, int(limit))
96
+ offset = max(0, int(offset))
97
+ from luminary_memory.recall.temporal import _parse_dt
98
+
99
+ all_mem = self.backend.all()
100
+ all_mem.sort(key=lambda m: (_parse_dt(m.created_at or ""), -(m.id or 0)), reverse=True)
101
+ return all_mem[offset:offset + limit]
102
+
103
+ def search(self, query: str, limit: int = 10) -> list[tuple[Memory, float]]:
104
+ """Direct keyword (FTS) search without the full recall pipeline."""
105
+ if not (query or "").strip():
106
+ return []
107
+ try:
108
+ return self.backend.keyword_search(query, limit=limit)
109
+ except Exception: # noqa: BLE001
110
+ return []
111
+
112
+ def stats(self) -> dict:
113
+ """Store statistics: count, oldest/newest, avg importance, tags."""
114
+ import statistics
115
+
116
+ all_mem = self.backend.all()
117
+ n = len(all_mem)
118
+ if not n:
119
+ return {
120
+ "count": 0,
121
+ "oldest": None,
122
+ "newest": None,
123
+ "avg_importance": 0.0,
124
+ "top_tags": {},
125
+ }
126
+
127
+ avg_importance = statistics.fmean([m.importance for m in all_mem])
128
+ tag_counts: dict[str, int] = {}
129
+ for m in all_mem:
130
+ for t in m.tags or []:
131
+ tag_counts[t] = tag_counts.get(t, 0) + 1
132
+ top_tags = sorted(tag_counts.items(), key=lambda kv: -kv[1])[:10]
133
+ created = sorted(m.created_at or "" for m in all_mem)
134
+ return {
135
+ "count": n,
136
+ "oldest": created[0] if created else None,
137
+ "newest": created[-1] if created else None,
138
+ "avg_importance": round(avg_importance, 3),
139
+ "top_tags": dict(top_tags),
140
+ }
141
+
142
+ def count(self) -> int:
143
+ return self.backend.count()
144
+
145
+ def run_lifecycle(self) -> dict[str, int]:
146
+ from luminary_memory.lifecycle.runner import run_lifecycle
147
+
148
+ return run_lifecycle(self.backend, self.settings)
149
+
150
+ def close(self) -> None:
151
+ self.backend.close()
152
+
153
+ def recall(
154
+ self,
155
+ query: str,
156
+ limit: int = 10,
157
+ token_budget: int | None = None,
158
+ ) -> RecallResult:
159
+ from luminary_memory.recall.dedup import dedup_jaccard
160
+ from luminary_memory.recall.fusion import reciprocal_rank_fusion
161
+ from luminary_memory.recall.graph import graph_recall
162
+ from luminary_memory.recall.keyword import keyword_recall
163
+ from luminary_memory.recall.semantic import semantic_recall
164
+ from luminary_memory.recall.temporal import temporal_recall
165
+
166
+ budget = token_budget if token_budget is not None else self.settings.token_budget
167
+ rrf_k = self.settings.rrf_k
168
+ dedup_threshold = self.settings.dedup_jaccard_threshold
169
+
170
+ # Per-strategy isolation: one failing strategy must not kill recall.
171
+ strategies: list[list[tuple]] = []
172
+ for fn in (
173
+ lambda: semantic_recall(self.backend, self.engine, query, limit=limit),
174
+ lambda: keyword_recall(self.backend, query, limit=limit),
175
+ lambda: temporal_recall(self.backend, limit=limit * 2),
176
+ lambda: graph_recall(self.backend, query, limit=limit),
177
+ ):
178
+ try:
179
+ strategies.append(fn())
180
+ except Exception: # noqa: BLE001
181
+ strategies.append([])
182
+
183
+ id_to_mem: dict[int, Memory] = {}
184
+ strategies_hit: dict[str, int] = {}
185
+ ranked_lists: list[list[int]] = []
186
+ for strat in strategies:
187
+ ranked_lists.append([m.id for m, _, _ in strat if m.id is not None])
188
+ for m, score, label in strat:
189
+ if m.id is None:
190
+ continue
191
+ strategies_hit[label] = strategies_hit.get(label, 0) + 1
192
+ id_to_mem[m.id] = m
193
+
194
+ fused = reciprocal_rank_fusion(ranked_lists, k=rrf_k)
195
+ scored: list[tuple[Memory, float]] = [
196
+ (id_to_mem[mid], score) for mid, score in fused if mid in id_to_mem
197
+ ]
198
+
199
+ if not scored:
200
+ return RecallResult(memories=[], scores=[], strategies_hit=strategies_hit)
201
+
202
+ scored = dedup_jaccard(scored, threshold=dedup_threshold)
203
+
204
+ memories_ordered = [m for m, _ in scored]
205
+ memories_ordered = truncate(memories_ordered, token_budget=budget)
206
+
207
+ id_to_fused = dict(fused)
208
+ final_scores = [float(id_to_fused.get(m.id, 0.0)) for m in memories_ordered]
209
+
210
+ from datetime import UTC, datetime
211
+
212
+ for m in memories_ordered[:limit]:
213
+ m.access_count += 1
214
+ m.last_accessed_at = datetime.now(UTC).isoformat()
215
+ self.backend.update(m)
216
+
217
+ trimmed = {k: v for k, v in strategies_hit.items() if v}
218
+ return RecallResult(
219
+ memories=memories_ordered[:limit],
220
+ scores=final_scores[:limit],
221
+ strategies_hit=trimmed,
222
+ )
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from luminary_memory.backends.sqlite import SQLiteBackend
6
+
7
+ if TYPE_CHECKING:
8
+ from luminary_memory.backends.base import MemoryBackend
9
+ from luminary_memory.config import Settings
10
+
11
+
12
+ def get_backend(settings: Settings) -> MemoryBackend:
13
+ backend_name = (settings.backend or "sqlite").strip().lower()
14
+ if backend_name == "pgvector":
15
+ from luminary_memory.backends.pgvector import (
16
+ PGVectorBackend,
17
+ )
18
+
19
+ if not settings.pg_dsn:
20
+ raise ValueError("pg_dsn is required when backend='pgvector'")
21
+ if int(settings.embedding_dim) <= 0:
22
+ raise ValueError("embedding_dim must be positive")
23
+ return PGVectorBackend(
24
+ dsn=settings.pg_dsn, embedding_dim=int(settings.embedding_dim)
25
+ )
26
+ if backend_name == "sqlite":
27
+ return SQLiteBackend(db_path=settings.db_path)
28
+ raise ValueError(f"unsupported backend {settings.backend!r}")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from luminary_memory.types import Memory
6
+
7
+
8
+ class MemoryBackend(ABC):
9
+ @abstractmethod
10
+ def add(self, m: Memory) -> int: ...
11
+ @abstractmethod
12
+ def get(self, id: int) -> Memory | None: ...
13
+ @abstractmethod
14
+ def update(self, m: Memory) -> None: ...
15
+ @abstractmethod
16
+ def delete(self, id: int) -> None: ...
17
+ @abstractmethod
18
+ def all(self) -> list[Memory]: ...
19
+ @abstractmethod
20
+ def keyword_search(self, query: str, limit: int) -> list[tuple[Memory, float]]: ...
21
+ @abstractmethod
22
+ def vector_search(self, vec: list[float], limit: int) -> list[tuple[Memory, float]]: ...
23
+ @abstractmethod
24
+ def count(self) -> int: ...
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from luminary_memory.backends.base import MemoryBackend
7
+ from luminary_memory.types import Memory
8
+
9
+
10
+ class PGVectorBackend(MemoryBackend):
11
+ def __init__(
12
+ self,
13
+ dsn: str = "postgresql://localhost/luminary_memory",
14
+ embedding_dim: int = 384,
15
+ ):
16
+ import psycopg
17
+
18
+ self.dsn = dsn
19
+ self.embedding_dim = int(embedding_dim)
20
+ self._psycopg = psycopg
21
+ self.conn = psycopg.connect(dsn)
22
+ self._ensure_schema()
23
+
24
+ def _ensure_schema(self) -> None:
25
+ cur = self.conn.cursor()
26
+ cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
27
+ cur.execute(
28
+ f"""
29
+ CREATE TABLE IF NOT EXISTS memories (
30
+ id SERIAL PRIMARY KEY,
31
+ content TEXT NOT NULL,
32
+ metadata JSONB NOT NULL DEFAULT '{{}}',
33
+ source TEXT,
34
+ tags JSONB NOT NULL DEFAULT '[]',
35
+ importance DOUBLE PRECISION NOT NULL DEFAULT 0.5,
36
+ ttl_seconds INTEGER,
37
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
38
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
39
+ last_accessed_at TIMESTAMPTZ,
40
+ access_count INTEGER NOT NULL DEFAULT 0,
41
+ embedding vector({self.embedding_dim})
42
+ )
43
+ """
44
+ )
45
+ cur.execute(
46
+ """
47
+ CREATE TABLE IF NOT EXISTS entities (
48
+ id SERIAL PRIMARY KEY,
49
+ name TEXT NOT NULL UNIQUE,
50
+ type TEXT NOT NULL DEFAULT 'generic'
51
+ )
52
+ """
53
+ )
54
+ cur.execute(
55
+ """
56
+ CREATE TABLE IF NOT EXISTS relations (
57
+ id SERIAL PRIMARY KEY,
58
+ source_id INTEGER NOT NULL REFERENCES entities(id),
59
+ target_id INTEGER NOT NULL REFERENCES entities(id),
60
+ relation_type TEXT NOT NULL DEFAULT 'cooccur',
61
+ weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
62
+ memory_id INTEGER REFERENCES memories(id)
63
+ )
64
+ """
65
+ )
66
+ self.conn.commit()
67
+
68
+ def _row_to_memory(self, row: tuple[Any, ...] | dict[str, Any]) -> Memory:
69
+ if isinstance(row, dict):
70
+ d: dict[str, Any] = row
71
+ emb = d.get("embedding")
72
+ else:
73
+ cols = [
74
+ "id", "content", "metadata", "source", "tags", "importance",
75
+ "ttl_seconds", "created_at", "updated_at", "last_accessed_at",
76
+ "access_count", "embedding",
77
+ ]
78
+ d = dict(zip(cols, row))
79
+ emb = d.get("embedding")
80
+ if isinstance(emb, str):
81
+ try:
82
+ emb = json.loads(emb)
83
+ except json.JSONDecodeError:
84
+ emb = None
85
+ return Memory(
86
+ id=int(d["id"]) if d.get("id") is not None else None,
87
+ content=str(d.get("content") or ""),
88
+ metadata=d.get("metadata") if isinstance(d.get("metadata"), dict) else json.loads(d.get("metadata") or "{}") if d.get("metadata") else {},
89
+ source=d.get("source"),
90
+ tags=d.get("tags") if isinstance(d.get("tags"), list) else json.loads(d.get("tags") or "[]") if d.get("tags") else [],
91
+ importance=float(d.get("importance") or 0.5),
92
+ ttl_seconds=d.get("ttl_seconds"),
93
+ created_at=str(d.get("created_at") or ""),
94
+ updated_at=str(d.get("updated_at") or ""),
95
+ last_accessed_at=str(d["last_accessed_at"]) if d.get("last_accessed_at") else None,
96
+ access_count=int(d.get("access_count") or 0),
97
+ embedding=list(emb) if isinstance(emb, (list, tuple)) else None,
98
+ )
99
+
100
+ def add(self, m: Memory) -> int:
101
+ cur = self.conn.cursor()
102
+ cur.execute(
103
+ """
104
+ INSERT INTO memories (content, metadata, source, tags, importance,
105
+ ttl_seconds, created_at, updated_at,
106
+ last_accessed_at, access_count, embedding)
107
+ VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s)
108
+ RETURNING id
109
+ """,
110
+ (
111
+ m.content,
112
+ json.dumps(m.metadata),
113
+ m.source,
114
+ json.dumps(m.tags),
115
+ float(m.importance),
116
+ m.ttl_seconds,
117
+ m.created_at,
118
+ m.updated_at,
119
+ m.last_accessed_at,
120
+ int(m.access_count),
121
+ m.embedding,
122
+ ),
123
+ )
124
+ row = cur.fetchone()
125
+ self.conn.commit()
126
+ return int(row[0]) if row else 0
127
+
128
+ def get(self, id: int) -> Memory | None:
129
+ cur = self.conn.cursor()
130
+ cur.execute("SELECT * FROM memories WHERE id = %s", (id,))
131
+ row = cur.fetchone()
132
+ if row is None:
133
+ return None
134
+ if isinstance(row, dict):
135
+ return self._row_to_memory(row)
136
+ return self._row_to_memory(row)
137
+
138
+ def update(self, m: Memory) -> None:
139
+ if m.id is None:
140
+ raise ValueError("cannot update a memory without an id")
141
+ cur = self.conn.cursor()
142
+ cur.execute(
143
+ """
144
+ UPDATE memories SET content=%s, metadata=%s::jsonb, source=%s, tags=%s::jsonb,
145
+ importance=%s, ttl_seconds=%s, updated_at=%s,
146
+ last_accessed_at=%s, access_count=%s, embedding=%s
147
+ WHERE id=%s
148
+ """,
149
+ (
150
+ m.content,
151
+ json.dumps(m.metadata),
152
+ m.source,
153
+ json.dumps(m.tags),
154
+ float(m.importance),
155
+ m.ttl_seconds,
156
+ m.updated_at,
157
+ m.last_accessed_at,
158
+ int(m.access_count),
159
+ m.embedding,
160
+ m.id,
161
+ ),
162
+ )
163
+ self.conn.commit()
164
+
165
+ def delete(self, id: int) -> None:
166
+ cur = self.conn.cursor()
167
+ # Relations reference memories via FK; delete them first (manual cascade).
168
+ cur.execute("DELETE FROM relations WHERE memory_id = %s", (id,))
169
+ cur.execute("DELETE FROM memories WHERE id = %s", (id,))
170
+ self.conn.commit()
171
+
172
+ def all(self) -> list[Memory]:
173
+ cur = self.conn.cursor()
174
+ cur.execute("SELECT * FROM memories ORDER BY id")
175
+ rows = cur.fetchall()
176
+ return [self._row_to_memory(r) for r in rows]
177
+
178
+ def keyword_search(self, query: str, limit: int = 10) -> list[tuple[Memory, float]]:
179
+ escaped = query.replace("%", r"\%").replace("_", r"\_")
180
+ q = f"%{escaped}%"
181
+ cur = self.conn.cursor()
182
+ cur.execute(
183
+ "SELECT * FROM memories WHERE content ILIKE %s OR tags::text ILIKE %s LIMIT %s",
184
+ (q, q, int(limit)),
185
+ )
186
+ rows = cur.fetchall()
187
+ return [(self._row_to_memory(r), 1.0) for r in rows]
188
+
189
+ def vector_search(self, vec: list[float], limit: int = 10) -> list[tuple[Memory, float]]:
190
+ cur = self.conn.cursor()
191
+ cur.execute(
192
+ "SELECT *, embedding <=> %s::vector AS distance FROM memories "
193
+ "WHERE embedding IS NOT NULL ORDER BY distance LIMIT %s",
194
+ (vec, int(limit)),
195
+ )
196
+ rows = cur.fetchall()
197
+ results: list[tuple[Memory, float]] = []
198
+ for r in rows:
199
+ if isinstance(r, dict):
200
+ dist = float(r.get("distance") or 0.0)
201
+ elif isinstance(r, (list, tuple)) and len(r) >= 13:
202
+ dist = float(r[-1] or 0.0)
203
+ else:
204
+ dist = 0.0
205
+ results.append((self._row_to_memory(r), 1.0 - dist))
206
+ return results
207
+
208
+ def count(self) -> int:
209
+ cur = self.conn.cursor()
210
+ cur.execute("SELECT COUNT(*) FROM memories")
211
+ row = cur.fetchone()
212
+ if row is None:
213
+ return 0
214
+ return int(row[0] if isinstance(row, (list, tuple)) else row.get("count", 0))
215
+
216
+ def close(self) -> None:
217
+ try:
218
+ self.conn.close()
219
+ except (AttributeError, OSError, RuntimeError):
220
+ pass
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+
6
+ import numpy as np
7
+
8
+ from luminary_memory.backends.base import MemoryBackend
9
+ from luminary_memory.schema import init_schema
10
+ from luminary_memory.types import Memory
11
+
12
+
13
+ class SQLiteBackend(MemoryBackend):
14
+ def __init__(self, db_path: str = "luminary_memory.db"):
15
+ self.db_path = db_path
16
+ self.conn = sqlite3.connect(db_path)
17
+ self.conn.row_factory = sqlite3.Row
18
+ self.conn.execute("PRAGMA foreign_keys = ON")
19
+ init_schema(self.conn)
20
+
21
+ def _row_to_memory(self, row: sqlite3.Row) -> Memory:
22
+ m = Memory(
23
+ id=row["id"],
24
+ content=row["content"],
25
+ metadata=json.loads(row["metadata"] or "{}"),
26
+ source=row["source"],
27
+ tags=json.loads(row["tags"] or "[]"),
28
+ importance=row["importance"],
29
+ ttl_seconds=row["ttl_seconds"],
30
+ created_at=row["created_at"],
31
+ updated_at=row["updated_at"],
32
+ last_accessed_at=row["last_accessed_at"],
33
+ access_count=row["access_count"],
34
+ )
35
+ emb = row["embedding"]
36
+ if emb is not None:
37
+ m.embedding = np.frombuffer(emb, dtype=np.float32).tolist()
38
+ return m
39
+
40
+ @staticmethod
41
+ def _encode_embedding(vec: list[float] | None) -> bytes | None:
42
+ if vec is None:
43
+ return None
44
+ return np.asarray(vec, dtype=np.float32).tobytes()
45
+
46
+ def add(self, m: Memory) -> int:
47
+ cur = self.conn.execute(
48
+ "INSERT INTO memories (content, metadata, source, tags, importance, "
49
+ "ttl_seconds, created_at, updated_at, last_accessed_at, access_count, embedding) "
50
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
51
+ (
52
+ m.content,
53
+ json.dumps(m.metadata),
54
+ m.source,
55
+ json.dumps(m.tags),
56
+ m.importance,
57
+ m.ttl_seconds,
58
+ m.created_at,
59
+ m.updated_at,
60
+ m.last_accessed_at,
61
+ m.access_count,
62
+ self._encode_embedding(m.embedding),
63
+ ),
64
+ )
65
+ self.conn.commit()
66
+ return int(cur.lastrowid)
67
+
68
+ def get(self, id: int) -> Memory | None:
69
+ row = self.conn.execute("SELECT * FROM memories WHERE id = ?", (id,)).fetchone()
70
+ return self._row_to_memory(row) if row else None
71
+
72
+ def update(self, m: Memory) -> None:
73
+ if m.id is None:
74
+ raise ValueError("cannot update a memory without an id")
75
+ self.conn.execute(
76
+ "UPDATE memories SET content=?, metadata=?, source=?, tags=?, importance=?, "
77
+ "ttl_seconds=?, updated_at=?, last_accessed_at=?, access_count=?, embedding=? "
78
+ "WHERE id=?",
79
+ (
80
+ m.content,
81
+ json.dumps(m.metadata),
82
+ m.source,
83
+ json.dumps(m.tags),
84
+ m.importance,
85
+ m.ttl_seconds,
86
+ m.updated_at,
87
+ m.last_accessed_at,
88
+ m.access_count,
89
+ self._encode_embedding(m.embedding),
90
+ m.id,
91
+ ),
92
+ )
93
+ self.conn.commit()
94
+
95
+ def delete(self, id: int) -> None:
96
+ # Relations reference memories via FK; delete them first (manual cascade).
97
+ self.conn.execute("DELETE FROM relations WHERE memory_id = ?", (id,))
98
+ self.conn.execute("DELETE FROM memories WHERE id = ?", (id,))
99
+ self.conn.commit()
100
+
101
+ def all(self) -> list[Memory]:
102
+ rows = self.conn.execute("SELECT * FROM memories ORDER BY id").fetchall()
103
+ return [self._row_to_memory(r) for r in rows]
104
+
105
+ def keyword_search(self, query: str, limit: int = 10) -> list[tuple[Memory, float]]:
106
+ safe = query.replace('"', ' ')
107
+ rows = self.conn.execute(
108
+ "SELECT m.*, bm25(memories_fts) AS rank "
109
+ "FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid "
110
+ "WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?",
111
+ (safe, limit),
112
+ ).fetchall()
113
+ return [(self._row_to_memory(r), -float(r["rank"])) for r in rows]
114
+
115
+ def vector_search(self, vec: list[float], limit: int = 10) -> list[tuple[Memory, float]]:
116
+ q = np.asarray(vec, dtype=np.float32)
117
+ qn = float(np.linalg.norm(q))
118
+ results: list[tuple[Memory, float]] = []
119
+ for m in self.all():
120
+ if m.embedding is None:
121
+ continue
122
+ v = np.asarray(m.embedding, dtype=np.float32)
123
+ vn = float(np.linalg.norm(v))
124
+ sim = 0.0 if (qn == 0 or vn == 0) else float(np.dot(q, v) / (qn * vn))
125
+ results.append((m, sim))
126
+ results.sort(key=lambda x: -x[1])
127
+ return results[:limit]
128
+
129
+ def count(self) -> int:
130
+ row = self.conn.execute("SELECT COUNT(*) FROM memories").fetchone()
131
+ return int(row[0])
132
+
133
+ def close(self) -> None:
134
+ self.conn.close()
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from luminary_memory.types import Memory
8
+
9
+
10
+ def _estimate_tokens(text: str) -> int:
11
+ return len(text.split())
12
+
13
+
14
+ def truncate(
15
+ memories: list[Memory],
16
+ token_budget: int,
17
+ tokenizer: Callable[[str], list] | None = None,
18
+ ) -> list[Memory]:
19
+ if not memories or token_budget <= 0:
20
+ return []
21
+ count_fn: Callable[[str], int]
22
+ if tokenizer is not None:
23
+ def count_fn(s: str) -> int:
24
+ return len(tokenizer(s))
25
+ else:
26
+ count_fn = _estimate_tokens
27
+
28
+ result: list[Memory] = []
29
+ total = 0
30
+ for m in memories:
31
+ cost = count_fn(m.content)
32
+ if total + cost > token_budget:
33
+ continue
34
+ total += cost
35
+ result.append(m)
36
+ return result