topos-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. shared/__init__.py +59 -0
  2. shared/filtering.py +640 -0
  3. shared/schema_registry.py +229 -0
  4. topos/__init__.py +5 -0
  5. topos/__version__.py +6 -0
  6. topos/analytics/__init__.py +15 -0
  7. topos/analytics/duckdb_adapter.py +48 -0
  8. topos/analytics/messenger_communities.py +349 -0
  9. topos/analytics/messenger_graph.py +522 -0
  10. topos/analytics/messenger_labels.py +321 -0
  11. topos/analytics/profiles.py +22 -0
  12. topos/analytics/query_engine.py +64 -0
  13. topos/analytics/raw_queries.py +174 -0
  14. topos/api/__init__.py +1 -0
  15. topos/api/analytics.py +52 -0
  16. topos/api/app_registry.py +31 -0
  17. topos/api/backup.py +15 -0
  18. topos/api/compute_remote.py +175 -0
  19. topos/api/data_commit.py +158 -0
  20. topos/api/data_explorer_table_prefs.py +81 -0
  21. topos/api/db.py +10 -0
  22. topos/api/device.py +25 -0
  23. topos/api/enrichment.py +959 -0
  24. topos/api/filter_lab.py +195 -0
  25. topos/api/health.py +61 -0
  26. topos/api/ingestion_api.py +37 -0
  27. topos/api/ingestion_compat.py +21 -0
  28. topos/api/ingestion_sources.py +600 -0
  29. topos/api/llm.py +76 -0
  30. topos/api/local_mcp.py +46 -0
  31. topos/api/messenger_analytics.py +385 -0
  32. topos/api/query_api.py +13 -0
  33. topos/api/sanitization_ollama_config.py +64 -0
  34. topos/api/source_install.py +324 -0
  35. topos/api/sources.py +13 -0
  36. topos/api/sync.py +10 -0
  37. topos/api/ui_config.py +83 -0
  38. topos/api/uma_data.py +311 -0
  39. topos/api/usage.py +49 -0
  40. topos/api/user_identity.py +46 -0
  41. topos/app.py +239 -0
  42. topos/auth.py +17 -0
  43. topos/canonicalization/__init__.py +1 -0
  44. topos/canonicalization/mappers/__init__.py +22 -0
  45. topos/canonicalization/mappers/base.py +26 -0
  46. topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
  47. topos/canonicalization/mappers/grok_mapper.py +17 -0
  48. topos/canonicalization/mappers/messenger_mapper.py +58 -0
  49. topos/canonicalization/models.py +31 -0
  50. topos/canonicalization/resolver.py +23 -0
  51. topos/cli/__init__.py +1 -0
  52. topos/cli/__main__.py +6 -0
  53. topos/cli/commands.py +132 -0
  54. topos/config/__init__.py +1 -0
  55. topos/config/sanitization_ollama.py +189 -0
  56. topos/config/settings.py +310 -0
  57. topos/contacts/__init__.py +5 -0
  58. topos/contacts/identity.py +24 -0
  59. topos/control_plane_client.py +300 -0
  60. topos/core/__init__.py +1 -0
  61. topos/core/api_models.py +128 -0
  62. topos/core/connection_resilience.py +99 -0
  63. topos/core/device_helpers.py +8 -0
  64. topos/core/errors.py +13 -0
  65. topos/core/events.py +12 -0
  66. topos/core/handlers.py +5625 -0
  67. topos/core/logging.py +175 -0
  68. topos/core/metrics.py +21 -0
  69. topos/core/startup_banner.py +62 -0
  70. topos/core/state.py +682 -0
  71. topos/core/table_layers.py +45 -0
  72. topos/core/types.py +13 -0
  73. topos/data_explorer_table_prefs.py +150 -0
  74. topos/engine/__init__.py +29 -0
  75. topos/engine/backends/__init__.py +50 -0
  76. topos/engine/backends/base.py +21 -0
  77. topos/engine/backends/huggingface.py +151 -0
  78. topos/engine/backends/ollama.py +181 -0
  79. topos/engine/backends/stub.py +22 -0
  80. topos/engine/engine.py +165 -0
  81. topos/engine/intake.py +32 -0
  82. topos/engine/queue_manager.py +112 -0
  83. topos/engine/registration.py +126 -0
  84. topos/engine/result_formatter.py +38 -0
  85. topos/engine/router.py +19 -0
  86. topos/engine/scoped_token.py +82 -0
  87. topos/engine/tasks.py +154 -0
  88. topos/engine/transport.py +44 -0
  89. topos/engine/usage_guard.py +100 -0
  90. topos/engine/usage_observation.py +129 -0
  91. topos/engine/validator.py +23 -0
  92. topos/enrichment/__init__.py +1 -0
  93. topos/enrichment/derived_tables.py +214 -0
  94. topos/enrichment/jobs/__init__.py +30 -0
  95. topos/enrichment/jobs/base.py +54 -0
  96. topos/enrichment/jobs/canonical/__init__.py +1 -0
  97. topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
  98. topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
  99. topos/enrichment/jobs/canonical/entities_job.py +27 -0
  100. topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
  101. topos/enrichment/jobs/canonical/topics_job.py +27 -0
  102. topos/enrichment/jobs/raw/__init__.py +1 -0
  103. topos/enrichment/jobs/raw/attachments_job.py +12 -0
  104. topos/enrichment/jobs/raw/language_job.py +12 -0
  105. topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
  106. topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
  107. topos/enrichment/models/__init__.py +1 -0
  108. topos/enrichment/models/manager.py +8 -0
  109. topos/enrichment/models/registry.py +71 -0
  110. topos/enrichment/models/versioning.py +8 -0
  111. topos/enrichment/orchestrator.py +177 -0
  112. topos/enrichment/processor.py +17 -0
  113. topos/enrichment/progress_bar.py +122 -0
  114. topos/enrichment/website_classifier.py +31 -0
  115. topos/filter_lab/__init__.py +1 -0
  116. topos/filter_lab/bundles.py +300 -0
  117. topos/filter_lab/schema.py +86 -0
  118. topos/filter_lab/service.py +167 -0
  119. topos/filter_lab/store.py +374 -0
  120. topos/filter_lab/worker.py +250 -0
  121. topos/hosted_pool_lease.py +153 -0
  122. topos/ingestion/__init__.py +1 -0
  123. topos/ingestion/checkpoints/__init__.py +6 -0
  124. topos/ingestion/checkpoints/checkpoint_store.py +24 -0
  125. topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
  126. topos/ingestion/ingest_helpers.py +504 -0
  127. topos/ingestion/jobs.py +91 -0
  128. topos/ingestion/local_sync.py +823 -0
  129. topos/ingestion/log_preview.py +21 -0
  130. topos/ingestion/manager.py +1100 -0
  131. topos/ingestion/parser.py +174 -0
  132. topos/ingestion/parsers/__init__.py +32 -0
  133. topos/ingestion/parsers/base.py +24 -0
  134. topos/ingestion/parsers/browser_parser.py +171 -0
  135. topos/ingestion/parsers/calendar_parser.py +21 -0
  136. topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
  137. topos/ingestion/parsers/chatgpt_parser.py +67 -0
  138. topos/ingestion/parsers/grok_parser.py +21 -0
  139. topos/ingestion/parsers/messenger_parser.py +97 -0
  140. topos/ingestion/progress.py +54 -0
  141. topos/ingestion/sources/__init__.py +20 -0
  142. topos/ingestion/sources/base.py +39 -0
  143. topos/ingestion/sources/calendar.py +29 -0
  144. topos/ingestion/sources/chatgpt.py +29 -0
  145. topos/ingestion/sources/contact_importers.py +274 -0
  146. topos/ingestion/sources/grok.py +29 -0
  147. topos/ingestion/sources/imessage_reader.py +479 -0
  148. topos/ingestion/sources/signal_export_parser.py +132 -0
  149. topos/ingestion/sources/signal_reader.py +491 -0
  150. topos/ingestion/state_machine.py +70 -0
  151. topos/ingestion/triggers/__init__.py +1 -0
  152. topos/ingestion/triggers/file_trigger.py +36 -0
  153. topos/ingestion/triggers/sqlite_trigger.py +18 -0
  154. topos/ingestion/validation/__init__.py +1 -0
  155. topos/ingestion/validation/base.py +27 -0
  156. topos/ingestion/validation/schema_registry.py +111 -0
  157. topos/ingestion/validation/schema_validator.py +13 -0
  158. topos/lineage/__init__.py +1 -0
  159. topos/lineage/provenance.py +9 -0
  160. topos/lineage/tracker.py +9 -0
  161. topos/mcp_stdio_proxy.py +83 -0
  162. topos/observability/__init__.py +1 -0
  163. topos/observability/alerts.py +7 -0
  164. topos/observability/metrics.py +25 -0
  165. topos/observability/tracing.py +18 -0
  166. topos/openai_client.py +69 -0
  167. topos/projections/__init__.py +1 -0
  168. topos/projections/vector_index/__init__.py +1 -0
  169. topos/projections/vector_index/base.py +21 -0
  170. topos/projections/vector_index/builders.py +11 -0
  171. topos/projections/vector_index/health_checks.py +5 -0
  172. topos/rate_limit.py +43 -0
  173. topos/sanitization/__init__.py +16 -0
  174. topos/sanitization/ollama_transforms.py +276 -0
  175. topos/scope_resolution.py +89 -0
  176. topos/services/__init__.py +1 -0
  177. topos/services/container.py +46 -0
  178. topos/services/embeddings/__init__.py +1 -0
  179. topos/services/embeddings/base.py +7 -0
  180. topos/services/embeddings/local.py +9 -0
  181. topos/services/embeddings/remote.py +9 -0
  182. topos/services/interfaces.py +40 -0
  183. topos/services/llm/__init__.py +1 -0
  184. topos/services/llm/base.py +7 -0
  185. topos/services/llm/openai.py +126 -0
  186. topos/services/local.py +123 -0
  187. topos/services/postgres.py +385 -0
  188. topos/sources/__init__.py +6 -0
  189. topos/sources/definitions.py +114 -0
  190. topos/sources/install_service.py +836 -0
  191. topos/sources/registry.py +263 -0
  192. topos/sources/runtime_install.py +427 -0
  193. topos/storage/__init__.py +1 -0
  194. topos/storage/canonical/__init__.py +18 -0
  195. topos/storage/canonical/ai_chat/__init__.py +22 -0
  196. topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
  197. topos/storage/canonical/ai_chat/mapper.py +168 -0
  198. topos/storage/canonical/ai_chat/model.py +87 -0
  199. topos/storage/canonical/ai_chat/tables.py +179 -0
  200. topos/storage/canonical/canonical_store.py +24 -0
  201. topos/storage/canonical/conversations_tables.py +1020 -0
  202. topos/storage/canonical/mapping_store.py +30 -0
  203. topos/storage/canonical/postgres.py +10 -0
  204. topos/storage/db/__init__.py +1 -0
  205. topos/storage/db/client.py +8 -0
  206. topos/storage/db/migrations/__init__.py +1 -0
  207. topos/storage/db/migrations/stage9_column_renames.py +78 -0
  208. topos/storage/db/paths.py +122 -0
  209. topos/storage/db/postgres.py +240 -0
  210. topos/storage/db/schema.py +6 -0
  211. topos/storage/enrichment/__init__.py +1 -0
  212. topos/storage/enrichment/canonical_enrichment_store.py +7 -0
  213. topos/storage/enrichment/raw_enrichment_store.py +18 -0
  214. topos/storage/normalized/__init__.py +1 -0
  215. topos/storage/normalized/normalized_store.py +24 -0
  216. topos/storage/oplog/__init__.py +1 -0
  217. topos/storage/oplog/decision.py +6 -0
  218. topos/storage/oplog/oplog_store.py +17 -0
  219. topos/storage/oplog/postgres.py +10 -0
  220. topos/storage/projections/__init__.py +1 -0
  221. topos/storage/projections/index_ops_store.py +6 -0
  222. topos/storage/projections/vector_index_store.py +6 -0
  223. topos/storage/raw/__init__.py +1 -0
  224. topos/storage/raw/browser_flat_tables.py +303 -0
  225. topos/storage/raw/file_store.py +100 -0
  226. topos/storage/raw/raw_store.py +29 -0
  227. topos/storage/raw/raw_tables_manager.py +295 -0
  228. topos/storage/raw/sqlite_raw_store.py +17 -0
  229. topos/storage/security/encryption.py +21 -0
  230. topos/storage/signal_identity.py +71 -0
  231. topos/storage/source_settings.py +116 -0
  232. topos/storage/user_identity.py +69 -0
  233. topos/sync/__init__.py +5 -0
  234. topos/sync/client.py +272 -0
  235. topos/sync_handlers.py +70 -0
  236. topos/testing/__init__.py +1 -0
  237. topos/testing/lifespan.py +7 -0
  238. topos/uma_contact_enrichment.py +1032 -0
  239. topos/uma_filters.py +669 -0
  240. topos/uma_resource_id.py +24 -0
  241. topos/uma_rpt.py +69 -0
  242. topos/utils/base_object.py +61 -0
  243. topos/websocket_client.py +21 -0
  244. topos_node-0.1.0.dist-info/METADATA +199 -0
  245. topos_node-0.1.0.dist-info/RECORD +249 -0
  246. topos_node-0.1.0.dist-info/WHEEL +5 -0
  247. topos_node-0.1.0.dist-info/entry_points.txt +2 -0
  248. topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
  249. topos_node-0.1.0.dist-info/top_level.txt +2 -0
