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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- 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
|