topos-node 0.1.7__tar.gz → 0.1.9__tar.gz

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 (256) hide show
  1. {topos_node-0.1.7 → topos_node-0.1.9}/PKG-INFO +1 -1
  2. {topos_node-0.1.7 → topos_node-0.1.9}/pyproject.toml +1 -1
  3. {topos_node-0.1.7 → topos_node-0.1.9}/topos/__version__.py +2 -2
  4. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/device.py +2 -2
  5. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/source_install.py +30 -0
  6. {topos_node-0.1.7 → topos_node-0.1.9}/topos/app.py +9 -0
  7. {topos_node-0.1.7 → topos_node-0.1.9}/topos/cli/commands.py +30 -64
  8. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/handlers.py +26 -1
  9. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/logging.py +7 -2
  10. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/state.py +58 -0
  11. topos_node-0.1.9/topos/runtime_update.py +246 -0
  12. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/interfaces.py +1 -1
  13. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/local.py +2 -1
  14. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/postgres.py +68 -5
  15. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sources/install_service.py +95 -0
  16. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sync/client.py +3 -0
  17. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/PKG-INFO +1 -1
  18. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/SOURCES.txt +1 -0
  19. {topos_node-0.1.7 → topos_node-0.1.9}/LICENSE +0 -0
  20. {topos_node-0.1.7 → topos_node-0.1.9}/README.md +0 -0
  21. {topos_node-0.1.7 → topos_node-0.1.9}/setup.cfg +0 -0
  22. {topos_node-0.1.7 → topos_node-0.1.9}/shared/__init__.py +0 -0
  23. {topos_node-0.1.7 → topos_node-0.1.9}/shared/filtering.py +0 -0
  24. {topos_node-0.1.7 → topos_node-0.1.9}/shared/schema_registry.py +0 -0
  25. {topos_node-0.1.7 → topos_node-0.1.9}/topos/__init__.py +0 -0
  26. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/__init__.py +0 -0
  27. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/duckdb_adapter.py +0 -0
  28. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/messenger_communities.py +0 -0
  29. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/messenger_graph.py +0 -0
  30. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/messenger_labels.py +0 -0
  31. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/profiles.py +0 -0
  32. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/query_engine.py +0 -0
  33. {topos_node-0.1.7 → topos_node-0.1.9}/topos/analytics/raw_queries.py +0 -0
  34. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/__init__.py +0 -0
  35. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/analytics.py +0 -0
  36. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/app_registry.py +0 -0
  37. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/backup.py +0 -0
  38. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/compute_remote.py +0 -0
  39. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/data_commit.py +0 -0
  40. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/data_explorer_table_prefs.py +0 -0
  41. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/db.py +0 -0
  42. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/enrichment.py +0 -0
  43. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/filter_lab.py +0 -0
  44. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/health.py +0 -0
  45. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/ingestion_api.py +0 -0
  46. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/ingestion_compat.py +0 -0
  47. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/ingestion_sources.py +0 -0
  48. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/llm.py +0 -0
  49. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/local_mcp.py +0 -0
  50. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/messenger_analytics.py +0 -0
  51. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/query_api.py +0 -0
  52. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/sanitization_ollama_config.py +0 -0
  53. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/sources.py +0 -0
  54. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/sync.py +0 -0
  55. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/ui_config.py +0 -0
  56. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/uma_data.py +0 -0
  57. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/usage.py +0 -0
  58. {topos_node-0.1.7 → topos_node-0.1.9}/topos/api/user_identity.py +0 -0
  59. {topos_node-0.1.7 → topos_node-0.1.9}/topos/auth.py +0 -0
  60. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/__init__.py +0 -0
  61. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/mappers/__init__.py +0 -0
  62. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/mappers/base.py +0 -0
  63. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/mappers/chatgpt_mapper.py +0 -0
  64. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/mappers/grok_mapper.py +0 -0
  65. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/mappers/messenger_mapper.py +0 -0
  66. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/models.py +0 -0
  67. {topos_node-0.1.7 → topos_node-0.1.9}/topos/canonicalization/resolver.py +0 -0
  68. {topos_node-0.1.7 → topos_node-0.1.9}/topos/cli/__init__.py +0 -0
  69. {topos_node-0.1.7 → topos_node-0.1.9}/topos/cli/__main__.py +0 -0
  70. {topos_node-0.1.7 → topos_node-0.1.9}/topos/config/__init__.py +0 -0
  71. {topos_node-0.1.7 → topos_node-0.1.9}/topos/config/sanitization_ollama.py +0 -0
  72. {topos_node-0.1.7 → topos_node-0.1.9}/topos/config/settings.py +0 -0
  73. {topos_node-0.1.7 → topos_node-0.1.9}/topos/contacts/__init__.py +0 -0
  74. {topos_node-0.1.7 → topos_node-0.1.9}/topos/contacts/identity.py +0 -0
  75. {topos_node-0.1.7 → topos_node-0.1.9}/topos/control_plane_client.py +0 -0
  76. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/__init__.py +0 -0
  77. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/api_models.py +0 -0
  78. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/connection_resilience.py +0 -0
  79. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/device_helpers.py +0 -0
  80. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/errors.py +0 -0
  81. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/events.py +0 -0
  82. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/metrics.py +0 -0
  83. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/startup_banner.py +0 -0
  84. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/table_layers.py +0 -0
  85. {topos_node-0.1.7 → topos_node-0.1.9}/topos/core/types.py +0 -0
  86. {topos_node-0.1.7 → topos_node-0.1.9}/topos/data_explorer_table_prefs.py +0 -0
  87. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/__init__.py +0 -0
  88. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/backends/__init__.py +0 -0
  89. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/backends/base.py +0 -0
  90. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/backends/huggingface.py +0 -0
  91. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/backends/ollama.py +0 -0
  92. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/backends/stub.py +0 -0
  93. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/engine.py +0 -0
  94. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/intake.py +0 -0
  95. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/queue_manager.py +0 -0
  96. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/registration.py +0 -0
  97. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/result_formatter.py +0 -0
  98. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/router.py +0 -0
  99. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/scoped_token.py +0 -0
  100. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/tasks.py +0 -0
  101. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/transport.py +0 -0
  102. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/usage_guard.py +0 -0
  103. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/usage_observation.py +0 -0
  104. {topos_node-0.1.7 → topos_node-0.1.9}/topos/engine/validator.py +0 -0
  105. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/__init__.py +0 -0
  106. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/derived_tables.py +0 -0
  107. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/__init__.py +0 -0
  108. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/base.py +0 -0
  109. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/__init__.py +0 -0
  110. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/embeddings_job.py +0 -0
  111. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/emo_27_job.py +0 -0
  112. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/entities_job.py +0 -0
  113. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/sentiment_job.py +0 -0
  114. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/canonical/topics_job.py +0 -0
  115. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/raw/__init__.py +0 -0
  116. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/raw/attachments_job.py +0 -0
  117. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/raw/language_job.py +0 -0
  118. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/raw/time_normalization_job.py +0 -0
  119. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/jobs/raw/tool_calls_job.py +0 -0
  120. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/models/__init__.py +0 -0
  121. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/models/manager.py +0 -0
  122. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/models/registry.py +0 -0
  123. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/models/versioning.py +0 -0
  124. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/orchestrator.py +0 -0
  125. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/processor.py +0 -0
  126. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/progress_bar.py +0 -0
  127. {topos_node-0.1.7 → topos_node-0.1.9}/topos/enrichment/website_classifier.py +0 -0
  128. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/__init__.py +0 -0
  129. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/bundles.py +0 -0
  130. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/schema.py +0 -0
  131. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/service.py +0 -0
  132. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/store.py +0 -0
  133. {topos_node-0.1.7 → topos_node-0.1.9}/topos/filter_lab/worker.py +0 -0
  134. {topos_node-0.1.7 → topos_node-0.1.9}/topos/hosted_pool_lease.py +0 -0
  135. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/__init__.py +0 -0
  136. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/checkpoints/__init__.py +0 -0
  137. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/checkpoints/checkpoint_store.py +0 -0
  138. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/checkpoints/sqlite_checkpoint_store.py +0 -0
  139. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/ingest_helpers.py +0 -0
  140. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/jobs.py +0 -0
  141. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/local_sync.py +0 -0
  142. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/log_preview.py +0 -0
  143. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/manager.py +0 -0
  144. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parser.py +0 -0
  145. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/__init__.py +0 -0
  146. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/base.py +0 -0
  147. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/browser_parser.py +0 -0
  148. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/calendar_parser.py +0 -0
  149. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/chatgpt_conversation_flattener.py +0 -0
  150. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/chatgpt_parser.py +0 -0
  151. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/grok_parser.py +0 -0
  152. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/parsers/messenger_parser.py +0 -0
  153. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/progress.py +0 -0
  154. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/__init__.py +0 -0
  155. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/base.py +0 -0
  156. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/calendar.py +0 -0
  157. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/chatgpt.py +0 -0
  158. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/contact_importers.py +0 -0
  159. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/grok.py +0 -0
  160. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/imessage_reader.py +0 -0
  161. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/signal_export_parser.py +0 -0
  162. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/sources/signal_reader.py +0 -0
  163. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/state_machine.py +0 -0
  164. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/triggers/__init__.py +0 -0
  165. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/triggers/file_trigger.py +0 -0
  166. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/triggers/sqlite_trigger.py +0 -0
  167. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/validation/__init__.py +0 -0
  168. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/validation/base.py +0 -0
  169. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/validation/schema_registry.py +0 -0
  170. {topos_node-0.1.7 → topos_node-0.1.9}/topos/ingestion/validation/schema_validator.py +0 -0
  171. {topos_node-0.1.7 → topos_node-0.1.9}/topos/lineage/__init__.py +0 -0
  172. {topos_node-0.1.7 → topos_node-0.1.9}/topos/lineage/provenance.py +0 -0
  173. {topos_node-0.1.7 → topos_node-0.1.9}/topos/lineage/tracker.py +0 -0
  174. {topos_node-0.1.7 → topos_node-0.1.9}/topos/mcp_stdio_proxy.py +0 -0
  175. {topos_node-0.1.7 → topos_node-0.1.9}/topos/observability/__init__.py +0 -0
  176. {topos_node-0.1.7 → topos_node-0.1.9}/topos/observability/alerts.py +0 -0
  177. {topos_node-0.1.7 → topos_node-0.1.9}/topos/observability/metrics.py +0 -0
  178. {topos_node-0.1.7 → topos_node-0.1.9}/topos/observability/tracing.py +0 -0
  179. {topos_node-0.1.7 → topos_node-0.1.9}/topos/openai_client.py +0 -0
  180. {topos_node-0.1.7 → topos_node-0.1.9}/topos/projections/__init__.py +0 -0
  181. {topos_node-0.1.7 → topos_node-0.1.9}/topos/projections/vector_index/__init__.py +0 -0
  182. {topos_node-0.1.7 → topos_node-0.1.9}/topos/projections/vector_index/base.py +0 -0
  183. {topos_node-0.1.7 → topos_node-0.1.9}/topos/projections/vector_index/builders.py +0 -0
  184. {topos_node-0.1.7 → topos_node-0.1.9}/topos/projections/vector_index/health_checks.py +0 -0
  185. {topos_node-0.1.7 → topos_node-0.1.9}/topos/rate_limit.py +0 -0
  186. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sanitization/__init__.py +0 -0
  187. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sanitization/ollama_transforms.py +0 -0
  188. {topos_node-0.1.7 → topos_node-0.1.9}/topos/scope_resolution.py +0 -0
  189. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/__init__.py +0 -0
  190. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/container.py +0 -0
  191. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/embeddings/__init__.py +0 -0
  192. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/embeddings/base.py +0 -0
  193. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/embeddings/local.py +0 -0
  194. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/embeddings/remote.py +0 -0
  195. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/llm/__init__.py +0 -0
  196. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/llm/base.py +0 -0
  197. {topos_node-0.1.7 → topos_node-0.1.9}/topos/services/llm/openai.py +0 -0
  198. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sources/__init__.py +0 -0
  199. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sources/definitions.py +0 -0
  200. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sources/registry.py +0 -0
  201. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sources/runtime_install.py +0 -0
  202. {topos_node-0.1.7 → topos_node-0.1.9}/topos/startup_banner.py +0 -0
  203. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/__init__.py +0 -0
  204. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/__init__.py +0 -0
  205. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/ai_chat/__init__.py +0 -0
  206. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/ai_chat/canonicalizer.py +0 -0
  207. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/ai_chat/mapper.py +0 -0
  208. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/ai_chat/model.py +0 -0
  209. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/ai_chat/tables.py +0 -0
  210. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/canonical_store.py +0 -0
  211. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/conversations_tables.py +0 -0
  212. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/mapping_store.py +0 -0
  213. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/canonical/postgres.py +0 -0
  214. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/__init__.py +0 -0
  215. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/client.py +0 -0
  216. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/migrations/__init__.py +0 -0
  217. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/migrations/stage9_column_renames.py +0 -0
  218. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/paths.py +0 -0
  219. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/postgres.py +0 -0
  220. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/db/schema.py +0 -0
  221. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/enrichment/__init__.py +0 -0
  222. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/enrichment/canonical_enrichment_store.py +0 -0
  223. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/enrichment/raw_enrichment_store.py +0 -0
  224. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/normalized/__init__.py +0 -0
  225. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/normalized/normalized_store.py +0 -0
  226. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/oplog/__init__.py +0 -0
  227. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/oplog/decision.py +0 -0
  228. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/oplog/oplog_store.py +0 -0
  229. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/oplog/postgres.py +0 -0
  230. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/projections/__init__.py +0 -0
  231. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/projections/index_ops_store.py +0 -0
  232. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/projections/vector_index_store.py +0 -0
  233. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/__init__.py +0 -0
  234. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/browser_flat_tables.py +0 -0
  235. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/file_store.py +0 -0
  236. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/raw_store.py +0 -0
  237. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/raw_tables_manager.py +0 -0
  238. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/raw/sqlite_raw_store.py +0 -0
  239. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/security/encryption.py +0 -0
  240. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/signal_identity.py +0 -0
  241. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/source_settings.py +0 -0
  242. {topos_node-0.1.7 → topos_node-0.1.9}/topos/storage/user_identity.py +0 -0
  243. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sync/__init__.py +0 -0
  244. {topos_node-0.1.7 → topos_node-0.1.9}/topos/sync_handlers.py +0 -0
  245. {topos_node-0.1.7 → topos_node-0.1.9}/topos/testing/__init__.py +0 -0
  246. {topos_node-0.1.7 → topos_node-0.1.9}/topos/testing/lifespan.py +0 -0
  247. {topos_node-0.1.7 → topos_node-0.1.9}/topos/uma_contact_enrichment.py +0 -0
  248. {topos_node-0.1.7 → topos_node-0.1.9}/topos/uma_filters.py +0 -0
  249. {topos_node-0.1.7 → topos_node-0.1.9}/topos/uma_resource_id.py +0 -0
  250. {topos_node-0.1.7 → topos_node-0.1.9}/topos/uma_rpt.py +0 -0
  251. {topos_node-0.1.7 → topos_node-0.1.9}/topos/utils/base_object.py +0 -0
  252. {topos_node-0.1.7 → topos_node-0.1.9}/topos/websocket_client.py +0 -0
  253. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/dependency_links.txt +0 -0
  254. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/entry_points.txt +0 -0
  255. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/requires.txt +0 -0
  256. {topos_node-0.1.7 → topos_node-0.1.9}/topos_node.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topos-node
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Topos personal AI engine (FastAPI): local data, sync, control plane WebSocket client
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://topos.dialogues.ai
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "topos-node"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Topos personal AI engine (FastAPI): local data, sync, control plane WebSocket client"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """Topos Control Plane Version Information."""
2
2
 
3
- __version__ = "0.1.7"
4
- __version_info__ = (0, 1, 7)
3
+ __version__ = "0.1.9"
4
+ __version_info__ = (0, 1, 9)
5
5
 
6
6
  __all__ = ["__version__", "__version_info__"]
@@ -12,12 +12,12 @@ router = APIRouter()
12
12
 
13
13
  @router.get("/device/info", response_model=DeviceInfoResponse, dependencies=[Depends(require_api_key)])
14
14
  async def get_device_info(services: Services = Depends(get_services)):
15
- return await services.device.get_device_info()
15
+ return await services.device.get_device_info(context=None)
16
16
 
17
17
 
18
18
  @router.get("/device_info", response_model=DeviceInfoResponse, dependencies=[Depends(require_api_key)])
19
19
  async def get_device_info_alias(services: Services = Depends(get_services)):
20
- return await services.device.get_device_info()
20
+ return await services.device.get_device_info(context=None)
21
21
 
22
22
 
23
23
  @router.post("/device_name", response_model=DeviceNameResponse, dependencies=[Depends(require_api_key)])
@@ -145,6 +145,21 @@ async def _list_sources_core(payload: Dict[str, Any]) -> Dict[str, Any]:
145
145
  return {"status": "ok", "sources": sources}
146
146
 
147
147
 
148
+ async def _patch_source_install_core(payload: Dict[str, Any]) -> Dict[str, Any]:
149
+ source_id = str(payload.get("source_id") or "").strip()
150
+ if not source_id:
151
+ raise ValueError("source_id is required")
152
+ partial = payload.get("source_definition_json")
153
+ if not isinstance(partial, dict):
154
+ raise ValueError("source_definition_json object is required")
155
+ record = install_service.patch_source_install(
156
+ source_id=source_id,
157
+ scope=_scope_from_payload(payload),
158
+ source_definition_json=partial,
159
+ )
160
+ return {"status": "ok", "install": record.to_dict()}
161
+
162
+
148
163
  async def _uninstall_source_core(payload: Dict[str, Any]) -> Dict[str, Any]:
149
164
  source_id = str(payload.get("source_id") or "").strip()
150
165
  if not source_id:
@@ -266,6 +281,21 @@ async def source_install_status(
266
281
  return _ok_envelope(request_id, result)
267
282
 
268
283
 
284
+ @router.patch("/source-install", dependencies=[Depends(require_api_key)])
285
+ async def patch_source_install(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
286
+ request_id = str(uuid.uuid4())
287
+ _log_request("patch_source_install", request_id, payload)
288
+ try:
289
+ result = await _patch_source_install_core(payload)
290
+ return _ok_envelope(request_id, result)
291
+ except ValueError as exc:
292
+ raise HTTPException(status_code=400, detail=str(exc))
293
+ except RuntimeError as exc:
294
+ raise HTTPException(status_code=503, detail=str(exc))
295
+ except Exception as exc: # noqa: BLE001
296
+ raise HTTPException(status_code=500, detail=str(exc))
297
+
298
+
269
299
  @router.delete("/source-install", dependencies=[Depends(require_api_key)])
270
300
  async def uninstall_source(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
271
301
  request_id = str(uuid.uuid4())
@@ -46,6 +46,11 @@ from .control_plane_client import ControlPlaneClient
46
46
  from .engine.registration import build_engine_heartbeat_message, build_engine_register_message
47
47
  from .hosted_pool_lease import HostedPoolLeaseClient
48
48
  from .services.container import get_services
49
+ from .runtime_update import (
50
+ start_runtime_update_monitor,
51
+ start_update_hotkey_listener,
52
+ stop_runtime_update_monitor,
53
+ )
49
54
  from .startup_banner import emit_startup_banner
50
55
  from .sync import SyncClient
51
56
  from .sync_handlers import handle_sync_op
@@ -206,6 +211,9 @@ async def startup_event() -> None:
206
211
  await state.control_plane_client.send_message(build_engine_heartbeat_message())
207
212
 
208
213
  state.engine_presence_task = asyncio.create_task(_presence_loop())
214
+ start_runtime_update_monitor()
215
+ start_update_hotkey_listener()
216
+
209
217
  if settings.enable_sync and settings.topos_user_id:
210
218
  state.sync_client = SyncClient(
211
219
  sync_url=settings.get_sync_url(),
@@ -229,6 +237,7 @@ async def startup_event() -> None:
229
237
 
230
238
  @app.on_event("shutdown")
231
239
  async def shutdown_event() -> None:
240
+ await stop_runtime_update_monitor()
232
241
  if state.engine_presence_task:
233
242
  state.engine_presence_task.cancel()
234
243
  try:
@@ -2,28 +2,42 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
- import subprocess
8
6
  import sys
9
- from importlib.metadata import PackageNotFoundError, version as package_version
10
7
  from pathlib import Path
11
- from urllib.error import URLError
12
- from urllib.request import urlopen
13
8
 
14
9
  import click
15
10
  import uvicorn
16
- from packaging.version import InvalidVersion, Version
17
11
 
18
12
  # Add parent directory to path for imports
19
13
  sys.path.insert(0, str(Path(__file__).parent.parent.parent))
20
14
 
15
+ from topos.runtime_update import (
16
+ DEFAULT_PACKAGE_NAME,
17
+ apply_package_update,
18
+ can_prompt_for_input,
19
+ check_for_update,
20
+ get_installed_package_version,
21
+ get_latest_pypi_version,
22
+ get_module_version,
23
+ should_skip_update_check,
24
+ )
21
25
  from topos.storage.db.paths import discover_databases
22
26
  from topos.startup_banner import emit_startup_banner
23
27
 
24
28
  USER_ENV_PATH = Path.home() / ".topos" / ".env"
25
29
  LEGACY_ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
26
30
 
31
+ # Re-exported for tests that monkeypatch commands.*
32
+ _get_installed_package_version = get_installed_package_version
33
+ _get_latest_pypi_version = get_latest_pypi_version
34
+ _get_module_version = get_module_version
35
+ _can_prompt_for_input = can_prompt_for_input
36
+
37
+
38
+ def _get_runtime_version(package_name: str = DEFAULT_PACKAGE_NAME) -> str:
39
+ return _get_module_version() or _get_installed_package_version(package_name) or "unknown"
40
+
27
41
 
28
42
  def _load_env_file(env_path: Path) -> None:
29
43
  if not env_path.exists():
@@ -69,10 +83,6 @@ def _save_topos_key(topos_key: str, env_path: Path = USER_ENV_PATH) -> Path:
69
83
  return env_path
70
84
 
71
85
 
72
- def _can_prompt_for_input() -> bool:
73
- return sys.stdin.isatty() and sys.stdout.isatty()
74
-
75
-
76
86
  def _prompt_for_topos_key() -> str:
77
87
  click.echo("TOPOS_KEY is required to connect Topos Node.")
78
88
  click.echo("Enter your TOPOS_KEY to save it locally and continue.")
@@ -107,26 +117,7 @@ def _resolve_topos_key(cli_topos_key: str | None, env_path: Path = USER_ENV_PATH
107
117
  )
108
118
 
109
119
 
110
- def _get_installed_package_version(package_name: str) -> str | None:
111
- try:
112
- return package_version(package_name)
113
- except PackageNotFoundError:
114
- return None
115
-
116
-
117
- def _get_module_version() -> str | None:
118
- try:
119
- from topos.__version__ import __version__
120
- except Exception:
121
- return None
122
- return __version__ or None
123
-
124
-
125
- def _get_runtime_version(package_name: str = "topos-node") -> str:
126
- return _get_module_version() or _get_installed_package_version(package_name) or "unknown"
127
-
128
-
129
- def _emit_startup_banner(host: str, port: int, package_name: str = "topos-node") -> None:
120
+ def _emit_startup_banner(host: str, port: int, package_name: str = DEFAULT_PACKAGE_NAME) -> None:
130
121
  runtime_version = _get_runtime_version(package_name=package_name)
131
122
  emit_startup_banner(
132
123
  click.echo,
@@ -138,45 +129,18 @@ def _emit_startup_banner(host: str, port: int, package_name: str = "topos-node")
138
129
  os.environ["TOPOS_STARTUP_BANNER_EMITTED"] = "1"
139
130
 
140
131
 
141
- def _get_latest_pypi_version(package_name: str, timeout_seconds: float = 2.0) -> str | None:
142
- url = f"https://pypi.org/pypi/{package_name}/json"
143
- try:
144
- with urlopen(url, timeout=timeout_seconds) as response:
145
- payload = json.loads(response.read().decode("utf-8"))
146
- except (OSError, URLError, ValueError):
147
- return None
148
- return str(payload.get("info", {}).get("version") or "").strip() or None
149
-
150
-
151
- def _should_skip_update_check(skip_update_check: bool) -> bool:
152
- if skip_update_check:
153
- return True
154
- env_value = (os.getenv("TOPOS_SKIP_UPDATE_CHECK") or "").strip().lower()
155
- return env_value in {"1", "true", "yes", "on"}
156
-
157
-
158
132
  def _maybe_offer_self_update(
159
133
  skip_update_check: bool,
160
- package_name: str = "topos-node",
134
+ package_name: str = DEFAULT_PACKAGE_NAME,
161
135
  ) -> bool:
162
- if _should_skip_update_check(skip_update_check):
136
+ if should_skip_update_check(cli_skip=skip_update_check):
163
137
  return False
164
138
 
165
- installed = _get_installed_package_version(package_name)
166
- if not installed:
139
+ info = check_for_update(package_name)
140
+ if not info:
167
141
  return False
168
142
 
169
- latest = _get_latest_pypi_version(package_name)
170
- if not latest:
171
- return False
172
-
173
- try:
174
- if Version(latest) <= Version(installed):
175
- return False
176
- except InvalidVersion:
177
- return False
178
-
179
- click.echo(f"Update available for {package_name}: {installed} -> {latest}")
143
+ click.echo(f"Update available for {package_name}: {info.installed} -> {info.latest}")
180
144
  if not _can_prompt_for_input():
181
145
  click.echo(f"Run `uv tool upgrade {package_name}` to update.")
182
146
  return False
@@ -185,8 +149,7 @@ def _maybe_offer_self_update(
185
149
  return False
186
150
 
187
151
  click.echo(f"Updating {package_name}...")
188
- result = subprocess.run(["uv", "tool", "upgrade", package_name], check=False)
189
- if result.returncode != 0:
152
+ if not apply_package_update(package_name):
190
153
  click.echo("Update failed. Continuing with current version.")
191
154
  return False
192
155
 
@@ -246,6 +209,9 @@ def main(db_path, topos_key, set_topos_key, discover, port, host, skip_update_ch
246
209
  click.echo("No existing databases found")
247
210
  return
248
211
 
212
+ if skip_update_check:
213
+ os.environ["TOPOS_SKIP_UPDATE_CHECK"] = "1"
214
+
249
215
  if _maybe_offer_self_update(skip_update_check=skip_update_check):
250
216
  return
251
217
 
@@ -1139,6 +1139,15 @@ async def handle_control_plane_request(message: Dict[str, Any]) -> Optional[Dict
1139
1139
  _payload = message.get("payload") or {}
1140
1140
  _mcp_source = _payload.get("mcp_source")
1141
1141
  _mcp_requester_id = _payload.get("mcp_requester_id")
1142
+ if msg_type == "migrate_browser_plugin_app_id":
1143
+ from .state import _migrate_legacy_browser_plugin_app_ids
1144
+
1145
+ conn = get_db_connection()
1146
+ if not conn:
1147
+ return {"id": req_id, "status": "error", "error": "Database not available"}
1148
+ updated = _migrate_legacy_browser_plugin_app_ids(conn)
1149
+ return {"id": req_id, "status": "ok", "payload": {"updated_rows": updated}}
1150
+
1142
1151
  if msg_type == "get_request_counts":
1143
1152
  """Return UMA + MCP request counts from engine DB (for CP proxy or direct frontend)."""
1144
1153
  payload = message.get("payload") or {}
@@ -1305,6 +1314,20 @@ async def handle_control_plane_request(message: Dict[str, Any]) -> Optional[Dict
1305
1314
  except Exception as exc: # noqa: BLE001
1306
1315
  return {"id": req_id, "status": "error", "error": str(exc)}
1307
1316
 
1317
+ if msg_type == "patch_source_install":
1318
+ from ..api.source_install import _patch_source_install_core
1319
+
1320
+ payload = message.get("payload") if isinstance(message.get("payload"), dict) else {}
1321
+ try:
1322
+ result = await _patch_source_install_core(payload)
1323
+ return {"id": req_id, "status": "ok", "payload": result}
1324
+ except ValueError as exc:
1325
+ return {"id": req_id, "status": "error", "error": str(exc)}
1326
+ except RuntimeError as exc:
1327
+ return {"id": req_id, "status": "error", "error": str(exc)}
1328
+ except Exception as exc: # noqa: BLE001
1329
+ return {"id": req_id, "status": "error", "error": str(exc)}
1330
+
1308
1331
  if msg_type == "post_source_test_ingestion":
1309
1332
  from ..api.source_install import _test_ingestion_core
1310
1333
 
@@ -1759,8 +1782,10 @@ async def handle_control_plane_request(message: Dict[str, Any]) -> Optional[Dict
1759
1782
  except Exception as exc: # noqa: BLE001
1760
1783
  return {"id": req_id, "status": "error", "error": str(exc)}
1761
1784
  if msg_type == "get_device_info":
1785
+ payload = message.get("payload") or {}
1786
+ context = payload if isinstance(payload, dict) else {}
1762
1787
  try:
1763
- result = await get_services().device.get_device_info()
1788
+ result = await get_services().device.get_device_info(context=context)
1764
1789
  return {"id": req_id, "status": "ok", "payload": result.model_dump()}
1765
1790
  except Exception as exc: # noqa: BLE001
1766
1791
  return {"id": req_id, "status": "error", "error": str(exc)}
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  from time import time
6
6
 
7
7
  from ..config.settings import settings
8
+ from ..runtime_update import is_update_available
8
9
 
9
10
  _LOG_FORMAT: str | None = None
10
11
 
@@ -96,8 +97,9 @@ class ColorFormatter(logging.Formatter):
96
97
  logging.CRITICAL: "\x1b[38;5;196m", # Red
97
98
  }
98
99
 
99
- # Timestamp color: Forest green
100
+ # Timestamp color: Forest green (amber when a newer Topos release is on PyPI)
100
101
  _TIMESTAMP_COLOR = "\x1b[38;5;28m" # Forest green
102
+ _TIMESTAMP_UPDATE_COLOR = "\x1b[38;5;214m" # Amber
101
103
 
102
104
  # Separator color: Light gray
103
105
  _SEPARATOR_COLOR = "\x1b[38;5;244m" # Light gray
@@ -119,7 +121,10 @@ class ColorFormatter(logging.Formatter):
119
121
  def format(self, record: logging.LogRecord) -> str: # noqa: D401
120
122
  # Format timestamp: YYYY-MM-DD HH:MM:SS.ms (green)
121
123
  timestamp_str = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
122
- timestamp = f"{self._TIMESTAMP_COLOR}{timestamp_str}{self._RESET}"
124
+ timestamp_color = (
125
+ self._TIMESTAMP_UPDATE_COLOR if is_update_available() else self._TIMESTAMP_COLOR
126
+ )
127
+ timestamp = f"{timestamp_color}{timestamp_str}{self._RESET}"
123
128
 
124
129
  # Get text color for log level (no background)
125
130
  level_color = self._LEVEL_COLORS.get(record.levelno, "")
@@ -14,6 +14,8 @@ from typing import Any, Dict, Optional
14
14
 
15
15
  MCP_REQUEST_LOG_TABLE = "mcp_request_log"
16
16
  UMA_ACCESS_REQUESTS_TABLE = "uma_access_requests"
17
+ LEGACY_BROWSER_PLUGIN_APP_ID = "browser-plugin"
18
+ BROWSER_HISTORY_PLUGIN_APP_ID = "browser-history-plugin"
17
19
 
18
20
 
19
21
  def derive_uma_access_context(owner_user_id: str, requesting_user_id: Optional[str]) -> str:
@@ -401,6 +403,29 @@ def _ensure_uma_access_requests_table(conn: sqlite3.Connection) -> None:
401
403
  conn.commit()
402
404
  except Exception as exc:
403
405
  logger.warning("Failed to ensure uma_access_requests table exists: %s", exc)
406
+ else:
407
+ _migrate_legacy_browser_plugin_app_ids(conn)
408
+
409
+
410
+ def _migrate_legacy_browser_plugin_app_ids(conn: sqlite3.Connection) -> int:
411
+ """Rewrite engine Activity counts from browser-plugin → browser-history-plugin (idempotent)."""
412
+ try:
413
+ cursor = conn.execute(
414
+ f"UPDATE {UMA_ACCESS_REQUESTS_TABLE} SET app_id = ? WHERE app_id = ?",
415
+ (BROWSER_HISTORY_PLUGIN_APP_ID, LEGACY_BROWSER_PLUGIN_APP_ID),
416
+ )
417
+ if cursor.rowcount:
418
+ conn.commit()
419
+ logger.info(
420
+ "Migrated %s uma_access_requests rows: %s → %s",
421
+ cursor.rowcount,
422
+ LEGACY_BROWSER_PLUGIN_APP_ID,
423
+ BROWSER_HISTORY_PLUGIN_APP_ID,
424
+ )
425
+ return int(cursor.rowcount or 0)
426
+ except Exception as exc:
427
+ logger.warning("browser-plugin app_id migration on engine failed: %s", exc)
428
+ return 0
404
429
 
405
430
 
406
431
  def record_uma_request(
@@ -463,6 +488,7 @@ def get_uma_request_counts(
463
488
  "total_read_requests": 0,
464
489
  "total_write_requests": 0,
465
490
  "by_app": [],
491
+ "by_app_requester": [],
466
492
  "by_requesting_user": [],
467
493
  "access_attribution": {
468
494
  "window_days": since_days or 0,
@@ -510,6 +536,38 @@ def get_uma_request_counts(
510
536
  for aid, d in sorted(by_app.items())
511
537
  ]
512
538
 
539
+ cursor = conn.execute(
540
+ f"""SELECT app_id, requesting_user_id, requesting_user_email, request_type
541
+ FROM {UMA_ACCESS_REQUESTS_TABLE}
542
+ WHERE owner_user_id = ? AND created_at >= ?""",
543
+ (owner_user_id, since_ts),
544
+ )
545
+ by_app_req: Dict[str, Dict[str, Any]] = {}
546
+ for row in cursor.fetchall():
547
+ app_id_val = (row[0] or "").strip() if len(row) > 0 else ""
548
+ rid = (row[1] or "").strip() if len(row) > 1 else ""
549
+ remail = (row[2] or "").strip() if len(row) > 2 else ""
550
+ rt = (row[3] or "").strip().lower() if len(row) > 3 else ""
551
+ key = f"{app_id_val}\0{rid}\0{remail}"
552
+ if key not in by_app_req:
553
+ by_app_req[key] = {
554
+ "app_id": app_id_val or None,
555
+ "requesting_user_id": rid or None,
556
+ "requesting_user_email": remail or None,
557
+ "read_requests": 0,
558
+ "write_requests": 0,
559
+ }
560
+ if rt == "read":
561
+ by_app_req[key]["read_requests"] += 1
562
+ elif rt == "write":
563
+ by_app_req[key]["write_requests"] += 1
564
+ app_req_ranked = []
565
+ for d in by_app_req.values():
566
+ tr = int(d["read_requests"]) + int(d["write_requests"])
567
+ app_req_ranked.append({**d, "total_requests": tr})
568
+ app_req_ranked.sort(key=lambda x: -x["total_requests"])
569
+ out["by_app_requester"] = app_req_ranked[:50]
570
+
513
571
  cursor = conn.execute(
514
572
  f"""SELECT requesting_user_id, requesting_user_email, request_type, access_context