topos/core/state.py ADDED
@@ -0,0 +1,682 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import asyncio
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import sqlite3
9
+ import sys
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ MCP_REQUEST_LOG_TABLE = "mcp_request_log"
16
+ UMA_ACCESS_REQUESTS_TABLE = "uma_access_requests"
17
+
18
+
19
+ def derive_uma_access_context(owner_user_id: str, requesting_user_id: Optional[str]) -> str:
20
+ """Classify whether the caller is the resource owner or a grantee (or unknown)."""
21
+ rid = (requesting_user_id or "").strip()
22
+ if not rid:
23
+ return "unknown"
24
+ oid = (owner_user_id or "").strip()
25
+ if oid and oid == rid:
26
+ return "owner_self"
27
+ return "grantee"
28
+
29
+
30
+ def derive_mcp_access_context(resource_owner_user_id: Optional[str], requester_id: Optional[str]) -> str:
31
+ """Classify MCP tool calls against this engine's linked owner user_id."""
32
+ rid = (requester_id or "").strip()
33
+ if not rid:
34
+ return "unknown"
35
+ oid = (resource_owner_user_id or "").strip()
36
+ if oid and oid == rid:
37
+ return "owner_self"
38
+ return "other"
39
+
40
+
41
+ from ..analytics.duckdb_adapter import DuckDBAdapter
42
+ from ..config.settings import settings
43
+ from ..control_plane_client import ControlPlaneClient
44
+ from ..storage.db.paths import get_data_directory
45
+ from ..sync.client import SyncClient
46
+ from ..websocket_client import CloudWebSocketClient
47
+ from ..openai_client import OpenAIClient
48
+
49
+ logger = logging.getLogger("topos.core.state")
50
+
51
+ ws_client = CloudWebSocketClient(api_key=settings.gt_cloud_api_key)
52
+ openai_client = OpenAIClient()
53
+ control_plane_client: ControlPlaneClient | None = None
54
+
55
+ db_conn: sqlite3.Connection | None = None
56
+ _db_conn_path: str | None = None # resolved path for current db_conn; invalidate if settings path changes
57
+ oplog_manager: Any = None
58
+ projection_manager: Any = None
59
+ encryption_manager: Any = None
60
+ analytics: DuckDBAdapter | None = None
61
+ sync_client: SyncClient | None = None
62
+ engine_presence_task: asyncio.Task | None = None
63
+ hosted_pool_lease_client: Any = None
64
+ hosted_pool_lease_task: asyncio.Task | None = None
65
+
66
+
67
+ def _resolve_database_path_from_settings() -> Optional[Path]:
68
+ """Pick the SQLite file path from settings and default search order.
69
+
70
+ When ``database_path`` is unset, the first existing file among canonical candidates wins
71
+ (same order as before). Intentionally does not log: this runs on almost every API call.
72
+ """
73
+ from ..storage.db.paths import get_database_path
74
+ from ..config.settings import settings
75
+
76
+ if settings.topos_database_path:
77
+ return Path(settings.topos_database_path)
78
+
79
+ potential_paths: list[Path] = []
80
+ potential_paths.append(get_database_path(settings.topos_database_path))
81
+ if platform.system() == "Darwin":
82
+ engine_path = Path.home() / "Library" / "Application Support" / "ToposEngine" / "database.db"
83
+ potential_paths.append(engine_path)
84
+ legacy_path = Path.home() / ".topos_engine" / "database.db"
85
+ potential_paths.append(legacy_path)
86
+ simple_path = Path.home() / ".topos" / "database.db"
87
+ potential_paths.append(simple_path)
88
+
89
+ for path in potential_paths:
90
+ if path.exists() and path.is_file():
91
+ return path
92
+ return get_database_path(settings.topos_database_path)
93
+
94
+
95
+ def get_db_connection() -> Optional[sqlite3.Connection]:
96
+ """Get or create database connection.
97
+
98
+ Returns existing connection if it matches the current configured path; otherwise opens the
99
+ correct file (tests may change ``DATABASE_PATH`` between imports; see ``_db_conn_path``).
100
+ """
101
+ global db_conn, _db_conn_path
102
+
103
+ from ..config.settings import settings
104
+
105
+ # Respect injected in-memory sqlite handles used by tests, even if _db_conn_path
106
+ # still points to a stale file-backed connection from a prior run.
107
+ if db_conn is not None:
108
+ try:
109
+ row = db_conn.execute("PRAGMA database_list").fetchone()
110
+ if row is not None:
111
+ db_file = str(row[2] or "").strip() # seq, name, file
112
+ if db_file in {"", ":memory:"}:
113
+ _db_conn_path = None
114
+ return db_conn
115
+ if _db_conn_path is None:
116
+ _db_conn_path = str(Path(db_file).resolve())
117
+ except Exception:
118
+ pass
119
+
120
+ if db_conn is not None and settings.topos_database_path and _db_conn_path:
121
+ try:
122
+ db_conn.execute("SELECT 1")
123
+ except (sqlite3.ProgrammingError, sqlite3.OperationalError):
124
+ db_conn = None
125
+ _db_conn_path = None
126
+ else:
127
+ try:
128
+ explicit_resolved = str(Path(settings.topos_database_path).resolve())
129
+ except (OSError, RuntimeError):
130
+ explicit_resolved = None
131
+ if explicit_resolved and explicit_resolved == _db_conn_path:
132
+ return db_conn
133
+
134
+ db_path: Path | None = None
135
+ try:
136
+ db_path = _resolve_database_path_from_settings()
137
+ except Exception as e:
138
+ logger.warning("Failed to determine database path: %s", e)
139
+ return None
140
+
141
+ if not db_path:
142
+ return None
143
+
144
+ resolved = str(db_path.resolve())
145
+ if db_conn is not None and _db_conn_path != resolved:
146
+ try:
147
+ db_conn.close()
148
+ except Exception:
149
+ pass
150
+ db_conn = None
151
+ _db_conn_path = None
152
+
153
+ if db_conn is not None:
154
+ try:
155
+ db_conn.execute("SELECT 1")
156
+ return db_conn
157
+ except (sqlite3.ProgrammingError, sqlite3.OperationalError):
158
+ db_conn = None
159
+ _db_conn_path = None
160
+
161
+ try:
162
+ db_path.parent.mkdir(parents=True, exist_ok=True)
163
+ db_conn = sqlite3.connect(str(db_path), check_same_thread=False)
164
+ db_conn.row_factory = sqlite3.Row
165
+ _db_conn_path = resolved
166
+ logger.debug("Created database connection: %s", db_path)
167
+ try:
168
+ from ..storage.raw.browser_flat_tables import backfill_browser_visits_from_raw_retention
169
+
170
+ backfill_browser_visits_from_raw_retention(db_conn)
171
+ except Exception as backfill_exc: # noqa: BLE001
172
+ logger.debug("browser_visits raw→flat backfill skipped: %s", backfill_exc)
173
+ return db_conn
174
+ except Exception as e:
175
+ logger.warning("Failed to create database connection: %s", e)
176
+ db_conn = None
177
+ _db_conn_path = None
178
+ return None
179
+
180
+
181
+ def get_or_create_user_id(conn: sqlite3.Connection) -> str:
182
+ """Get existing user_id or create a new one.
183
+
184
+ Ensures engine_config table exists before accessing it.
185
+ """
186
+ try:
187
+ _ensure_engine_config_table(conn)
188
+ cursor = conn.execute("SELECT value FROM engine_config WHERE key = 'user_id'")
189
+ row = cursor.fetchone()
190
+ if row:
191
+ return row["value"]
192
+ user_id = str(uuid.uuid4())
193
+ conn.execute(
194
+ "INSERT INTO engine_config (key, value) VALUES (?, ?)",
195
+ ("user_id", user_id),
196
+ )
197
+ conn.commit()
198
+ return user_id
199
+ except Exception as exc:
200
+ logger.error("Failed to get or create user_id: %s", exc, exc_info=True)
201
+ raise
202
+
203
+
204
+ def store_user_id(conn: sqlite3.Connection, user_id: str) -> None:
205
+ """Store user_id in engine_config table.
206
+
207
+ Ensures engine_config table exists before writing.
208
+ """
209
+ try:
210
+ _ensure_engine_config_table(conn)
211
+ conn.execute(
212
+ "INSERT OR REPLACE INTO engine_config (key, value) VALUES (?, ?)",
213
+ ("user_id", user_id),
214
+ )
215
+ conn.commit()
216
+ except Exception as exc:
217
+ logger.error("Failed to store user_id: %s", exc, exc_info=True)
218
+ raise
219
+
220
+
221
+ def get_user_id(conn: sqlite3.Connection) -> str | None:
222
+ """Get user_id from engine_config table.
223
+
224
+ Ensures engine_config table exists before accessing it.
225
+ Returns None if user_id doesn't exist or on error.
226
+ """
227
+ try:
228
+ _ensure_engine_config_table(conn)
229
+ cursor = conn.execute("SELECT value FROM engine_config WHERE key = 'user_id'")
230
+ row = cursor.fetchone()
231
+ if row:
232
+ return row["value"]
233
+ return None
234
+ except Exception as exc:
235
+ logger.warning("Failed to get user_id: %s", exc)
236
+ return None
237
+
238
+
239
+ def _ensure_engine_config_table(conn: sqlite3.Connection) -> None:
240
+ """Ensure engine_config table exists, creating it if necessary."""
241
+ try:
242
+ # Check if table exists
243
+ cursor = conn.execute(
244
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='engine_config'"
245
+ )
246
+ if not cursor.fetchone():
247
+ # Create table if it doesn't exist
248
+ logger.debug("Creating engine_config table...")
249
+ conn.execute("""
250
+ CREATE TABLE IF NOT EXISTS engine_config (
251
+ key TEXT PRIMARY KEY,
252
+ value TEXT NOT NULL,
253
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
254
+ )
255
+ """)
256
+ conn.commit()
257
+ logger.debug("engine_config table created successfully")
258
+ except Exception as exc:
259
+ logger.warning("Failed to ensure engine_config table exists: %s", exc)
260
+
261
+
262
+ def get_engine_config_value(conn: sqlite3.Connection, key: str) -> Optional[str]:
263
+ """Get a value from engine_config table.
264
+
265
+ If the table doesn't exist, it will be created automatically.
266
+ Returns None if the key doesn't exist or on error.
267
+ """
268
+ try:
269
+ _ensure_engine_config_table(conn)
270
+ cursor = conn.execute("SELECT value FROM engine_config WHERE key = ?", (key,))
271
+ row = cursor.fetchone()
272
+ if row:
273
+ return row["value"]
274
+ return None
275
+ except Exception as exc:
276
+ # Only log as warning for missing table (expected on fresh databases)
277
+ # Log as error for other issues
278
+ if "no such table" in str(exc).lower():
279
+ logger.debug("engine_config table not found, will be created on next write: %s", key)
280
+ else:
281
+ logger.warning("Failed to get engine_config[%s]: %s", key, exc)
282
+ return None
283
+
284
+
285
+ def set_engine_config_value(conn: sqlite3.Connection, key: str, value: str) -> None:
286
+ """Set a value in engine_config table.
287
+
288
+ If the table doesn't exist, it will be created automatically.
289
+ """
290
+ try:
291
+ _ensure_engine_config_table(conn)
292
+ conn.execute(
293
+ "INSERT OR REPLACE INTO engine_config (key, value) VALUES (?, ?)",
294
+ (key, value),
295
+ )
296
+ conn.commit()
297
+ except Exception as exc:
298
+ logger.error("Failed to set engine_config[%s]: %s", key, exc, exc_info=True)
299
+ raise
300
+
301
+
302
+ def _ensure_mcp_request_log_table(conn: sqlite3.Connection) -> None:
303
+ """Ensure mcp_request_log table exists (engine-owned MCP request counting). Includes source and requester_id for per-source display."""
304
+ try:
305
+ cursor = conn.execute(
306
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
307
+ (MCP_REQUEST_LOG_TABLE,),
308
+ )
309
+ if not cursor.fetchone():
310
+ conn.execute(f"""
311
+ CREATE TABLE IF NOT EXISTS {MCP_REQUEST_LOG_TABLE} (
312
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
313
+ tool_name TEXT NOT NULL,
314
+ requested_at TEXT NOT NULL DEFAULT (datetime('now')),
315
+ source TEXT,
316
+ requester_id TEXT,
317
+ access_context TEXT
318
+ )
319
+ """)
320
+ conn.commit()
321
+ logger.debug("mcp_request_log table created successfully")
322
+ return
323
+ # Add source/requester_id columns if missing (migration for existing DBs)
324
+ cursor = conn.execute(f"PRAGMA table_info({MCP_REQUEST_LOG_TABLE})")
325
+ columns = [row[1] for row in cursor.fetchall()]
326
+ if "source" not in columns:
327
+ conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN source TEXT")
328
+ conn.commit()
329
+ if "requester_id" not in columns:
330
+ conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN requester_id TEXT")
331
+ conn.commit()
332
+ if "access_context" not in columns:
333
+ conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN access_context TEXT")
334
+ conn.commit()
335
+ except Exception as exc:
336
+ logger.warning("Failed to ensure mcp_request_log table exists: %s", exc)
337
+
338
+
339
+ def record_mcp_request(
340
+ conn: Optional[sqlite3.Connection],
341
+ tool_name: str,
342
+ source: Optional[str] = None,
343
+ requester_id: Optional[str] = None,
344
+ resource_owner_user_id: Optional[str] = None,
345
+ ) -> None:
346
+ """
347
+ Record one MCP tool invocation in the engine's DB.
348
+ source: e.g. "claude_desktop", "chatgpt", "local". requester_id: identity of requester (e.g. user sub or engine key prefix).
349
+ resource_owner_user_id: engine-linked owner; used to set access_context (owner_self vs other).
350
+ """
351
+ if not conn or not tool_name:
352
+ return
353
+ try:
354
+ _ensure_mcp_request_log_table(conn)
355
+ requested_at = datetime.now(timezone.utc).isoformat()
356
+ access_ctx = derive_mcp_access_context(resource_owner_user_id, requester_id)
357
+ conn.execute(
358
+ f"INSERT INTO {MCP_REQUEST_LOG_TABLE} (tool_name, requested_at, source, requester_id, access_context) VALUES (?, ?, ?, ?, ?)",
359
+ (tool_name, requested_at, source or None, requester_id or None, access_ctx),
360
+ )
361
+ conn.commit()
362
+ except Exception as exc:
363
+ logger.debug("Failed to record MCP request (non-critical): %s", exc)
364
+
365
+
366
+ def _ensure_uma_access_requests_table(conn: sqlite3.Connection) -> None:
367
+ """Ensure uma_access_requests table exists (engine-owned UMA request counting)."""
368
+ try:
369
+ cursor = conn.execute(
370
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
371
+ (UMA_ACCESS_REQUESTS_TABLE,),
372
+ )
373
+ if not cursor.fetchone():
374
+ conn.execute(f"""
375
+ CREATE TABLE IF NOT EXISTS {UMA_ACCESS_REQUESTS_TABLE} (
376
+ id TEXT PRIMARY KEY,
377
+ owner_user_id TEXT NOT NULL,
378
+ requesting_user_id TEXT,
379
+ requesting_user_email TEXT,
380
+ app_id TEXT,
381
+ resource_id TEXT NOT NULL,
382
+ request_type TEXT NOT NULL,
383
+ endpoint TEXT NOT NULL,
384
+ access_channel TEXT,
385
+ access_context TEXT,
386
+ created_at TEXT NOT NULL
387
+ )
388
+ """)
389
+ conn.commit()
390
+ logger.debug("uma_access_requests table created successfully")
391
+ return
392
+ cursor = conn.execute(f"PRAGMA table_info({UMA_ACCESS_REQUESTS_TABLE})")
393
+ columns = [row[1] for row in cursor.fetchall()]
394
+ for col, ddl in (
395
+ ("requesting_user_email", "TEXT"),
396
+ ("access_channel", "TEXT"),
397
+ ("access_context", "TEXT"),
398
+ ):
399
+ if col not in columns:
400
+ conn.execute(f"ALTER TABLE {UMA_ACCESS_REQUESTS_TABLE} ADD COLUMN {col} {ddl}")
401
+ conn.commit()
402
+ except Exception as exc:
403
+ logger.warning("Failed to ensure uma_access_requests table exists: %s", exc)
404
+
405
+
406
+ def record_uma_request(
407
+ conn: Optional[sqlite3.Connection],
408
+ owner_user_id: str,
409
+ resource_id: str,
410
+ request_type: str,
411
+ endpoint: str,
412
+ requesting_user_id: Optional[str] = None,
413
+ app_id: Optional[str] = None,
414
+ *,
415
+ requesting_user_email: Optional[str] = None,
416
+ access_channel: Optional[str] = None,
417
+ access_context: Optional[str] = None,
418
+ ) -> None:
419
+ """
420
+ Record one UMA access request in the engine's DB (read or write from connected apps).
421
+ Mirror of CP's Supabase recording; engine owns this data for request counts.
422
+ """
423
+ if not conn or not owner_user_id or not resource_id or not request_type or not endpoint:
424
+ return
425
+ try:
426
+ _ensure_uma_access_requests_table(conn)
427
+ row_id = str(uuid.uuid4())
428
+ created_at = datetime.now(timezone.utc).isoformat()
429
+ ctx = access_context or derive_uma_access_context(owner_user_id, requesting_user_id)
430
+ ch = (access_channel or "").strip() or None
431
+ email = (requesting_user_email or "").strip() or None
432
+ conn.execute(
433
+ f"""INSERT INTO {UMA_ACCESS_REQUESTS_TABLE}
434
+ (id, owner_user_id, requesting_user_id, requesting_user_email, app_id, resource_id, request_type, endpoint, access_channel, access_context, created_at)
435
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
436
+ (
437
+ row_id,
438
+ owner_user_id,
439
+ (requesting_user_id or "").strip() or None,
440
+ email,
441
+ app_id or None,
442
+ resource_id,
443
+ request_type,
444
+ endpoint,
445
+ ch,
446
+ ctx,
447
+ created_at,
448
+ ),
449
+ )
450
+ conn.commit()
451
+ except Exception as exc:
452
+ logger.debug("Failed to record UMA request (non-critical): %s", exc)
453
+
454
+
455
+ def get_uma_request_counts(
456
+ conn: Optional[sqlite3.Connection],
457
+ owner_user_id: str,
458
+ since_days: Optional[int] = 90,
459
+ ) -> Dict[str, Any]:
460
+ """Return UMA request counts from engine DB. Extends CP shape with attribution + top requesters."""
461
+ from datetime import timedelta
462
+ out: Dict[str, Any] = {
463
+ "total_read_requests": 0,
464
+ "total_write_requests": 0,
465
+ "by_app": [],
466
+ "by_requesting_user": [],
467
+ "access_attribution": {
468
+ "window_days": since_days or 0,
469
+ "owner_self_reads": 0,
470
+ "owner_self_writes": 0,
471
+ "grantee_reads": 0,
472
+ "grantee_writes": 0,
473
+ "unknown_reads": 0,
474
+ "unknown_writes": 0,
475
+ },
476
+ }
477
+ if not conn or not owner_user_id:
478
+ return out
479
+ try:
480
+ _ensure_uma_access_requests_table(conn)
481
+ for req_type in ("read", "write"):
482
+ cursor = conn.execute(
483
+ f"SELECT COUNT(*) FROM {UMA_ACCESS_REQUESTS_TABLE} WHERE owner_user_id = ? AND request_type = ?",
484
+ (owner_user_id, req_type),
485
+ )
486
+ row = cursor.fetchone()
487
+ count = row[0] if row else 0
488
+ if req_type == "read":
489
+ out["total_read_requests"] = count
490
+ else:
491
+ out["total_write_requests"] = count
492
+ if since_days and since_days > 0:
493
+ since_ts = (datetime.now(timezone.utc) - timedelta(days=since_days)).isoformat()
494
+ cursor = conn.execute(
495
+ f"SELECT app_id, request_type FROM {UMA_ACCESS_REQUESTS_TABLE} WHERE owner_user_id = ? AND created_at >= ?",
496
+ (owner_user_id, since_ts),
497
+ )
498
+ by_app: Dict[str, Dict[str, int]] = {}
499
+ for row in cursor.fetchall():
500
+ app_id_val = (row[0] or "") if len(row) > 0 else ""
501
+ rt = (row[1] or "") if len(row) > 1 else ""
502
+ if app_id_val not in by_app:
503
+ by_app[app_id_val] = {"read_requests": 0, "write_requests": 0}
504
+ if rt == "read":
505
+ by_app[app_id_val]["read_requests"] += 1
506
+ elif rt == "write":
507
+ by_app[app_id_val]["write_requests"] += 1
508
+ out["by_app"] = [
509
+ {"app_id": aid, "app_name": None, "read_requests": d["read_requests"], "write_requests": d["write_requests"]}
510
+ for aid, d in sorted(by_app.items())
511
+ ]
512
+
513
+ cursor = conn.execute(
514
+ f"""SELECT requesting_user_id, requesting_user_email, request_type, access_context
515
+ FROM {UMA_ACCESS_REQUESTS_TABLE}
516
+ WHERE owner_user_id = ? AND created_at >= ?""",
517
+ (owner_user_id, since_ts),
518
+ )
519
+ by_req: Dict[str, Dict[str, Any]] = {}
520
+ attr = out["access_attribution"]
521
+ attr["window_days"] = int(since_days)
522
+ for row in cursor.fetchall():
523
+ rid = (row[0] or "").strip() if len(row) > 0 else ""
524
+ remail = (row[1] or "").strip() if len(row) > 1 else ""
525
+ rt = (row[2] or "").strip().lower() if len(row) > 2 else ""
526
+ actx = (row[3] or "").strip().lower() if len(row) > 3 else ""
527
+ if not actx:
528
+ actx = derive_uma_access_context(owner_user_id, rid or None)
529
+ is_read = rt == "read"
530
+ is_write = rt == "write"
531
+ if actx == "owner_self":
532
+ if is_read:
533
+ attr["owner_self_reads"] += 1
534
+ elif is_write:
535
+ attr["owner_self_writes"] += 1
536
+ elif actx == "grantee":
537
+ if is_read:
538
+ attr["grantee_reads"] += 1
539
+ elif is_write:
540
+ attr["grantee_writes"] += 1
541
+ else:
542
+ if is_read:
543
+ attr["unknown_reads"] += 1
544
+ elif is_write:
545
+ attr["unknown_writes"] += 1
546
+ key = rid if rid else (f"unknown:{remail}" if remail else "unknown")
547
+ if key not in by_req:
548
+ by_req[key] = {
549
+ "requesting_user_id": rid or None,
550
+ "requesting_user_email": remail or None,
551
+ "read_requests": 0,
552
+ "write_requests": 0,
553
+ }
554
+ if is_read:
555
+ by_req[key]["read_requests"] += 1
556
+ elif is_write:
557
+ by_req[key]["write_requests"] += 1
558
+ ranked = []
559
+ for _k, d in by_req.items():
560
+ tr = int(d["read_requests"]) + int(d["write_requests"])
561
+ ranked.append({**d, "total_requests": tr})
562
+ ranked.sort(key=lambda x: -x["total_requests"])
563
+ out["by_requesting_user"] = ranked[:50]
564
+ except Exception as exc:
565
+ logger.debug("get_uma_request_counts failed: %s", exc)
566
+ return out
567
+
568
+
569
+ def get_mcp_request_counts(
570
+ conn: Optional[sqlite3.Connection],
571
+ since_days: Optional[int] = 90,
572
+ ) -> Dict[str, Any]:
573
+ """Return MCP request counts from engine DB: by_source (identity + count), by_tool, by_access_context, total."""
574
+ from datetime import timedelta
575
+ out: Dict[str, Any] = {
576
+ "by_source": [],
577
+ "by_tool": [],
578
+ "by_access_context": [],
579
+ "total": 0,
580
+ }
581
+ if not conn:
582
+ return out
583
+ try:
584
+ _ensure_mcp_request_log_table(conn)
585
+ since_ts = (datetime.now(timezone.utc) - timedelta(days=since_days)).isoformat() if (since_days and since_days > 0) else None
586
+ if since_ts:
587
+ cursor = conn.execute(
588
+ f"SELECT source, requester_id, access_context FROM {MCP_REQUEST_LOG_TABLE} WHERE requested_at >= ?",
589
+ (since_ts,),
590
+ )
591
+ else:
592
+ cursor = conn.execute(f"SELECT source, requester_id, access_context FROM {MCP_REQUEST_LOG_TABLE}")
593
+ rows = cursor.fetchall()
594
+ by_key: Dict[tuple, int] = {}
595
+ by_ctx: Dict[str, int] = {}
596
+ for row in rows:
597
+ src = (row[0] or "") if len(row) > 0 else ""
598
+ rid = (row[1] or "") if len(row) > 1 else ""
599
+ actx = (row[2] or "").strip() if len(row) > 2 else ""
600
+ if not actx:
601
+ actx = "unknown"
602
+ key = (src, rid)
603
+ by_key[key] = by_key.get(key, 0) + 1
604
+ by_ctx[actx] = by_ctx.get(actx, 0) + 1
605
+ out["by_source"] = [
606
+ {"source": src, "requester_id": rid or None, "count": c}
607
+ for (src, rid), c in sorted(by_key.items(), key=lambda x: -x[1])
608
+ ]
609
+ out["by_access_context"] = [
610
+ {"access_context": k, "count": v} for k, v in sorted(by_ctx.items(), key=lambda x: -x[1])
611
+ ]
612
+ if since_ts:
613
+ cursor = conn.execute(
614
+ f"SELECT tool_name FROM {MCP_REQUEST_LOG_TABLE} WHERE requested_at >= ?",
615
+ (since_ts,),
616
+ )
617
+ else:
618
+ cursor = conn.execute(f"SELECT tool_name FROM {MCP_REQUEST_LOG_TABLE}")
619
+ tool_rows = cursor.fetchall()
620
+ by_tool: Dict[str, int] = {}
621
+ for row in tool_rows:
622
+ t = (row[0] or "") if row else ""
623
+ by_tool[t] = by_tool.get(t, 0) + 1
624
+ out["by_tool"] = [{"tool_name": t, "count": c} for t, c in sorted(by_tool.items(), key=lambda x: -x[1])]
625
+ out["total"] = sum(by_tool.values())
626
+ except Exception as exc:
627
+ logger.debug("get_mcp_request_counts failed: %s", exc)
628
+ return out
629
+
630
+
631
+ def get_engine_mode() -> str:
632
+ mode = (settings.engine_mode or "full").strip().lower()
633
+ if mode not in {"full", "sync"}:
634
+ return "full"
635
+ if mode == "sync":
636
+ return "sync"
637
+ return "full" if settings.enable_llm else "sync"
638
+
639
+
640
+ def get_engine_class() -> str:
641
+ return "sync_engine" if get_engine_mode() == "sync" else "full_engine"
642
+
643
+
644
+ def get_total_memory_bytes() -> Optional[int]:
645
+ try:
646
+ if hasattr(os, "sysconf"):
647
+ page_size = os.sysconf("SC_PAGE_SIZE")
648
+ pages = os.sysconf("SC_PHYS_PAGES")
649
+ if isinstance(page_size, int) and isinstance(pages, int):
650
+ return page_size * pages
651
+ except Exception:
652
+ return None
653
+ return None
654
+
655
+
656
+ def get_system_info() -> Dict[str, Any]:
657
+ disk_total = None
658
+ disk_free = None
659
+ try:
660
+ # Get disk usage for the root filesystem (whole device)
661
+ root_path = Path("/") if platform.system() != "Windows" else Path("C:\\")
662
+ usage = shutil.disk_usage(root_path)
663
+ disk_total = usage.total
664
+ disk_free = usage.free
665
+ except Exception as exc:
666
+ logger.warning("Failed to get disk usage: %s", exc)
667
+ disk_total = None
668
+ disk_free = None
669
+ return {
670
+ "hostname": platform.node() or None,
671
+ "os": platform.system() or None,
672
+ "os_version": platform.version() or None,
673
+ "platform": platform.platform() or None,
674
+ "arch": platform.machine() or None,
675
+ "cpu_count": os.cpu_count(),
676
+ "memory_total_bytes": get_total_memory_bytes(),
677
+ "storage_total_bytes": disk_total,
678
+ "storage_free_bytes": disk_free,
679
+ "python_version": sys.version.split()[0],
680
+ "gpu_name": None,
681
+ "gpu_memory_bytes": None,
682
+ }