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.
- luminary_memory/__init__.py +4 -0
- luminary_memory/api.py +222 -0
- luminary_memory/backends/__init__.py +28 -0
- luminary_memory/backends/base.py +24 -0
- luminary_memory/backends/pgvector.py +220 -0
- luminary_memory/backends/sqlite.py +134 -0
- luminary_memory/budget.py +36 -0
- luminary_memory/cli.py +146 -0
- luminary_memory/config.py +86 -0
- luminary_memory/embeddings/__init__.py +0 -0
- luminary_memory/embeddings/fastembed.py +35 -0
- luminary_memory/ingest/__init__.py +0 -0
- luminary_memory/ingest/llm.py +21 -0
- luminary_memory/ingest/whitelist.py +18 -0
- luminary_memory/lifecycle/__init__.py +0 -0
- luminary_memory/lifecycle/cleanup.py +29 -0
- luminary_memory/lifecycle/consolidate.py +47 -0
- luminary_memory/lifecycle/prune.py +28 -0
- luminary_memory/lifecycle/runner.py +26 -0
- luminary_memory/recall/__init__.py +0 -0
- luminary_memory/recall/dedup.py +30 -0
- luminary_memory/recall/fusion.py +12 -0
- luminary_memory/recall/graph.py +145 -0
- luminary_memory/recall/keyword.py +15 -0
- luminary_memory/recall/semantic.py +21 -0
- luminary_memory/recall/temporal.py +43 -0
- luminary_memory/schema.py +54 -0
- luminary_memory/types.py +36 -0
- luminary_memory-0.1.0.dist-info/METADATA +166 -0
- luminary_memory-0.1.0.dist-info/RECORD +33 -0
- luminary_memory-0.1.0.dist-info/WHEEL +4 -0
- luminary_memory-0.1.0.dist-info/entry_points.txt +2 -0
- luminary_memory-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|