contextos-memory-runtime 1.0.0rc2__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.
Files changed (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,497 @@
1
+ """SQLite database management for ContextOS.
2
+
3
+ Handles connection pooling, WAL mode, migrations, and schema setup.
4
+ All database access goes through the Database class, which provides
5
+ the connection to repositories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sqlite3
12
+ from pathlib import Path
13
+
14
+ import aiosqlite
15
+
16
+ from contextos.core.exceptions import MigrationError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Schema SQL — Phase 1 initial schema
22
+ # ---------------------------------------------------------------------------
23
+
24
+ SCHEMA_VERSION = 7
25
+
26
+ SCHEMA_SQL = """
27
+ -- Schema version tracking
28
+ CREATE TABLE IF NOT EXISTS schema_version (
29
+ version INTEGER PRIMARY KEY,
30
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
31
+ description TEXT NOT NULL
32
+ );
33
+
34
+ -- Memories: the core data object
35
+ CREATE TABLE IF NOT EXISTS memories (
36
+ id TEXT PRIMARY KEY,
37
+ content TEXT NOT NULL,
38
+ content_hash TEXT NOT NULL,
39
+ type TEXT NOT NULL DEFAULT 'context',
40
+ source_type TEXT NOT NULL DEFAULT 'cli_input',
41
+ source_uri TEXT,
42
+ provenance_event_id TEXT,
43
+ status TEXT NOT NULL DEFAULT 'candidate',
44
+ confidence REAL NOT NULL DEFAULT 0.8,
45
+ importance REAL NOT NULL DEFAULT 0.5,
46
+ privacy_level TEXT NOT NULL DEFAULT 'personal',
47
+ token_count INTEGER NOT NULL DEFAULT 0,
48
+ embedding_id TEXT,
49
+ superseded_by TEXT,
50
+ supersedes TEXT,
51
+ access_count INTEGER NOT NULL DEFAULT 0,
52
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
53
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
54
+ last_accessed_at TEXT,
55
+ expires_at TEXT,
56
+ version INTEGER NOT NULL DEFAULT 1,
57
+ tags TEXT NOT NULL DEFAULT '[]'
58
+ );
59
+
60
+ -- Indexes for common query patterns
61
+ CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
62
+ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
63
+ CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash);
64
+ CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at);
65
+ CREATE INDEX IF NOT EXISTS idx_memories_privacy_level ON memories(privacy_level);
66
+ CREATE INDEX IF NOT EXISTS idx_memories_source_type ON memories(source_type);
67
+ CREATE INDEX IF NOT EXISTS idx_memories_provenance ON memories(provenance_event_id);
68
+
69
+ -- Memory relations (many-to-many)
70
+ CREATE TABLE IF NOT EXISTS memory_relations (
71
+ id TEXT PRIMARY KEY,
72
+ source_memory_id TEXT NOT NULL,
73
+ target_memory_id TEXT NOT NULL,
74
+ relation_type TEXT NOT NULL,
75
+ confidence REAL NOT NULL DEFAULT 1.0,
76
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
77
+ metadata TEXT NOT NULL DEFAULT '{}',
78
+ FOREIGN KEY (source_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
79
+ FOREIGN KEY (target_memory_id) REFERENCES memories(id) ON DELETE CASCADE
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_relations_source ON memory_relations(source_memory_id);
83
+ CREATE INDEX IF NOT EXISTS idx_relations_target ON memory_relations(target_memory_id);
84
+ CREATE INDEX IF NOT EXISTS idx_relations_type ON memory_relations(relation_type);
85
+
86
+ -- Raw events: append-only audit log
87
+ CREATE TABLE IF NOT EXISTS events (
88
+ id TEXT PRIMARY KEY,
89
+ event_type TEXT NOT NULL,
90
+ timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
91
+ source_type TEXT NOT NULL DEFAULT 'system',
92
+ source_uri TEXT,
93
+ content TEXT,
94
+ content_hash TEXT,
95
+ metadata TEXT NOT NULL DEFAULT '{}',
96
+ privacy_scan_result TEXT,
97
+ memory_ids TEXT NOT NULL DEFAULT '[]'
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
101
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
102
+ CREATE INDEX IF NOT EXISTS idx_events_source_type ON events(source_type);
103
+ CREATE INDEX IF NOT EXISTS idx_events_content_hash ON events(content_hash);
104
+
105
+ -- Pipeline traces
106
+ CREATE TABLE IF NOT EXISTS traces (
107
+ id TEXT PRIMARY KEY,
108
+ trace_type TEXT NOT NULL,
109
+ timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
110
+ query TEXT,
111
+ stages TEXT NOT NULL DEFAULT '[]',
112
+ total_latency_ms REAL NOT NULL DEFAULT 0.0,
113
+ total_input_tokens INTEGER,
114
+ total_output_tokens INTEGER,
115
+ metadata TEXT NOT NULL DEFAULT '{}'
116
+ );
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_traces_timestamp ON traces(timestamp);
119
+ CREATE INDEX IF NOT EXISTS idx_traces_type ON traces(trace_type);
120
+ """
121
+
122
+ MIGRATION_2_SQL = """
123
+ ALTER TABLE memories ADD COLUMN observed_at TEXT;
124
+ ALTER TABLE memories ADD COLUMN valid_from TEXT;
125
+ ALTER TABLE memories ADD COLUMN valid_to TEXT;
126
+ ALTER TABLE memories ADD COLUMN temporal_precision TEXT NOT NULL DEFAULT 'unknown';
127
+ ALTER TABLE memories ADD COLUMN temporal_status TEXT NOT NULL DEFAULT 'unspecified';
128
+ ALTER TABLE memories ADD COLUMN temporal_expression TEXT;
129
+ ALTER TABLE memories ADD COLUMN slot_json TEXT;
130
+ ALTER TABLE memories ADD COLUMN slot_key TEXT;
131
+ ALTER TABLE memories ADD COLUMN uncertain INTEGER NOT NULL DEFAULT 0;
132
+ ALTER TABLE memories ADD COLUMN negated INTEGER NOT NULL DEFAULT 0;
133
+ ALTER TABLE memories ADD COLUMN resolution_reason TEXT;
134
+ ALTER TABLE memories ADD COLUMN resolution_confidence REAL;
135
+ UPDATE memories SET observed_at = created_at WHERE observed_at IS NULL;
136
+ CREATE INDEX IF NOT EXISTS idx_memories_slot_key ON memories(slot_key);
137
+ CREATE INDEX IF NOT EXISTS idx_memories_temporal_status ON memories(temporal_status);
138
+ CREATE INDEX IF NOT EXISTS idx_memories_validity ON memories(valid_from, valid_to);
139
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_unique
140
+ ON memory_relations(source_memory_id, target_memory_id, relation_type);
141
+ """
142
+
143
+ MIGRATION_3_SQL = """
144
+ CREATE TABLE IF NOT EXISTS graph_nodes (
145
+ id TEXT PRIMARY KEY,
146
+ node_type TEXT NOT NULL,
147
+ canonical_key TEXT NOT NULL,
148
+ label TEXT NOT NULL,
149
+ metadata TEXT NOT NULL DEFAULT '{}',
150
+ created_at TEXT NOT NULL,
151
+ updated_at TEXT NOT NULL,
152
+ UNIQUE(node_type, canonical_key)
153
+ );
154
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_key
155
+ ON graph_nodes(canonical_key, node_type);
156
+
157
+ CREATE TABLE IF NOT EXISTS graph_edges (
158
+ id TEXT PRIMARY KEY,
159
+ source_node_id TEXT NOT NULL,
160
+ target_node_id TEXT NOT NULL,
161
+ relation_type TEXT NOT NULL,
162
+ confidence REAL NOT NULL CHECK(confidence >= 0 AND confidence <= 1),
163
+ directed INTEGER NOT NULL DEFAULT 1,
164
+ scope_key TEXT NOT NULL DEFAULT '',
165
+ metadata TEXT NOT NULL DEFAULT '{}',
166
+ created_at TEXT NOT NULL,
167
+ updated_at TEXT NOT NULL,
168
+ FOREIGN KEY (source_node_id) REFERENCES graph_nodes(id) ON DELETE CASCADE,
169
+ FOREIGN KEY (target_node_id) REFERENCES graph_nodes(id) ON DELETE CASCADE,
170
+ UNIQUE(source_node_id, target_node_id, relation_type, scope_key)
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_source ON graph_edges(source_node_id);
173
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_target ON graph_edges(target_node_id);
174
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_type ON graph_edges(relation_type);
175
+
176
+ CREATE TABLE IF NOT EXISTS graph_edge_supports (
177
+ edge_id TEXT NOT NULL,
178
+ memory_id TEXT NOT NULL,
179
+ confidence REAL NOT NULL CHECK(confidence >= 0 AND confidence <= 1),
180
+ provenance_event_id TEXT,
181
+ created_at TEXT NOT NULL,
182
+ PRIMARY KEY(edge_id, memory_id),
183
+ FOREIGN KEY (edge_id) REFERENCES graph_edges(id) ON DELETE CASCADE,
184
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
185
+ );
186
+ CREATE INDEX IF NOT EXISTS idx_graph_support_memory
187
+ ON graph_edge_supports(memory_id);
188
+ """
189
+
190
+ MIGRATION_4_SQL = """
191
+ -- A persisted dirty bit makes graph freshness checks O(1). The graph is a
192
+ -- projection, so a crash before it is marked clean merely causes a safe rebuild.
193
+ CREATE TABLE IF NOT EXISTS graph_projection_state (
194
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
195
+ dirty INTEGER NOT NULL DEFAULT 1 CHECK(dirty IN (0, 1))
196
+ );
197
+ INSERT OR IGNORE INTO graph_projection_state(singleton, dirty) VALUES (1, 1);
198
+
199
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_memory_insert
200
+ AFTER INSERT ON memories BEGIN
201
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
202
+ END;
203
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_memory_update
204
+ AFTER UPDATE ON memories BEGIN
205
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
206
+ END;
207
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_memory_delete
208
+ AFTER DELETE ON memories BEGIN
209
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
210
+ END;
211
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_relation_insert
212
+ AFTER INSERT ON memory_relations BEGIN
213
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
214
+ END;
215
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_relation_update
216
+ AFTER UPDATE ON memory_relations BEGIN
217
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
218
+ END;
219
+ CREATE TRIGGER IF NOT EXISTS trg_graph_dirty_relation_delete
220
+ AFTER DELETE ON memory_relations BEGIN
221
+ UPDATE graph_projection_state SET dirty = 1 WHERE singleton = 1;
222
+ END;
223
+ """
224
+
225
+ MIGRATION_5_SQL = """
226
+ CREATE TABLE IF NOT EXISTS model_invocations (
227
+ id TEXT PRIMARY KEY,
228
+ invocation_id TEXT NOT NULL UNIQUE,
229
+ session_id TEXT,
230
+ provider_id TEXT NOT NULL,
231
+ model_id TEXT NOT NULL,
232
+ is_local INTEGER NOT NULL DEFAULT 1,
233
+ timestamp TEXT NOT NULL,
234
+ candidate_context_tokens INTEGER NOT NULL DEFAULT 0,
235
+ retrieved_context_tokens INTEGER NOT NULL DEFAULT 0,
236
+ optimized_context_tokens INTEGER NOT NULL DEFAULT 0,
237
+ compiled_context_tokens INTEGER NOT NULL DEFAULT 0,
238
+ prompt_tokens_before_context INTEGER NOT NULL DEFAULT 0,
239
+ final_input_tokens INTEGER NOT NULL DEFAULT 0,
240
+ provider_input_tokens INTEGER NOT NULL DEFAULT 0,
241
+ provider_output_tokens INTEGER NOT NULL DEFAULT 0,
242
+ provider_total_tokens INTEGER NOT NULL DEFAULT 0,
243
+ token_measurement_source TEXT NOT NULL,
244
+ context_tokens_avoided INTEGER NOT NULL DEFAULT 0,
245
+ reduction_ratio REAL NOT NULL DEFAULT 0.0,
246
+ lexical_candidate_count INTEGER NOT NULL DEFAULT 0,
247
+ dense_candidate_count INTEGER NOT NULL DEFAULT 0,
248
+ hybrid_candidate_count INTEGER NOT NULL DEFAULT 0,
249
+ graph_expanded_count INTEGER NOT NULL DEFAULT 0,
250
+ temporal_filtered_count INTEGER NOT NULL DEFAULT 0,
251
+ selected_memory_count INTEGER NOT NULL DEFAULT 0,
252
+ compiled_fact_count INTEGER NOT NULL DEFAULT 0,
253
+ retrieval_ms REAL NOT NULL DEFAULT 0.0,
254
+ optimization_ms REAL NOT NULL DEFAULT 0.0,
255
+ compilation_ms REAL NOT NULL DEFAULT 0.0,
256
+ routing_ms REAL NOT NULL DEFAULT 0.0,
257
+ token_counting_ms REAL NOT NULL DEFAULT 0.0,
258
+ provider_latency_ms REAL NOT NULL DEFAULT 0.0,
259
+ end_to_end_ms REAL NOT NULL DEFAULT 0.0,
260
+ routing_policy TEXT NOT NULL,
261
+ routing_reason TEXT NOT NULL,
262
+ selected_provider TEXT NOT NULL,
263
+ selected_model TEXT NOT NULL,
264
+ fallback_used INTEGER NOT NULL DEFAULT 0,
265
+ fallback_reason TEXT,
266
+ finish_reason TEXT NOT NULL DEFAULT 'stop',
267
+ status TEXT NOT NULL DEFAULT 'success',
268
+ error_code TEXT,
269
+ metadata TEXT NOT NULL DEFAULT '{}'
270
+ );
271
+
272
+ CREATE INDEX IF NOT EXISTS idx_invocations_timestamp ON model_invocations(timestamp);
273
+ CREATE INDEX IF NOT EXISTS idx_invocations_provider ON model_invocations(provider_id);
274
+ CREATE INDEX IF NOT EXISTS idx_invocations_model ON model_invocations(model_id);
275
+ CREATE INDEX IF NOT EXISTS idx_invocations_is_local ON model_invocations(is_local);
276
+ """
277
+
278
+ MIGRATION_6_SQL = """
279
+ CREATE TABLE IF NOT EXISTS connector_state (
280
+ connector_id TEXT PRIMARY KEY,
281
+ connector_type TEXT NOT NULL,
282
+ cursor TEXT,
283
+ last_success_at TEXT,
284
+ last_attempt_at TEXT,
285
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
286
+ status TEXT NOT NULL DEFAULT 'idle',
287
+ error_code TEXT
288
+ );
289
+ CREATE TABLE IF NOT EXISTS connector_items (
290
+ connector_id TEXT NOT NULL,
291
+ external_id TEXT NOT NULL,
292
+ revision TEXT NOT NULL,
293
+ content_hash TEXT NOT NULL,
294
+ source_uri TEXT NOT NULL,
295
+ memory_ids TEXT NOT NULL DEFAULT '[]',
296
+ last_seen_at TEXT NOT NULL,
297
+ deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN (0, 1)),
298
+ PRIMARY KEY(connector_id, external_id)
299
+ );
300
+ CREATE INDEX IF NOT EXISTS idx_connector_items_connector ON connector_items(connector_id);
301
+ """
302
+
303
+ MIGRATION_7_SQL = """
304
+ ALTER TABLE model_invocations ADD COLUMN context_token_measurement_source TEXT;
305
+ ALTER TABLE model_invocations ADD COLUMN context_tokenizer TEXT;
306
+ CREATE INDEX IF NOT EXISTS idx_invocations_model_recent ON model_invocations(model_id, timestamp DESC);
307
+ """
308
+
309
+
310
+
311
+ class Database:
312
+ """Manages the SQLite database connection and schema.
313
+
314
+ Usage:
315
+ db = Database(data_dir / "contextos.db")
316
+ await db.initialize()
317
+ # ... use db.connection() to get aiosqlite connection
318
+ await db.close()
319
+ """
320
+
321
+ def __init__(self, db_path: Path) -> None:
322
+ self._db_path = db_path
323
+ self._connection: aiosqlite.Connection | None = None
324
+
325
+ @property
326
+ def path(self) -> Path:
327
+ return self._db_path
328
+
329
+ async def initialize(self) -> None:
330
+ """Open the database, set pragmas, and run schema migrations."""
331
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
332
+
333
+ if self._connection is not None:
334
+ return
335
+ self._connection = await aiosqlite.connect(
336
+ str(self._db_path),
337
+ detect_types=sqlite3.PARSE_DECLTYPES,
338
+ )
339
+
340
+ # Enable WAL mode for concurrent read access and crash resilience
341
+ await self._connection.execute("PRAGMA journal_mode=WAL")
342
+
343
+ # Enable foreign keys
344
+ await self._connection.execute("PRAGMA foreign_keys=ON")
345
+
346
+ # Reasonable busy timeout for concurrent access
347
+ await self._connection.execute("PRAGMA busy_timeout=5000")
348
+
349
+ # Row factory for dict-like access
350
+ self._connection.row_factory = aiosqlite.Row
351
+
352
+ try:
353
+ await self._apply_schema()
354
+ except Exception:
355
+ await self.close()
356
+ raise
357
+ logger.info("Database initialized at %s", self._db_path)
358
+
359
+ async def _apply_schema(self) -> None:
360
+ """Apply the initial schema and ordered incremental migrations."""
361
+ assert self._connection is not None
362
+
363
+ # Check if schema_version table exists
364
+ cursor = await self._connection.execute(
365
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
366
+ )
367
+ table_exists = await cursor.fetchone()
368
+
369
+ if not table_exists:
370
+ try:
371
+ await self._connection.executescript(
372
+ "BEGIN IMMEDIATE;\n" + SCHEMA_SQL
373
+ + "\nINSERT INTO schema_version (version, description) "
374
+ "VALUES (1, 'Initial schema');\nCOMMIT;"
375
+ )
376
+ except Exception as exc:
377
+ await self._connection.rollback()
378
+ raise MigrationError("Failed to initialize database schema") from exc
379
+ current_version = 1
380
+ else:
381
+ cursor = await self._connection.execute(
382
+ "SELECT MAX(version) FROM schema_version"
383
+ )
384
+ row = await cursor.fetchone()
385
+ current_version = row[0] if row and row[0] is not None else 0
386
+
387
+ if current_version > SCHEMA_VERSION:
388
+ raise MigrationError(
389
+ f"Database schema version {current_version} is newer than supported {SCHEMA_VERSION}"
390
+ )
391
+ if current_version < 2:
392
+ try:
393
+ await self._connection.executescript(
394
+ "BEGIN IMMEDIATE;\n" + MIGRATION_2_SQL
395
+ + "\nINSERT INTO schema_version (version, description) "
396
+ "VALUES (2, 'Temporal memory and resolution metadata');\nCOMMIT;"
397
+ )
398
+ except Exception as exc:
399
+ await self._connection.rollback()
400
+ raise MigrationError("Failed to apply schema migration 2") from exc
401
+ current_version = 2
402
+
403
+ if current_version < 3:
404
+ try:
405
+ await self._connection.executescript(
406
+ "BEGIN IMMEDIATE;\n" + MIGRATION_3_SQL
407
+ + "\nINSERT INTO schema_version (version, description) "
408
+ "VALUES (3, 'Memory graph projection and edge provenance');\nCOMMIT;"
409
+ )
410
+ except Exception as exc:
411
+ await self._connection.rollback()
412
+ raise MigrationError("Failed to apply schema migration 3") from exc
413
+ current_version = 3
414
+
415
+ if current_version < 4:
416
+ try:
417
+ await self._connection.executescript(
418
+ "BEGIN IMMEDIATE;\n" + MIGRATION_4_SQL
419
+ + "\nINSERT INTO schema_version (version, description) "
420
+ "VALUES (4, 'Graph projection freshness tracking');\nCOMMIT;"
421
+ )
422
+ except Exception as exc:
423
+ await self._connection.rollback()
424
+ raise MigrationError("Failed to apply schema migration 4") from exc
425
+ current_version = 4
426
+
427
+ if current_version < 5:
428
+ try:
429
+ await self._connection.executescript(
430
+ "BEGIN IMMEDIATE;\n" + MIGRATION_5_SQL
431
+ + "\nINSERT INTO schema_version (version, description) "
432
+ "VALUES (5, 'Model invocation telemetry persistence');\nCOMMIT;"
433
+ )
434
+ except Exception as exc:
435
+ await self._connection.rollback()
436
+ raise MigrationError("Failed to apply schema migration 5") from exc
437
+ current_version = 5
438
+
439
+ if current_version < 6:
440
+ try:
441
+ await self._connection.executescript(
442
+ "BEGIN IMMEDIATE;\n" + MIGRATION_6_SQL
443
+ + "\nINSERT INTO schema_version (version, description) "
444
+ "VALUES (6, 'Connector sync state and source item identity');\nCOMMIT;"
445
+ )
446
+ except Exception as exc:
447
+ await self._connection.rollback()
448
+ raise MigrationError("Failed to apply schema migration 6") from exc
449
+ current_version = 6
450
+
451
+ if current_version < 7:
452
+ try:
453
+ await self._connection.executescript(
454
+ "BEGIN IMMEDIATE;\n" + MIGRATION_7_SQL
455
+ + "\nINSERT INTO schema_version (version, description) "
456
+ "VALUES (7, 'Context token counting provenance');\nCOMMIT;"
457
+ )
458
+ except Exception as exc:
459
+ await self._connection.rollback()
460
+ raise MigrationError("Failed to apply schema migration 7") from exc
461
+ current_version = 7
462
+
463
+
464
+ if current_version != SCHEMA_VERSION:
465
+ raise MigrationError(
466
+ f"Database schema version {current_version} is unsupported"
467
+ )
468
+ logger.info("Applied schema version %d", SCHEMA_VERSION)
469
+
470
+ def connection(self) -> aiosqlite.Connection:
471
+ """Get the database connection. Raises if not initialized."""
472
+ if self._connection is None:
473
+ raise RuntimeError(
474
+ "Database not initialized. Call await db.initialize() first."
475
+ )
476
+ return self._connection
477
+
478
+ async def close(self) -> None:
479
+ """Close the database connection."""
480
+ if self._connection is not None:
481
+ await self._connection.close()
482
+ self._connection = None
483
+ logger.info("Database connection closed")
484
+
485
+ async def integrity_check(self) -> tuple[bool, str]:
486
+ """Run SQLite integrity check. Returns (ok, message)."""
487
+ conn = self.connection()
488
+ cursor = await conn.execute("PRAGMA integrity_check")
489
+ row = await cursor.fetchone()
490
+ result = row[0] if row else "unknown"
491
+ return result == "ok", result
492
+
493
+ async def get_size_bytes(self) -> int:
494
+ """Get database file size in bytes."""
495
+ if self._db_path.exists():
496
+ return self._db_path.stat().st_size
497
+ return 0
@@ -0,0 +1,137 @@
1
+ """SQLite-backed Event repository.
2
+
3
+ Implements the EventRepository protocol. Events are append-only — the only
4
+ mutation is purge, which physically removes the row.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from uuid import UUID
13
+
14
+ import aiosqlite
15
+
16
+ from contextos.core.enums import EventType
17
+ from contextos.core.models import EventFilters, RawEvent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _row_to_event(row: aiosqlite.Row) -> RawEvent:
23
+ """Convert a SQLite row to a RawEvent model."""
24
+ data = dict(row)
25
+
26
+ data["id"] = UUID(data["id"])
27
+ data["event_type"] = EventType(data["event_type"])
28
+
29
+ # Parse JSON fields
30
+ data["metadata"] = json.loads(data.get("metadata", "{}"))
31
+ data["memory_ids"] = [UUID(mid) for mid in json.loads(data.get("memory_ids", "[]"))]
32
+
33
+ pscan = data.get("privacy_scan_result")
34
+ data["privacy_scan_result"] = json.loads(pscan) if pscan else None
35
+
36
+ # Parse timestamp
37
+ ts = data.get("timestamp")
38
+ if ts is not None:
39
+ data["timestamp"] = datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
40
+
41
+ return RawEvent.model_validate(data)
42
+
43
+
44
+ def _event_to_row(event: RawEvent) -> dict:
45
+ """Convert a RawEvent model to a dict suitable for SQLite insertion."""
46
+ return {
47
+ "id": str(event.id),
48
+ "event_type": event.event_type.value,
49
+ "timestamp": event.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
50
+ "source_type": event.source_type,
51
+ "source_uri": event.source_uri,
52
+ "content": event.content,
53
+ "content_hash": event.content_hash,
54
+ "metadata": json.dumps(event.metadata),
55
+ "privacy_scan_result": (
56
+ json.dumps(event.privacy_scan_result)
57
+ if event.privacy_scan_result is not None
58
+ else None
59
+ ),
60
+ "memory_ids": json.dumps([str(mid) for mid in event.memory_ids]),
61
+ }
62
+
63
+
64
+ class SqliteEventRepository:
65
+ """SQLite implementation of the EventRepository protocol."""
66
+
67
+ def __init__(self, db: aiosqlite.Connection) -> None:
68
+ self._db = db
69
+
70
+ async def append(self, event: RawEvent) -> None:
71
+ row = _event_to_row(event)
72
+ columns = ", ".join(row.keys())
73
+ placeholders = ", ".join("?" for _ in row)
74
+
75
+ await self._db.execute(
76
+ f"INSERT INTO events ({columns}) VALUES ({placeholders})",
77
+ list(row.values()),
78
+ )
79
+ await self._db.commit()
80
+ logger.debug("Appended event %s (type: %s)", event.id, event.event_type.value)
81
+
82
+ async def get(self, event_id: UUID) -> RawEvent | None:
83
+ cursor = await self._db.execute(
84
+ "SELECT * FROM events WHERE id = ?", (str(event_id),)
85
+ )
86
+ row = await cursor.fetchone()
87
+ if row is None:
88
+ return None
89
+ return _row_to_event(row)
90
+
91
+ async def list(self, filters: EventFilters) -> list[RawEvent]:
92
+ query = "SELECT * FROM events WHERE 1=1"
93
+ params: list = []
94
+
95
+ if filters.event_type is not None:
96
+ query += " AND event_type = ?"
97
+ params.append(filters.event_type.value)
98
+
99
+ if filters.source_type is not None:
100
+ query += " AND source_type = ?"
101
+ params.append(filters.source_type)
102
+
103
+ if filters.after is not None:
104
+ query += " AND timestamp >= ?"
105
+ params.append(filters.after.strftime("%Y-%m-%dT%H:%M:%SZ"))
106
+
107
+ if filters.before is not None:
108
+ query += " AND timestamp <= ?"
109
+ params.append(filters.before.strftime("%Y-%m-%dT%H:%M:%SZ"))
110
+
111
+ if filters.memory_id is not None:
112
+ # Search for memory_id within the JSON array
113
+ query += " AND EXISTS (SELECT 1 FROM json_each(memory_ids) WHERE value = ?)"
114
+ params.append(str(filters.memory_id))
115
+
116
+ query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
117
+ params.extend([filters.limit, filters.offset])
118
+
119
+ cursor = await self._db.execute(query, params)
120
+ rows = await cursor.fetchall()
121
+ return [_row_to_event(row) for row in rows]
122
+
123
+ async def count(self, filters: EventFilters | None = None) -> int:
124
+ if filters is None:
125
+ cursor = await self._db.execute("SELECT COUNT(*) FROM events")
126
+ else:
127
+ query = "SELECT COUNT(*) FROM events WHERE 1=1"
128
+ params: list = []
129
+
130
+ if filters.event_type is not None:
131
+ query += " AND event_type = ?"
132
+ params.append(filters.event_type.value)
133
+
134
+ cursor = await self._db.execute(query, params)
135
+
136
+ row = await cursor.fetchone()
137
+ return row[0] if row else 0