astrocyte-sqlite 0.16.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,11 @@
1
+ """SQLite adapter for Astrocyte — zero-infrastructure local storage.
2
+
3
+ One file on disk, no server, no native extensions. Provides a
4
+ :class:`SqliteStore` satisfying both the VectorStore and DocumentStore
5
+ protocols, with semantics matched to :class:`astrocyte_postgres.PostgresStore`
6
+ so recall behaves the same on a laptop as on the benched production backend.
7
+ """
8
+
9
+ from astrocyte_sqlite.store import SqliteStore
10
+
11
+ __all__ = ["SqliteStore"]
@@ -0,0 +1,799 @@
1
+ """SQLite-backed VectorStore + DocumentStore.
2
+
3
+ Why this exists
4
+ ---------------
5
+ "Install it and it works in your coding agent" needs storage with no server
6
+ to run. This adapter keeps every memory in one SQLite file so a developer can
7
+ ``pip install`` and start retaining immediately.
8
+
9
+ Semantics are matched to ``PostgresStore``, not ``InMemoryVectorStore``
10
+ -----------------------------------------------------------------------
11
+ The two existing backends disagree in several places (the in-memory store lets
12
+ untagged items through a tag filter, never soft-deletes, returns unclamped
13
+ cosine, and applies ``time_range``/``session_id`` in ``search_similar`` where
14
+ Postgres does not). Postgres is what users deploy and what every benchmark
15
+ measures, so it is the contract here: identical config should recall
16
+ identically whichever of the two production backends holds the data.
17
+ ``tests/test_parity_postgres.py`` enforces this differentially.
18
+
19
+ Design choices
20
+ --------------
21
+ * **No native extensions.** Embeddings are float32 BLOBs (pgvector's ``vector``
22
+ is also float4) and similarity is exact cosine in numpy. ``sqlite-vec`` would
23
+ need ``enable_load_extension``, which some Python builds omit. Exact search
24
+ also out-recalls ANN at personal-memory scale.
25
+ * **FTS5 when present, LIKE fallback otherwise.** Keyword queries drop the same
26
+ English stopwords ``plainto_tsquery('english', …)`` drops and AND the rest,
27
+ so "what is the database" matches like it does on Postgres.
28
+ * **Multi-process safe.** An MCP server and short-lived hook processes write
29
+ the same file concurrently: WAL journal, ``BEGIN IMMEDIATE`` for writes, a
30
+ busy timeout, and a fresh connection per call (sqlite3 connections are
31
+ thread-affine and cheap to open).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import json
38
+ import os
39
+ import re
40
+ import sqlite3
41
+ import threading
42
+ import time
43
+ from collections.abc import Callable, Iterable
44
+ from datetime import UTC, datetime, timedelta
45
+ from pathlib import Path
46
+ from typing import Any, ClassVar, TypeVar
47
+
48
+ import numpy as np
49
+ from astrocyte.types import (
50
+ Document,
51
+ DocumentFilters,
52
+ DocumentHit,
53
+ HealthStatus,
54
+ VectorFilters,
55
+ VectorHit,
56
+ VectorItem,
57
+ )
58
+
59
+ T = TypeVar("T")
60
+
61
+ DEFAULT_DB_PATH = "~/.local/share/astrocyte/astrocyte.db"
62
+
63
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
64
+ _US = timedelta(microseconds=1)
65
+
66
+ # PostgreSQL's ``english`` text-search stopword list (snowball english.stop).
67
+ # plainto_tsquery('english', q) discards these before ANDing the remaining
68
+ # terms; FTS5 would otherwise require every one of them to appear.
69
+ _ENGLISH_STOPWORDS = frozenset(
70
+ """
71
+ i me my myself we our ours ourselves you your yours yourself yourselves he
72
+ him his himself she her hers herself it its itself they them their theirs
73
+ themselves what which who whom this that these those am is are was were be
74
+ been being have has had having do does did doing a an the and but if or
75
+ because as until while of at by for with about against between into through
76
+ during before after above below to from up down in out on off over under
77
+ again further then once here there when where why how all any both each few
78
+ more most other some such no nor not only own same so than too very s t can
79
+ will just don should now
80
+ """.split()
81
+ )
82
+
83
+ _TOKEN_RE = re.compile(r"\w+", re.UNICODE)
84
+
85
+ _SCHEMA = """
86
+ CREATE TABLE IF NOT EXISTS astrocyte_meta (
87
+ key TEXT PRIMARY KEY,
88
+ value TEXT NOT NULL
89
+ );
90
+ CREATE TABLE IF NOT EXISTS astrocyte_vectors (
91
+ pk INTEGER PRIMARY KEY,
92
+ id TEXT NOT NULL UNIQUE,
93
+ bank_id TEXT NOT NULL,
94
+ embedding BLOB NOT NULL,
95
+ text TEXT NOT NULL,
96
+ metadata TEXT,
97
+ tags TEXT,
98
+ fact_type TEXT,
99
+ occurred_at INTEGER,
100
+ memory_layer TEXT,
101
+ retained_at INTEGER NOT NULL,
102
+ forgotten_at INTEGER,
103
+ chunk_id TEXT
104
+ );
105
+ CREATE INDEX IF NOT EXISTS astrocyte_vectors_bank_live
106
+ ON astrocyte_vectors (bank_id, forgotten_at);
107
+ CREATE INDEX IF NOT EXISTS astrocyte_vectors_bank_chunk
108
+ ON astrocyte_vectors (bank_id, chunk_id);
109
+ """
110
+
111
+ _FTS_SCHEMA = """
112
+ CREATE VIRTUAL TABLE IF NOT EXISTS astrocyte_vectors_fts USING fts5(
113
+ text, content='astrocyte_vectors', content_rowid='pk',
114
+ tokenize='porter unicode61'
115
+ );
116
+ CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_ai
117
+ AFTER INSERT ON astrocyte_vectors BEGIN
118
+ INSERT INTO astrocyte_vectors_fts(rowid, text) VALUES (new.pk, new.text);
119
+ END;
120
+ CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_ad
121
+ AFTER DELETE ON astrocyte_vectors BEGIN
122
+ INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rowid, text)
123
+ VALUES ('delete', old.pk, old.text);
124
+ END;
125
+ CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_au
126
+ AFTER UPDATE OF text ON astrocyte_vectors BEGIN
127
+ INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rowid, text)
128
+ VALUES ('delete', old.pk, old.text);
129
+ INSERT INTO astrocyte_vectors_fts(rowid, text) VALUES (new.pk, new.text);
130
+ END;
131
+ """
132
+
133
+ _HIT_COLUMNS = "id, text, metadata, tags, fact_type, occurred_at, memory_layer, retained_at, chunk_id"
134
+ _ITEM_COLUMNS = "id, bank_id, embedding, text, metadata, tags, fact_type, occurred_at, memory_layer, retained_at"
135
+
136
+
137
+ # ── value conversion ─────────────────────────────────────────────────────
138
+
139
+
140
+ def _to_us(dt: datetime | None) -> int | None:
141
+ """Exact integer microseconds since the epoch (naive datetimes are UTC,
142
+ as ``timestamptz`` treats them under a UTC session)."""
143
+ if dt is None:
144
+ return None
145
+ if dt.tzinfo is None:
146
+ dt = dt.replace(tzinfo=UTC)
147
+ return (dt - _EPOCH) // _US
148
+
149
+
150
+ def _from_us(us: int | None) -> datetime | None:
151
+ return None if us is None else _EPOCH + timedelta(microseconds=us)
152
+
153
+
154
+ def _now_us() -> int:
155
+ return _to_us(datetime.now(UTC)) # type: ignore[return-value]
156
+
157
+
158
+ def _encode_vector(vec: list[float]) -> bytes:
159
+ return np.asarray(vec, dtype=np.float32).tobytes()
160
+
161
+
162
+ def _decode_vector(blob: bytes) -> list[float]:
163
+ return np.frombuffer(blob, dtype=np.float32).tolist()
164
+
165
+
166
+ def _encode_tags(tags: list[str] | None) -> str | None:
167
+ return json.dumps(list(tags)) if tags is not None else None
168
+
169
+
170
+ def _decode_tags(raw: str | None) -> list[str] | None:
171
+ # PostgresStore returns None for both NULL and the empty array.
172
+ if not raw:
173
+ return None
174
+ tags = json.loads(raw)
175
+ return list(tags) if tags else None
176
+
177
+
178
+ def _decode_metadata(raw: str | None) -> Any:
179
+ return json.loads(raw) if raw is not None else None
180
+
181
+
182
+ def _keyword_terms(query: str) -> list[str]:
183
+ """Terms plainto_tsquery('english', …) would keep, in query order."""
184
+ seen: set[str] = set()
185
+ terms: list[str] = []
186
+ for tok in _TOKEN_RE.findall(query.lower()):
187
+ if tok in _ENGLISH_STOPWORDS or tok in seen:
188
+ continue
189
+ seen.add(tok)
190
+ terms.append(tok)
191
+ return terms
192
+
193
+
194
+ def _placeholders(n: int) -> str:
195
+ return ",".join("?" * n)
196
+
197
+
198
+ # ── store ────────────────────────────────────────────────────────────────
199
+
200
+
201
+ class SqliteStore:
202
+ """VectorStore + DocumentStore over a single SQLite file.
203
+
204
+ Args:
205
+ path: Database file. Defaults to ``ASTROCYTE_SQLITE_PATH`` or
206
+ ``~/.local/share/astrocyte/astrocyte.db``. Parent directories are
207
+ created. ``:memory:`` is rejected: every call opens its own
208
+ connection, so an in-memory database would vanish between calls.
209
+ embedding_dimensions: Expected vector length. When omitted, the first
210
+ write records it and every later write/query is checked against
211
+ it, so switching embedding models fails loudly instead of silently
212
+ mixing incomparable vectors.
213
+ busy_timeout_ms: How long a writer waits for another process's lock.
214
+ """
215
+
216
+ SPI_VERSION: ClassVar[int] = 1
217
+
218
+ def __init__(
219
+ self,
220
+ path: str | None = None,
221
+ embedding_dimensions: int | None = None,
222
+ busy_timeout_ms: int = 10_000,
223
+ **_: Any,
224
+ ) -> None:
225
+ raw = path or os.environ.get("ASTROCYTE_SQLITE_PATH") or DEFAULT_DB_PATH
226
+ if raw.strip() == ":memory:":
227
+ raise ValueError(
228
+ "SqliteStore does not support ':memory:' (each call opens its own "
229
+ "connection). Use a file path, or the in_memory store for tests."
230
+ )
231
+ self._path = str(Path(raw).expanduser())
232
+ self._configured_dim = int(embedding_dimensions) if embedding_dimensions else None
233
+ self._busy_timeout_ms = int(busy_timeout_ms)
234
+ self._schema_lock = threading.Lock()
235
+ self._schema_ready = False
236
+ self._fts = False
237
+ self._dim: int | None = self._configured_dim
238
+
239
+ # ── plumbing ──────────────────────────────────────────────────────
240
+
241
+ @property
242
+ def path(self) -> str:
243
+ return self._path
244
+
245
+ @property
246
+ def fts_enabled(self) -> bool:
247
+ self._ensure_schema()
248
+ return self._fts
249
+
250
+ def _connect(self) -> sqlite3.Connection:
251
+ # isolation_level=None: autocommit, so transactions are explicit.
252
+ conn = sqlite3.connect(self._path, timeout=self._busy_timeout_ms / 1000, isolation_level=None)
253
+ conn.row_factory = sqlite3.Row
254
+ conn.execute(f"PRAGMA busy_timeout = {self._busy_timeout_ms}")
255
+ return conn
256
+
257
+ def _ensure_schema(self) -> None:
258
+ if self._schema_ready:
259
+ return
260
+ with self._schema_lock:
261
+ if self._schema_ready:
262
+ return
263
+ Path(self._path).parent.mkdir(parents=True, exist_ok=True)
264
+ conn = self._connect()
265
+ try:
266
+ # WAL persists in the file: readers never block the writer, and
267
+ # concurrent hook processes don't trip "database is locked".
268
+ conn.execute("PRAGMA journal_mode = WAL")
269
+ conn.executescript(_SCHEMA)
270
+ try:
271
+ conn.executescript(_FTS_SCHEMA)
272
+ self._fts = True
273
+ except sqlite3.OperationalError:
274
+ self._fts = False # SQLite built without FTS5 → LIKE fallback
275
+ row = conn.execute("SELECT value FROM astrocyte_meta WHERE key = 'embedding_dimensions'").fetchone()
276
+ recorded = int(row["value"]) if row else None
277
+ if recorded and self._configured_dim and recorded != self._configured_dim:
278
+ raise ValueError(
279
+ f"{self._path} holds {recorded}-dim embeddings but "
280
+ f"embedding_dimensions={self._configured_dim} is configured. "
281
+ "Use the embedding model that built this store, or a new file."
282
+ )
283
+ self._dim = self._configured_dim or recorded
284
+ finally:
285
+ conn.close()
286
+ self._schema_ready = True
287
+
288
+ async def _run(self, fn: Callable[..., T], *args: Any) -> T:
289
+ return await asyncio.to_thread(fn, *args)
290
+
291
+ def _check_dim(self, n: int, what: str) -> None:
292
+ if self._dim is not None and n != self._dim:
293
+ raise ValueError(f"{what} length {n} != embedding_dimensions {self._dim}")
294
+
295
+ @staticmethod
296
+ def _live_filters(filters: VectorFilters | None, *, with_time_range: bool) -> tuple[list[str], list[Any]]:
297
+ """WHERE clauses shared by search/list paths, mirroring PostgresStore.
298
+
299
+ ``with_time_range`` reproduces a deliberate asymmetry in the reference:
300
+ ``list_recent_vectors`` honours ``time_range`` but ``search_similar``
301
+ does not. Neither applies ``session_id`` or ``metadata_filters``.
302
+ """
303
+ where: list[str] = []
304
+ params: list[Any] = []
305
+ if filters and filters.as_of:
306
+ as_of = _to_us(filters.as_of)
307
+ where.append("retained_at <= ?")
308
+ where.append("(forgotten_at IS NULL OR forgotten_at > ?)")
309
+ params.extend([as_of, as_of])
310
+ else:
311
+ where.append("forgotten_at IS NULL")
312
+ if filters and filters.tags:
313
+ where.append(
314
+ "EXISTS (SELECT 1 FROM json_each(astrocyte_vectors.tags) "
315
+ f"WHERE json_each.value IN ({_placeholders(len(filters.tags))}))"
316
+ )
317
+ params.extend(filters.tags)
318
+ if filters and filters.fact_types:
319
+ where.append(f"fact_type IN ({_placeholders(len(filters.fact_types))})")
320
+ params.extend(filters.fact_types)
321
+ if with_time_range and filters and filters.time_range:
322
+ start, end = filters.time_range
323
+ where.append("occurred_at >= ?")
324
+ where.append("occurred_at <= ?")
325
+ params.extend([_to_us(start), _to_us(end)])
326
+ return where, params
327
+
328
+ @staticmethod
329
+ def _row_to_hit(row: sqlite3.Row, score: float) -> VectorHit:
330
+ return VectorHit(
331
+ id=row["id"],
332
+ text=row["text"],
333
+ score=score,
334
+ metadata=_decode_metadata(row["metadata"]),
335
+ tags=_decode_tags(row["tags"]),
336
+ fact_type=row["fact_type"],
337
+ occurred_at=_from_us(row["occurred_at"]),
338
+ memory_layer=row["memory_layer"],
339
+ retained_at=_from_us(row["retained_at"]),
340
+ chunk_id=row["chunk_id"],
341
+ )
342
+
343
+ @staticmethod
344
+ def _row_to_item(row: sqlite3.Row) -> VectorItem:
345
+ # Mirrors PostgresStore, whose list paths do not return chunk_id.
346
+ return VectorItem(
347
+ id=row["id"],
348
+ bank_id=row["bank_id"],
349
+ vector=_decode_vector(row["embedding"]),
350
+ text=row["text"],
351
+ metadata=_decode_metadata(row["metadata"]),
352
+ tags=_decode_tags(row["tags"]),
353
+ fact_type=row["fact_type"],
354
+ occurred_at=_from_us(row["occurred_at"]),
355
+ memory_layer=row["memory_layer"],
356
+ retained_at=_from_us(row["retained_at"]),
357
+ )
358
+
359
+ # ── VectorStore ───────────────────────────────────────────────────
360
+
361
+ async def store_vectors(self, items: list[VectorItem]) -> list[str]:
362
+ return await self._run(self._store_vectors, items)
363
+
364
+ def _store_vectors(self, items: list[VectorItem]) -> list[str]:
365
+ self._ensure_schema()
366
+ if not items:
367
+ return []
368
+ dim = self._dim or len(items[0].vector)
369
+ # Validate the whole batch before writing: a bad item fails the batch
370
+ # atomically, as the reference's single transaction does.
371
+ for item in items:
372
+ if len(item.vector) != dim:
373
+ raise ValueError(f"Vector length {len(item.vector)} != embedding_dimensions {dim}")
374
+ conn = self._connect()
375
+ try:
376
+ conn.execute("BEGIN IMMEDIATE")
377
+ try:
378
+ if self._dim is None:
379
+ # Another process may have recorded a dimension first.
380
+ row = conn.execute("SELECT value FROM astrocyte_meta WHERE key = 'embedding_dimensions'").fetchone()
381
+ if row and int(row["value"]) != dim:
382
+ raise ValueError(f"Vector length {dim} != embedding_dimensions {row['value']}")
383
+ conn.execute(
384
+ "INSERT OR IGNORE INTO astrocyte_meta(key, value) VALUES ('embedding_dimensions', ?)",
385
+ (str(dim),),
386
+ )
387
+ for item in items:
388
+ conn.execute(
389
+ """
390
+ INSERT INTO astrocyte_vectors (
391
+ id, bank_id, embedding, text, metadata, tags, fact_type,
392
+ occurred_at, memory_layer, retained_at, chunk_id, forgotten_at
393
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
394
+ ON CONFLICT(id) DO UPDATE SET
395
+ bank_id = excluded.bank_id,
396
+ embedding = excluded.embedding,
397
+ text = excluded.text,
398
+ metadata = excluded.metadata,
399
+ tags = excluded.tags,
400
+ fact_type = excluded.fact_type,
401
+ occurred_at = excluded.occurred_at,
402
+ memory_layer = excluded.memory_layer,
403
+ retained_at = excluded.retained_at,
404
+ chunk_id = excluded.chunk_id,
405
+ forgotten_at = NULL
406
+ """,
407
+ (
408
+ item.id,
409
+ item.bank_id,
410
+ _encode_vector(item.vector),
411
+ item.text,
412
+ json.dumps(item.metadata) if item.metadata is not None else None,
413
+ _encode_tags(item.tags),
414
+ item.fact_type,
415
+ _to_us(item.occurred_at),
416
+ item.memory_layer,
417
+ _to_us(item.retained_at) if item.retained_at else _now_us(),
418
+ item.chunk_id,
419
+ ),
420
+ )
421
+ conn.execute("COMMIT")
422
+ except BaseException:
423
+ conn.execute("ROLLBACK")
424
+ raise
425
+ finally:
426
+ conn.close()
427
+ self._dim = dim
428
+ return [item.id for item in items]
429
+
430
+ async def search_similar(
431
+ self,
432
+ query_vector: list[float],
433
+ bank_id: str,
434
+ limit: int = 10,
435
+ filters: VectorFilters | None = None,
436
+ ) -> list[VectorHit]:
437
+ return await self._run(self._search_similar, query_vector, bank_id, limit, filters)
438
+
439
+ def _search_similar(
440
+ self,
441
+ query_vector: list[float],
442
+ bank_id: str,
443
+ limit: int,
444
+ filters: VectorFilters | None,
445
+ ) -> list[VectorHit]:
446
+ self._ensure_schema()
447
+ self._check_dim(len(query_vector), "Query vector")
448
+ if limit <= 0:
449
+ return []
450
+ where, params = self._live_filters(filters, with_time_range=False)
451
+ conn = self._connect()
452
+ try:
453
+ # Phase 1: score only (pk, embedding). Phase 2: hydrate the winners.
454
+ rows = conn.execute(
455
+ f"SELECT pk, embedding FROM astrocyte_vectors WHERE bank_id = ? AND {' AND '.join(where)}",
456
+ [bank_id, *params],
457
+ ).fetchall()
458
+ if not rows:
459
+ return []
460
+ pks = np.fromiter((r["pk"] for r in rows), dtype=np.int64, count=len(rows))
461
+ matrix = np.frombuffer(b"".join(r["embedding"] for r in rows), dtype=np.float32)
462
+ matrix = matrix.reshape(len(rows), -1)
463
+ # Rank on the raw cosine and clamp only the reported score — the
464
+ # reference ORDER BYs the unclamped distance, so every negatively
465
+ # correlated candidate (all reported as 0.0) still has a true order.
466
+ raw = _cosine_raw(np.asarray(query_vector, dtype=np.float32), matrix)
467
+ rank = {int(pks[i]): float(raw[i]) for i in _top_k(raw, limit)}
468
+ hydrated = conn.execute(
469
+ f"SELECT pk, {_HIT_COLUMNS} FROM astrocyte_vectors WHERE pk IN ({_placeholders(len(rank))})",
470
+ list(rank),
471
+ ).fetchall()
472
+ finally:
473
+ conn.close()
474
+ # Best raw similarity first; id breaks exact ties deterministically
475
+ # (the reference leaves tie order to the planner).
476
+ hydrated.sort(key=lambda r: (-rank[r["pk"]], r["id"]))
477
+ return [self._row_to_hit(r, _clamp(rank[r["pk"]])) for r in hydrated]
478
+
479
+ async def delete(self, ids: list[str], bank_id: str) -> int:
480
+ return await self._run(self._delete, ids, bank_id)
481
+
482
+ def _delete(self, ids: list[str], bank_id: str) -> int:
483
+ """Soft delete — sets ``forgotten_at`` so ``as_of`` time travel still
484
+ sees the memory as it was, exactly like the reference."""
485
+ if not ids:
486
+ return 0
487
+ self._ensure_schema()
488
+ conn = self._connect()
489
+ try:
490
+ conn.execute("BEGIN IMMEDIATE")
491
+ cur = conn.execute(
492
+ f"UPDATE astrocyte_vectors SET forgotten_at = ? "
493
+ f"WHERE bank_id = ? AND forgotten_at IS NULL "
494
+ f"AND id IN ({_placeholders(len(ids))})",
495
+ [_now_us(), bank_id, *ids],
496
+ )
497
+ conn.execute("COMMIT")
498
+ return cur.rowcount or 0
499
+ finally:
500
+ conn.close()
501
+
502
+ async def purge(self, bank_id: str, ids: list[str] | None = None) -> int:
503
+ """Erase forgotten memories from disk; returns how many were erased.
504
+
505
+ ``delete`` is a soft delete (the reference's semantics: ``as_of``
506
+ still sees the past), so a forgotten memory's text stays in the file.
507
+ When a user removes something — a pasted key, a remark they regret —
508
+ it has to actually go. Only already-forgotten rows are erased (all of
509
+ the bank's, or those of ``ids``), so purge never bypasses forget.
510
+
511
+ Not part of the VectorStore SPI; ``astrocyte memory forget`` calls it
512
+ where available. FTS entries are removed outright (FTS5
513
+ ``secure-delete``, SQLite 3.42+), the file is rebuilt (``VACUUM``,
514
+ with ``secure_delete``) and the WAL checkpointed, so no copy survives
515
+ in the database files. The rebuild is O(store size): seconds for a
516
+ local store, which is the trade for an erase that is one.
517
+ """
518
+ return await self._run(self._purge, bank_id, ids)
519
+
520
+ def _purge(self, bank_id: str, ids: list[str] | None) -> int:
521
+ self._ensure_schema()
522
+ conn = self._connect()
523
+ try:
524
+ conn.execute("PRAGMA secure_delete = ON")
525
+ if self._fts:
526
+ try:
527
+ conn.execute(
528
+ "INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rank) VALUES('secure-delete', 1)"
529
+ )
530
+ except sqlite3.OperationalError:
531
+ pass # older SQLite: deleted terms linger in the index until segments merge
532
+ sql = "DELETE FROM astrocyte_vectors WHERE bank_id = ? AND forgotten_at IS NOT NULL"
533
+ params: list[Any] = [bank_id]
534
+ if ids is not None:
535
+ if not ids:
536
+ return 0
537
+ sql += f" AND id IN ({_placeholders(len(ids))})"
538
+ params += ids
539
+ conn.execute("BEGIN IMMEDIATE")
540
+ erased = conn.execute(sql, params).rowcount or 0
541
+ conn.execute("COMMIT")
542
+ if erased:
543
+ # The soft delete rewrote the row without secure_delete,
544
+ # leaving its old bytes in the page's free space; only a
545
+ # rebuild is sure to drop every stale copy.
546
+ conn.execute("VACUUM")
547
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
548
+ return erased
549
+ finally:
550
+ conn.close()
551
+
552
+ async def list_vectors(self, bank_id: str, offset: int = 0, limit: int = 100) -> list[VectorItem]:
553
+ return await self._run(self._list_vectors, bank_id, offset, limit)
554
+
555
+ def _list_vectors(self, bank_id: str, offset: int, limit: int) -> list[VectorItem]:
556
+ self._ensure_schema()
557
+ conn = self._connect()
558
+ try:
559
+ rows = conn.execute(
560
+ f"SELECT {_ITEM_COLUMNS} FROM astrocyte_vectors "
561
+ "WHERE bank_id = ? AND forgotten_at IS NULL "
562
+ "ORDER BY id LIMIT ? OFFSET ?",
563
+ (bank_id, limit, offset),
564
+ ).fetchall()
565
+ finally:
566
+ conn.close()
567
+ return [self._row_to_item(r) for r in rows]
568
+
569
+ async def list_recent_vectors(
570
+ self, bank_id: str, limit: int = 100, filters: VectorFilters | None = None
571
+ ) -> list[VectorItem]:
572
+ return await self._run(self._list_recent_vectors, bank_id, limit, filters)
573
+
574
+ def _list_recent_vectors(self, bank_id: str, limit: int, filters: VectorFilters | None) -> list[VectorItem]:
575
+ self._ensure_schema()
576
+ where, params = self._live_filters(filters, with_time_range=True)
577
+ conn = self._connect()
578
+ try:
579
+ rows = conn.execute(
580
+ f"SELECT {_ITEM_COLUMNS} FROM astrocyte_vectors "
581
+ f"WHERE bank_id = ? AND {' AND '.join(where)} "
582
+ "ORDER BY COALESCE(occurred_at, retained_at) DESC, id LIMIT ?",
583
+ [bank_id, *params, limit],
584
+ ).fetchall()
585
+ finally:
586
+ conn.close()
587
+ return [self._row_to_item(r) for r in rows]
588
+
589
+ async def list_banks(self) -> list[tuple[str, int, datetime | None]]:
590
+ """Every bank holding live memories: ``(bank_id, count, newest)``.
591
+
592
+ Not part of the VectorStore SPI; ``astrocyte memory banks`` uses it
593
+ where available, since a local store has no other bank registry.
594
+ """
595
+ return await self._run(self._list_banks)
596
+
597
+ def _list_banks(self) -> list[tuple[str, int, datetime | None]]:
598
+ self._ensure_schema()
599
+ conn = self._connect()
600
+ try:
601
+ rows = conn.execute(
602
+ "SELECT bank_id, COUNT(*), MAX(COALESCE(occurred_at, retained_at)) FROM astrocyte_vectors "
603
+ "WHERE forgotten_at IS NULL GROUP BY bank_id ORDER BY 3 DESC"
604
+ ).fetchall()
605
+ finally:
606
+ conn.close()
607
+ return [(r[0], r[1], _from_us(r[2])) for r in rows]
608
+
609
+ async def get_by_chunk_ids(self, chunk_ids: list[str], bank_id: str) -> list[VectorHit]:
610
+ return await self._run(self._get_by_chunk_ids, chunk_ids, bank_id)
611
+
612
+ def _get_by_chunk_ids(self, chunk_ids: list[str], bank_id: str) -> list[VectorHit]:
613
+ if not chunk_ids:
614
+ return []
615
+ self._ensure_schema()
616
+ conn = self._connect()
617
+ try:
618
+ rows = conn.execute(
619
+ f"SELECT {_HIT_COLUMNS} FROM astrocyte_vectors "
620
+ "WHERE bank_id = ? AND forgotten_at IS NULL "
621
+ f"AND chunk_id IN ({_placeholders(len(chunk_ids))}) ORDER BY id",
622
+ [bank_id, *chunk_ids],
623
+ ).fetchall()
624
+ finally:
625
+ conn.close()
626
+ return [self._row_to_hit(r, 1.0) for r in rows]
627
+
628
+ async def close(self) -> None:
629
+ """No pooled resources: every call opens and closes its own connection."""
630
+
631
+ async def health(self) -> HealthStatus:
632
+ return await self._run(self._health)
633
+
634
+ def _health(self) -> HealthStatus:
635
+ started = time.perf_counter()
636
+ try:
637
+ self._ensure_schema()
638
+ conn = self._connect()
639
+ try:
640
+ conn.execute("SELECT 1").fetchone()
641
+ finally:
642
+ conn.close()
643
+ except Exception as e: # noqa: BLE001 — health reports, never raises
644
+ return HealthStatus(healthy=False, message=f"sqlite unhealthy ({self._path}): {e!s}")
645
+ mode = "fts5" if self._fts else "LIKE fallback (no FTS5)"
646
+ return HealthStatus(
647
+ healthy=True,
648
+ message=f"sqlite {self._path} — keyword search: {mode}",
649
+ latency_ms=(time.perf_counter() - started) * 1000,
650
+ last_check_at=datetime.now(UTC),
651
+ )
652
+
653
+ # ── DocumentStore ─────────────────────────────────────────────────
654
+ # Same arrangement as PostgresStore: memory text is already stored by
655
+ # store_vectors(), and the FTS5 triggers keep the index in sync, so the
656
+ # DocumentStore methods read the same table.
657
+
658
+ async def store_document(self, document: Document, bank_id: str) -> str:
659
+ """No-op: text is indexed at retain time by store_vectors()."""
660
+ return document.id
661
+
662
+ async def search_fulltext(
663
+ self,
664
+ query: str,
665
+ bank_id: str,
666
+ limit: int = 10,
667
+ filters: DocumentFilters | None = None,
668
+ ) -> list[DocumentHit]:
669
+ return await self._run(self._search_fulltext, query, bank_id, limit, filters)
670
+
671
+ def _search_fulltext(
672
+ self,
673
+ query: str,
674
+ bank_id: str,
675
+ limit: int,
676
+ filters: DocumentFilters | None,
677
+ ) -> list[DocumentHit]:
678
+ if not query or not query.strip():
679
+ return []
680
+ terms = _keyword_terms(query)
681
+ if not terms:
682
+ return [] # all stopwords: plainto_tsquery yields an empty query
683
+ self._ensure_schema()
684
+ tag_sql, tag_params = "", []
685
+ if filters and filters.tags:
686
+ tag_sql = (
687
+ " AND EXISTS (SELECT 1 FROM json_each(v.tags) "
688
+ f"WHERE json_each.value IN ({_placeholders(len(filters.tags))}))"
689
+ )
690
+ tag_params = list(filters.tags)
691
+ conn = self._connect()
692
+ try:
693
+ if self._fts:
694
+ match = " AND ".join('"' + t.replace('"', '""') + '"' for t in terms)
695
+ rows = conn.execute(
696
+ "SELECT v.id, v.text, v.metadata, -bm25(astrocyte_vectors_fts) AS score "
697
+ "FROM astrocyte_vectors_fts "
698
+ "JOIN astrocyte_vectors v ON v.pk = astrocyte_vectors_fts.rowid "
699
+ "WHERE astrocyte_vectors_fts MATCH ? AND v.bank_id = ? "
700
+ f"AND v.forgotten_at IS NULL{tag_sql} "
701
+ "ORDER BY score DESC, v.id LIMIT ?",
702
+ [match, bank_id, *tag_params, limit],
703
+ ).fetchall()
704
+ else:
705
+ rows = self._like_search(conn, terms, bank_id, tag_sql, tag_params, limit)
706
+ finally:
707
+ conn.close()
708
+ return [
709
+ DocumentHit(
710
+ document_id=r["id"],
711
+ text=r["text"],
712
+ score=float(r["score"]),
713
+ metadata=_decode_metadata(r["metadata"]),
714
+ )
715
+ for r in rows
716
+ ]
717
+
718
+ @staticmethod
719
+ def _like_search(
720
+ conn: sqlite3.Connection,
721
+ terms: list[str],
722
+ bank_id: str,
723
+ tag_sql: str,
724
+ tag_params: list[Any],
725
+ limit: int,
726
+ ) -> list[sqlite3.Row]:
727
+ """Degraded keyword search for SQLite builds without FTS5: every term
728
+ must appear (no stemming); ranked by matched-term density."""
729
+ likes = " AND ".join("lower(v.text) LIKE ?" for _ in terms)
730
+ score = " + ".join("(length(lower(v.text)) - length(replace(lower(v.text), ?, ''))) / length(?)" for _ in terms)
731
+ score_params: list[Any] = []
732
+ for t in terms:
733
+ score_params.extend([t, t])
734
+ return conn.execute(
735
+ f"SELECT v.id, v.text, v.metadata, "
736
+ f"CAST(({score}) AS REAL) / (length(v.text) + 1) AS score "
737
+ f"FROM astrocyte_vectors v WHERE v.bank_id = ? AND v.forgotten_at IS NULL "
738
+ f"AND {likes}{tag_sql} ORDER BY score DESC, v.id LIMIT ?",
739
+ [*score_params, bank_id, *(f"%{t}%" for t in terms), *tag_params, limit],
740
+ ).fetchall()
741
+
742
+ async def get_document(self, document_id: str, bank_id: str) -> Document | None:
743
+ return await self._run(self._get_document, document_id, bank_id)
744
+
745
+ def _get_document(self, document_id: str, bank_id: str) -> Document | None:
746
+ self._ensure_schema()
747
+ conn = self._connect()
748
+ try:
749
+ row = conn.execute(
750
+ "SELECT id, text, metadata, tags FROM astrocyte_vectors "
751
+ "WHERE id = ? AND bank_id = ? AND forgotten_at IS NULL",
752
+ (document_id, bank_id),
753
+ ).fetchone()
754
+ finally:
755
+ conn.close()
756
+ if row is None:
757
+ return None
758
+ return Document(
759
+ id=row["id"],
760
+ text=row["text"],
761
+ metadata=_decode_metadata(row["metadata"]),
762
+ tags=_decode_tags(row["tags"]),
763
+ )
764
+
765
+
766
+ # ── numerics ─────────────────────────────────────────────────────────────
767
+
768
+
769
+ # Below any real cosine (>= -1), so zero-norm rows rank last — where pgvector's
770
+ # NaN distance puts them under ORDER BY.
771
+ _UNRANKABLE = -2.0
772
+
773
+
774
+ def _cosine_raw(query: np.ndarray, matrix: np.ndarray) -> np.ndarray:
775
+ """Unclamped cosine similarity, i.e. ``1 - cosine_distance``.
776
+
777
+ A zero vector has no direction: pgvector returns NaN distance for it, which
778
+ sorts last. Those rows (and every row, for a zero query) get
779
+ ``_UNRANKABLE`` and report a score of 0.0 rather than NaN.
780
+ """
781
+ q_norm = float(np.linalg.norm(query))
782
+ if q_norm == 0.0:
783
+ return np.full(matrix.shape[0], _UNRANKABLE, dtype=np.float64)
784
+ wide = matrix.astype(np.float64)
785
+ row_norms = np.linalg.norm(wide, axis=1)
786
+ dots = wide @ query.astype(np.float64)
787
+ with np.errstate(divide="ignore", invalid="ignore"):
788
+ return np.where(row_norms > 0, dots / (row_norms * q_norm), _UNRANKABLE)
789
+
790
+
791
+ def _clamp(raw: float) -> float:
792
+ """The reported score: the reference clamps ``1 - distance`` to [0, 1]."""
793
+ return min(max(raw, 0.0), 1.0)
794
+
795
+
796
+ def _top_k(scores: np.ndarray, k: int) -> Iterable[int]:
797
+ if k >= scores.shape[0]:
798
+ return range(scores.shape[0])
799
+ return np.argpartition(-scores, k - 1)[:k].tolist()
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: astrocyte-sqlite
3
+ Version: 0.16.0
4
+ Summary: SQLite adapter for Astrocyte (vector + document stores in one local file; zero infrastructure)
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: astrocyte<2,>=0.15.0
8
+ Requires-Dist: numpy>=1.26
9
+ Provides-Extra: dev
10
+ Requires-Dist: astrocyte-postgres; extra == 'dev'
11
+ Requires-Dist: astrocyte[mcp]; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # astrocyte-sqlite
18
+
19
+ Zero-infrastructure storage for Astrocyte: every memory in one SQLite file.
20
+ No server, no Docker, no native extensions — `pip install` and retain.
21
+
22
+ ```yaml
23
+ # astrocyte.yaml
24
+ provider_tier: storage
25
+ vector_store: sqlite
26
+ vector_store_config:
27
+ path: ~/.local/share/astrocyte/astrocyte.db # default; or ASTROCYTE_SQLITE_PATH
28
+ ```
29
+
30
+ `SqliteStore` satisfies both the `VectorStore` and `DocumentStore` protocols,
31
+ so the pipeline's keyword retrieval leg turns on automatically (the same
32
+ auto-wire `PostgresStore` gets).
33
+
34
+ ## Behaves like Postgres, deliberately
35
+
36
+ Semantics match `astrocyte-postgres`, not the in-memory test store, because
37
+ Postgres is what users deploy and what every benchmark measures:
38
+
39
+ | Behaviour | `SqliteStore` / `PostgresStore` |
40
+ |---|---|
41
+ | `delete` | soft delete (`forgotten_at`); `as_of` time travel still sees it |
42
+ | re-storing a deleted id | resurrects it |
43
+ | `tags` / `fact_types` filters | items without tags / type are excluded |
44
+ | similarity score | cosine, clamped to [0, 1] |
45
+ | `search_similar` + `time_range` | not applied (`list_recent_vectors` applies it) |
46
+ | batch with a wrong-length vector | whole batch rejected |
47
+ | keyword query | English stopwords dropped, remaining terms ANDed, stemmed |
48
+
49
+ `tests/test_parity_postgres.py` runs identical operations against both
50
+ backends and fails on any divergence. Known, accepted differences: keyword
51
+ *ranking* (FTS5 `bm25` vs `ts_rank_cd` — the pipeline fuses by rank, and the
52
+ match set is identical), stemmer edge cases (Porter vs Snowball), and zero
53
+ vectors (score 0.0 here; NaN in pgvector).
54
+
55
+ ## Design
56
+
57
+ - **Exact cosine in numpy over float32 BLOBs.** pgvector stores float4 too.
58
+ `sqlite-vec` was rejected: it needs `enable_load_extension`, which some Python
59
+ builds (including macOS system Python) omit.
60
+ - **FTS5 with a LIKE fallback** for SQLite builds compiled without it.
61
+ `health()` reports which is active.
62
+ - **Safe across processes.** An MCP server and short-lived agent-hook
63
+ processes write the same file: WAL journal, `BEGIN IMMEDIATE` writes, busy
64
+ timeout, a connection per call.
65
+ - **Embedding dimension is pinned on first write.** Switching embedding models
66
+ against an existing file fails loudly instead of mixing incomparable vectors.
67
+ - **Scale.** Exact search is linear in a bank's live memories. Measured on an
68
+ Apple Silicon laptop, median of 15 queries, `limit=50`:
69
+
70
+ | memories | dim | vector query | keyword query | file |
71
+ |---:|---:|---:|---:|---:|
72
+ | 1,000 | 384 | 3.7 ms | 3.5 ms | 2 MB |
73
+ | 10,000 | 384 | 23 ms | 19 ms | 21 MB |
74
+ | 50,000 | 384 | 116 ms | 95 ms | 107 MB |
75
+ | 10,000 | 1536 | 68 ms | 27 ms | 83 MB |
76
+ | 50,000 | 1536 | 420 ms | 143 ms | 414 MB |
77
+
78
+ Use the embedding model's native width: `local_embeddings` with
79
+ `pad_to: null` gives bge-small's 384 dims. Zero-padding to 1536 (needed only
80
+ for Postgres's fixed `vector(1536)` column) costs ~4x in time and disk and
81
+ changes no similarity. Keyword figures are pessimistic: the synthetic corpus
82
+ puts the query terms in nearly every document.
83
+ - **Identifiers sort bytewise.** `list_vectors` orders by `id` in byte order.
84
+ Postgres orders by the database collation; the two agree for the lowercase
85
+ UUIDs Astrocyte generates, but could differ for arbitrary mixed-case ids.
@@ -0,0 +1,6 @@
1
+ astrocyte_sqlite/__init__.py,sha256=JionWmBdpD4jlFZgcD1R93xB0tDjHXdRjVJykfArtnI,440
2
+ astrocyte_sqlite/store.py,sha256=eEytYziCyC6WZ0motGUOiJoP1z2YAhvQJiNt6CHgr3o,33466
3
+ astrocyte_sqlite-0.16.0.dist-info/METADATA,sha256=M6glEkvXiikT7rnbek6CT9e3RTQxYTgNp2GpN_tQPis,3868
4
+ astrocyte_sqlite-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ astrocyte_sqlite-0.16.0.dist-info/entry_points.txt,sha256=74qzezc8ANXUWz4VYVhN6D4GZnox45UtaPj82qYUU90,143
6
+ astrocyte_sqlite-0.16.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [astrocyte.document_stores]
2
+ sqlite = astrocyte_sqlite.store:SqliteStore
3
+
4
+ [astrocyte.vector_stores]
5
+ sqlite = astrocyte_sqlite.store:SqliteStore