515
573
  FROM {UMA_ACCESS_REQUESTS_TABLE}
@@ -0,0 +1,246 @@
1
+ """Runtime PyPI update checks for the Topos node (non-blocking while server runs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ from dataclasses import dataclass
13
+ from importlib.metadata import PackageNotFoundError, version as package_version
14
+ from urllib.error import URLError
15
+ from urllib.request import urlopen
16
+
17
+ from packaging.version import InvalidVersion, Version
18
+
19
+ DEFAULT_PACKAGE_NAME = "topos-node"
20
+ DEFAULT_CHECK_INTERVAL_SECONDS = 6 * 60 * 60
21
+ INITIAL_CHECK_DELAY_SECONDS = 10.0
22
+ PYPI_TIMEOUT_SECONDS = 2.0
23
+
24
+ _logger = logging.getLogger("topos.runtime_update")
25
+ _lock = threading.Lock()
26
+ _update_info: UpdateInfo | None = None
27
+ _announcement_logged = False
28
+ _monitor_task: asyncio.Task[None] | None = None
29
+ _hotkey_thread: threading.Thread | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class UpdateInfo:
34
+ package_name: str
35
+ installed: str
36
+ latest: str
37
+
38
+
39
+ def should_skip_update_check(*, cli_skip: bool = False) -> bool:
40
+ if cli_skip:
41
+ return True
42
+ env_value = (os.getenv("TOPOS_SKIP_UPDATE_CHECK") or "").strip().lower()
43
+ return env_value in {"1", "true", "yes", "on"}
44
+
45
+
46
+ def get_installed_package_version(package_name: str = DEFAULT_PACKAGE_NAME) -> str | None:
47
+ try:
48
+ return package_version(package_name)
49
+ except PackageNotFoundError:
50
+ return None
51
+
52
+
53
+ def get_module_version() -> str | None:
54
+ try:
55
+ from topos.__version__ import __version__
56
+ except Exception:
57
+ return None
58
+ return __version__ or None
59
+
60
+
61
+ def get_runtime_version(package_name: str = DEFAULT_PACKAGE_NAME) -> str:
62
+ return get_module_version() or get_installed_package_version(package_name) or "unknown"
63
+
64
+
65
+ def get_latest_pypi_version(
66
+ package_name: str = DEFAULT_PACKAGE_NAME,
67
+ timeout_seconds: float = PYPI_TIMEOUT_SECONDS,
68
+ ) -> str | None:
69
+ url = f"https://pypi.org/pypi/{package_name}/json"
70
+ try:
71
+ with urlopen(url, timeout=timeout_seconds) as response:
72
+ payload = json.loads(response.read().decode("utf-8"))
73
+ except (OSError, URLError, ValueError):
74
+ return None
75
+ return str(payload.get("info", {}).get("version") or "").strip() or None
76
+
77
+
78
+ def is_update_available() -> bool:
79
+ with _lock:
80
+ return _update_info is not None
81
+
82
+
83
+ def get_update_info() -> UpdateInfo | None:
84
+ with _lock:
85
+ return _update_info
86
+
87
+
88
+ def _set_update_info(info: UpdateInfo | None) -> None:
89
+ global _update_info
90
+ with _lock:
91
+ _update_info = info
92
+
93
+
94
+ def _is_newer_version(installed: str, latest: str) -> bool:
95
+ try:
96
+ return Version(latest) > Version(installed)
97
+ except InvalidVersion:
98
+ return False
99
+
100
+
101
+ def check_for_update(package_name: str = DEFAULT_PACKAGE_NAME) -> UpdateInfo | None:
102
+ """Return update details when PyPI has a newer release, else None."""
103
+ if should_skip_update_check():
104
+ return None
105
+
106
+ installed = get_installed_package_version(package_name) or get_module_version()
107
+ if not installed or installed == "unknown":
108
+ return None
109
+
110
+ latest = get_latest_pypi_version(package_name)
111
+ if not latest or not _is_newer_version(installed, latest):
112
+ return None
113
+
114
+ return UpdateInfo(package_name=package_name, installed=installed, latest=latest)
115
+
116
+
117
+ def apply_package_update(package_name: str = DEFAULT_PACKAGE_NAME) -> bool:
118
+ """Install the latest PyPI release via `uv tool upgrade`. Returns True on success."""
119
+ result = subprocess.run(["uv", "tool", "upgrade", package_name], check=False)
120
+ return result.returncode == 0
121
+
122
+
123
+ def _log_update_available_once(info: UpdateInfo) -> None:
124
+ global _announcement_logged
125
+ with _lock:
126
+ if _announcement_logged:
127
+ return
128
+ _announcement_logged = True
129
+
130
+ _logger.warning(
131
+ "New Topos version available: %s -> %s. "
132
+ "Timestamps will show in amber until you restart. "
133
+ "Update with `uv tool upgrade %s` then stop (Ctrl+C) and re-run `topos-node`. "
134
+ "In an interactive terminal, type `:update` and press Enter to install now.",
135
+ info.installed,
136
+ info.latest,
137
+ info.package_name,
138
+ )
139
+
140
+
141
+ async def _monitor_loop(
142
+ *,
143
+ package_name: str = DEFAULT_PACKAGE_NAME,
144
+ initial_delay_seconds: float = INITIAL_CHECK_DELAY_SECONDS,
145
+ interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS,
146
+ ) -> None:
147
+ await asyncio.sleep(initial_delay_seconds)
148
+ while True:
149
+ try:
150
+ info = await asyncio.to_thread(check_for_update, package_name)
151
+ if info:
152
+ _set_update_info(info)
153
+ _log_update_available_once(info)
154
+ except asyncio.CancelledError:
155
+ raise
156
+ except Exception as exc: # noqa: BLE001
157
+ _logger.debug("Runtime update check failed (non-fatal): %s", exc)
158
+ await asyncio.sleep(interval_seconds)
159
+
160
+
161
+ def start_runtime_update_monitor(
162
+ *,
163
+ cli_skip: bool = False,
164
+ package_name: str = DEFAULT_PACKAGE_NAME,
165
+ ) -> asyncio.Task[None] | None:
166
+ """Schedule periodic PyPI checks; safe to call once at app startup."""
167
+ global _monitor_task
168
+ if should_skip_update_check(cli_skip=cli_skip):
169
+ return None
170
+ if _monitor_task is not None and not _monitor_task.done():
171
+ return _monitor_task
172
+
173
+ loop = asyncio.get_running_loop()
174
+ _monitor_task = loop.create_task(
175
+ _monitor_loop(package_name=package_name),
176
+ name="topos-runtime-update-monitor",
177
+ )
178
+ return _monitor_task
179
+
180
+
181
+ async def stop_runtime_update_monitor() -> None:
182
+ global _monitor_task
183
+ task = _monitor_task
184
+ _monitor_task = None
185
+ if task is None:
186
+ return
187
+ task.cancel()
188
+ try:
189
+ await task
190
+ except asyncio.CancelledError:
191
+ pass
192
+
193
+
194
+ def _handle_hotkey_line(line: str, package_name: str = DEFAULT_PACKAGE_NAME) -> None:
195
+ command = line.strip().lower()
196
+ if command not in {":update", ":u"}:
197
+ return
198
+
199
+ info = get_update_info() or check_for_update(package_name)
200
+ if not info:
201
+ _logger.info("Topos is already on the latest published version.")
202
+ return
203
+
204
+ _logger.info("Updating %s (%s -> %s)...", package_name, info.installed, info.latest)
205
+ if apply_package_update(package_name):
206
+ _logger.warning(
207
+ "Update installed. Stop Topos (Ctrl+C) and re-run `topos-node` to use %s.",
208
+ info.latest,
209
+ )
210
+ else:
211
+ _logger.error("Update failed. Run `uv tool upgrade %s` manually.", package_name)
212
+
213
+
214
+ def _hotkey_listener_loop(package_name: str = DEFAULT_PACKAGE_NAME) -> None:
215
+ try:
216
+ for line in sys.stdin:
217
+ _handle_hotkey_line(line, package_name=package_name)
218
+ except Exception as exc: # noqa: BLE001
219
+ _logger.debug("Update hotkey listener stopped: %s", exc)
220
+
221
+
222
+ def start_update_hotkey_listener(
223
+ *,
224
+ cli_skip: bool = False,
225
+ package_name: str = DEFAULT_PACKAGE_NAME,
226
+ ) -> None:
227
+ """Listen for `:update` on stdin (interactive terminals only)."""
228
+ global _hotkey_thread
229
+ if should_skip_update_check(cli_skip=cli_skip):
230
+ return
231
+ if not sys.stdin.isatty():
232
+ return
233
+ if _hotkey_thread is not None and _hotkey_thread.is_alive():
234
+ return
235
+
236
+ _hotkey_thread = threading.Thread(
237
+ target=_hotkey_listener_loop,
238
+ kwargs={"package_name": package_name},
239
+ name="topos-update-hotkey",
240
+ daemon=True,
241
+ )
242
+ _hotkey_thread.start()
243
+
244
+
245
+ def can_prompt_for_input() -> bool:
246
+ return sys.stdin.isatty() and sys.stdout.isatty()