virtual-context 0.3.8__tar.gz → 0.4.2__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 (320) hide show
  1. {virtual_context-0.3.8 → virtual_context-0.4.2}/.gitignore +10 -0
  2. {virtual_context-0.3.8 → virtual_context-0.4.2}/PKG-INFO +1 -1
  3. {virtual_context-0.3.8 → virtual_context-0.4.2}/pyproject.toml +10 -1
  4. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_compaction.py +3 -3
  5. virtual_context-0.4.2/tests/test_active_compaction_op_invariants.py +172 -0
  6. virtual_context-0.4.2/tests/test_canonical_row_epoch_guards.py +224 -0
  7. virtual_context-0.4.2/tests/test_canonical_row_epoch_guards_postgres.py +200 -0
  8. virtual_context-0.4.2/tests/test_canonical_turn_grouping.py +206 -0
  9. virtual_context-0.4.2/tests/test_canonical_turns_schema.py +32 -0
  10. virtual_context-0.4.2/tests/test_canonical_turns_schema_postgres.py +47 -0
  11. virtual_context-0.4.2/tests/test_claim_compaction_lease_signature.py +147 -0
  12. virtual_context-0.4.2/tests/test_claim_compaction_lease_signature_postgres.py +114 -0
  13. virtual_context-0.4.2/tests/test_cleanup_abandoned_compaction.py +329 -0
  14. virtual_context-0.4.2/tests/test_cleanup_abandoned_compaction_postgres.py +381 -0
  15. virtual_context-0.4.2/tests/test_compacting_phase_takeover.py +834 -0
  16. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compaction_commit_prune.py +3 -23
  17. virtual_context-0.4.2/tests/test_compaction_heartbeat_sidecar.py +139 -0
  18. virtual_context-0.4.2/tests/test_compaction_lease_lost_abort.py +54 -0
  19. virtual_context-0.4.2/tests/test_compaction_lifecycle.py +170 -0
  20. virtual_context-0.4.2/tests/test_compaction_lifecycle_contract.py +69 -0
  21. virtual_context-0.4.2/tests/test_compaction_lifecycle_wiring.py +167 -0
  22. virtual_context-0.4.2/tests/test_compaction_operation.py +80 -0
  23. virtual_context-0.4.2/tests/test_compaction_operation_crud.py +396 -0
  24. virtual_context-0.4.2/tests/test_compaction_operation_crud_postgres.py +400 -0
  25. virtual_context-0.4.2/tests/test_compaction_operation_id_uuid.py +94 -0
  26. virtual_context-0.4.2/tests/test_compaction_operation_postgres.py +50 -0
  27. virtual_context-0.4.2/tests/test_compaction_ownership_guards.py +742 -0
  28. virtual_context-0.4.2/tests/test_compaction_progress_event.py +84 -0
  29. virtual_context-0.4.2/tests/test_compaction_resume_e2e_postgres.py +342 -0
  30. virtual_context-0.4.2/tests/test_compaction_resume_schema_migration.py +99 -0
  31. virtual_context-0.4.2/tests/test_compaction_resume_schema_migration_postgres.py +173 -0
  32. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_composite_store.py +12 -2
  33. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_coverage_report.py +44 -2
  34. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_lifecycle.py +7 -5
  35. virtual_context-0.4.2/tests/test_conversation_schema.py +19 -0
  36. virtual_context-0.4.2/tests/test_conversation_schema_postgres.py +35 -0
  37. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_deferred_payload_compaction.py +174 -77
  38. virtual_context-0.4.2/tests/test_delete_resurrect_epoch_races.py +179 -0
  39. virtual_context-0.4.2/tests/test_engine_event_bus.py +65 -0
  40. virtual_context-0.4.2/tests/test_engine_lifecycle_epoch.py +146 -0
  41. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_state.py +210 -40
  42. virtual_context-0.4.2/tests/test_engine_sync_turns.py +844 -0
  43. virtual_context-0.4.2/tests/test_filesystem_store_compaction_compat.py +172 -0
  44. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_find_quote.py +26 -26
  45. virtual_context-0.4.2/tests/test_find_stale_lease_helpers.py +192 -0
  46. virtual_context-0.4.2/tests/test_handle_prepare_payload.py +707 -0
  47. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_history_filter.py +1 -1
  48. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_ingest_index_integrity.py +8 -8
  49. virtual_context-0.4.2/tests/test_ingest_reconciler_append_fastpath.py +390 -0
  50. virtual_context-0.4.2/tests/test_ingest_reconciler_epoch.py +142 -0
  51. virtual_context-0.4.2/tests/test_ingestion_compaction_full_cycle.py +227 -0
  52. virtual_context-0.4.2/tests/test_ingestion_db_sweep_catchup.py +222 -0
  53. virtual_context-0.4.2/tests/test_ingestion_episode.py +74 -0
  54. virtual_context-0.4.2/tests/test_ingestion_episode_crud.py +198 -0
  55. virtual_context-0.4.2/tests/test_ingestion_episode_crud_postgres.py +301 -0
  56. virtual_context-0.4.2/tests/test_ingestion_episode_postgres.py +50 -0
  57. virtual_context-0.4.2/tests/test_ingestion_finalize_self_heal.py +291 -0
  58. virtual_context-0.4.2/tests/test_ingestion_progress_event.py +75 -0
  59. virtual_context-0.4.2/tests/test_ingestion_regression_resume.py +471 -0
  60. virtual_context-0.4.2/tests/test_legacy_ingestion_lease_ownership.py +1095 -0
  61. virtual_context-0.4.2/tests/test_lifecycle_epoch.py +47 -0
  62. virtual_context-0.4.2/tests/test_lifecycle_epoch_store.py +221 -0
  63. virtual_context-0.4.2/tests/test_lifecycle_epoch_store_postgres.py +290 -0
  64. virtual_context-0.4.2/tests/test_mark_canonical_turns_compacted_all_halves.py +194 -0
  65. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_monitor.py +1 -1
  66. virtual_context-0.4.2/tests/test_multi_worker_sliding_window.py +247 -0
  67. virtual_context-0.4.2/tests/test_neo4j_falkordb_compaction_compat.py +81 -0
  68. virtual_context-0.4.2/tests/test_orphan_compacting_repair.py +102 -0
  69. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_paging.py +10 -10
  70. virtual_context-0.4.2/tests/test_phase_transition_events.py +112 -0
  71. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_postgres_store.py +75 -0
  72. virtual_context-0.4.2/tests/test_progress_event_bus.py +91 -0
  73. virtual_context-0.4.2/tests/test_progress_events.py +74 -0
  74. virtual_context-0.4.2/tests/test_progress_snapshot.py +202 -0
  75. virtual_context-0.4.2/tests/test_progress_snapshot_postgres.py +334 -0
  76. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy.py +175 -5
  77. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_dashboard.py +6 -6
  78. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_message_filter.py +6 -6
  79. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_session.py +277 -8
  80. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_streaming.py +1 -1
  81. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_raw_content.py +14 -17
  82. virtual_context-0.4.2/tests/test_refresh_compaction_heartbeat.py +85 -0
  83. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_request_captures_persistence.py +110 -18
  84. virtual_context-0.4.2/tests/test_request_metadata.py +187 -0
  85. virtual_context-0.4.2/tests/test_request_metadata_postgres.py +196 -0
  86. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_semantic_search.py +7 -7
  87. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_sender_identity.py +2 -2
  88. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_date.py +109 -0
  89. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_state.py +31 -3
  90. virtual_context-0.4.2/tests/test_sqlite_schema_add_column_race.py +99 -0
  91. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_store_recovery.py +9 -8
  92. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_splitter.py +4 -4
  93. virtual_context-0.4.2/tests/test_tagger_loop.py +231 -0
  94. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_loop.py +1 -1
  95. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_result_filter.py +7 -8
  96. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_tags.py +4 -4
  97. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_turn_tag_index.py +2 -2
  98. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_vcattach.py +20 -9
  99. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/__init__.py +1 -1
  100. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/cli/main.py +2 -3
  101. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/assembler.py +6 -1
  102. virtual_context-0.4.2/virtual_context/core/canonical_turns.py +96 -0
  103. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/compaction_pipeline.py +253 -124
  104. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/compactor.py +18 -2
  105. virtual_context-0.4.2/virtual_context/core/composite_store.py +1096 -0
  106. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/conversation_store.py +10 -6
  107. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/coverage_report.py +12 -2
  108. virtual_context-0.4.2/virtual_context/core/event_bus.py +43 -0
  109. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/hint_builder.py +10 -11
  110. virtual_context-0.4.2/virtual_context/core/ingest_reconciler.py +844 -0
  111. virtual_context-0.4.2/virtual_context/core/lifecycle_epoch.py +25 -0
  112. virtual_context-0.4.2/virtual_context/core/progress_events.py +37 -0
  113. virtual_context-0.4.2/virtual_context/core/progress_snapshot.py +40 -0
  114. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/protocols.py +109 -24
  115. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/provider_adapters.py +12 -4
  116. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/quote_search.py +12 -29
  117. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retrieval_assembler.py +9 -6
  118. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/search_engine.py +3 -3
  119. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/segmenter.py +1 -1
  120. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/semantic_search.py +26 -36
  121. virtual_context-0.4.2/virtual_context/core/store.py +1112 -0
  122. virtual_context-0.4.2/virtual_context/core/tagging_pipeline.py +1514 -0
  123. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tool_query.py +1 -1
  124. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/turn_tag_index.py +38 -6
  125. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/engine.py +524 -299
  126. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/dashboard.html +7 -7
  127. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/dashboard.py +8 -8
  128. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/formats.py +237 -115
  129. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/handlers.py +37 -19
  130. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/helpers.py +17 -6
  131. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/message_filter.py +193 -27
  132. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/metrics.py +29 -8
  133. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/server.py +221 -61
  134. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/session_state.py +65 -21
  135. virtual_context-0.4.2/virtual_context/proxy/state.py +3854 -0
  136. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/falkordb.py +18 -2
  137. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/filesystem.py +37 -129
  138. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/neo4j.py +18 -2
  139. virtual_context-0.4.2/virtual_context/storage/postgres.py +5302 -0
  140. virtual_context-0.4.2/virtual_context/storage/sqlite.py +5987 -0
  141. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/types.py +105 -29
  142. virtual_context-0.3.8/.github/workflows/python-publish.yml +0 -37
  143. virtual_context-0.3.8/CLAUDE.md +0 -16
  144. virtual_context-0.3.8/specs/cache-aware-compaction.md +0 -573
  145. virtual_context-0.3.8/tests/test_engine_sync_turns.py +0 -195
  146. virtual_context-0.3.8/tests/test_full_text_backfill.py +0 -478
  147. virtual_context-0.3.8/virtual-context.yaml +0 -105
  148. virtual_context-0.3.8/virtual_context/core/composite_store.py +0 -546
  149. virtual_context-0.3.8/virtual_context/core/full_text_backfill.py +0 -691
  150. virtual_context-0.3.8/virtual_context/core/store.py +0 -627
  151. virtual_context-0.3.8/virtual_context/core/tagging_pipeline.py +0 -907
  152. virtual_context-0.3.8/virtual_context/proxy/state.py +0 -1967
  153. virtual_context-0.3.8/virtual_context/storage/postgres.py +0 -2900
  154. virtual_context-0.3.8/virtual_context/storage/sqlite.py +0 -3676
  155. {virtual_context-0.3.8 → virtual_context-0.4.2}/LICENSE +0 -0
  156. {virtual_context-0.3.8 → virtual_context-0.4.2}/README.md +0 -0
  157. {virtual_context-0.3.8 → virtual_context-0.4.2}/assets/dashboard.png +0 -0
  158. {virtual_context-0.3.8 → virtual_context-0.4.2}/assets/hero.png +0 -0
  159. {virtual_context-0.3.8 → virtual_context-0.4.2}/models.yaml +0 -0
  160. {virtual_context-0.3.8 → virtual_context-0.4.2}/scripts/install.ps1 +0 -0
  161. {virtual_context-0.3.8 → virtual_context-0.4.2}/scripts/install.sh +0 -0
  162. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/REGRESSION_MAP.md +0 -0
  163. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/conftest.py +0 -0
  164. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/docker-compose.test.yml +0 -0
  165. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/__init__.py +0 -0
  166. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/conftest.py +0 -0
  167. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_retrieval.py +0 -0
  168. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_tagging.py +0 -0
  169. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/__init__.py +0 -0
  170. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/conftest.py +0 -0
  171. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_compactor.py +0 -0
  172. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_pipeline.py +0 -0
  173. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_provider.py +0 -0
  174. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_tag_generator.py +0 -0
  175. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/__init__.py +0 -0
  176. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/test_dashboard_cors.py +0 -0
  177. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/test_metrics.py +0 -0
  178. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_assembler.py +0 -0
  179. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_backend_integration.py +0 -0
  180. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_beam_judge.py +0 -0
  181. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_budget_enforcement.py +0 -0
  182. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_cli_init.py +0 -0
  183. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compactor.py +0 -0
  184. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compactor_concurrent.py +0 -0
  185. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_config.py +0 -0
  186. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_context_bleed.py +0 -0
  187. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_identity.py +0 -0
  188. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_scoping.py +0 -0
  189. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_embedding_tag_generator.py +0 -0
  190. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_empty_turn_skip.py +0 -0
  191. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_integration.py +0 -0
  192. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_lookback.py +0 -0
  193. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_enrichment.py +0 -0
  194. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_graph_integration.py +0 -0
  195. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_checker.py +0 -0
  196. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_query.py +0 -0
  197. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_types.py +0 -0
  198. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_links_sqlite.py +0 -0
  199. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_redesign.py +0 -0
  200. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fill_pass.py +0 -0
  201. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_format_agnostic.py +0 -0
  202. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_headless.py +0 -0
  203. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_idf_retrieval.py +0 -0
  204. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_longmemeval_auth.py +0 -0
  205. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_mcp_server.py +0 -0
  206. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_media.py +0 -0
  207. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_message_filter.py +0 -0
  208. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_metrics_persistence.py +0 -0
  209. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_model_catalog.py +0 -0
  210. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_model_limits.py +0 -0
  211. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_multi_instance.py +0 -0
  212. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_noop_fact_link_store.py +0 -0
  213. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_openrouter_provider.py +0 -0
  214. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_passthrough_filter.py +0 -0
  215. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_presets.py +0 -0
  216. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_prev_context_leak.py +0 -0
  217. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_provider_adapters.py +0 -0
  218. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_formats.py +0 -0
  219. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_recall_all.py +0 -0
  220. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_registry_lifecycle.py +0 -0
  221. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_retriever.py +0 -0
  222. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_rrf_scoring.py +0 -0
  223. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_segmenter.py +0 -0
  224. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_cache.py +0 -0
  225. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_storage_protocols.py +0 -0
  226. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_store_sqlite.py +0 -0
  227. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_stub_turn_handling.py +0 -0
  228. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_supersession.py +0 -0
  229. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_supersession_migration.py +0 -0
  230. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_canonicalizer.py +0 -0
  231. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_consolidator.py +0 -0
  232. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_generator.py +0 -0
  233. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_telemetry.py +0 -0
  234. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_telemetry_integration.py +0 -0
  235. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_temporal_resolver.py +0 -0
  236. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_output_interceptor.py +0 -0
  237. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tui.py +0 -0
  238. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_turn_grouping.py +0 -0
  239. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_unified_budget.py +0 -0
  240. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_upstream_trim.py +0 -0
  241. {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_verb_expansion.py +0 -0
  242. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual-context.yaml.example +0 -0
  243. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/cli/__init__.py +0 -0
  244. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/config.py +0 -0
  245. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/conversation_identity.py +0 -0
  246. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/__init__.py +0 -0
  247. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/embedding_provider.py +0 -0
  248. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/embedding_tag_generator.py +0 -0
  249. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/engine_utils.py +0 -0
  250. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/fact_query.py +0 -0
  251. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/fts_preprocessor.py +0 -0
  252. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/llm_utils.py +0 -0
  253. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/math_utils.py +0 -0
  254. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/model_catalog.py +0 -0
  255. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/monitor.py +0 -0
  256. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/paging_manager.py +0 -0
  257. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retrieval_scoring.py +0 -0
  258. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retriever.py +0 -0
  259. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_canonicalizer.py +0 -0
  260. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_consolidator.py +0 -0
  261. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_generator.py +0 -0
  262. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_scoring.py +0 -0
  263. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_splitter.py +0 -0
  264. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/telemetry.py +0 -0
  265. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/temporal_resolver.py +0 -0
  266. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tool_loop.py +0 -0
  267. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/data/anthropic-tokenizer/tokenizer.json +0 -0
  268. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/data/models.yaml +0 -0
  269. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/__init__.py +0 -0
  270. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/curator.py +0 -0
  271. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/date_resolver.py +0 -0
  272. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/parsers.py +0 -0
  273. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/supersession.py +0 -0
  274. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/mcp/__init__.py +0 -0
  275. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/mcp/server.py +0 -0
  276. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/model_limits.py +0 -0
  277. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/openclaw/virtual-context.mjs +0 -0
  278. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/patterns.py +0 -0
  279. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/__init__.py +0 -0
  280. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/agentic.py +0 -0
  281. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/base.py +0 -0
  282. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/coding.py +0 -0
  283. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/__init__.py +0 -0
  284. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/anthropic.py +0 -0
  285. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/base.py +0 -0
  286. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/generic_openai.py +0 -0
  287. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/ollama_native.py +0 -0
  288. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/__init__.py +0 -0
  289. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/_envelope.py +0 -0
  290. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/media.py +0 -0
  291. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/multi.py +0 -0
  292. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/registry.py +0 -0
  293. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/session_cache.py +0 -0
  294. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/android-chrome-192x192.png +0 -0
  295. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/android-chrome-512x512.png +0 -0
  296. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/apple-touch-icon.png +0 -0
  297. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon-16x16.png +0 -0
  298. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon-32x32.png +0 -0
  299. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon.ico +0 -0
  300. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/site.webmanifest +0 -0
  301. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/tool_output_interceptor.py +0 -0
  302. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/vcattach.py +0 -0
  303. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/__init__.py +0 -0
  304. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/helpers.py +0 -0
  305. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/noop_fact_link_store.py +0 -0
  306. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/token_counter.py +0 -0
  307. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/__init__.py +0 -0
  308. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/app.py +0 -0
  309. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/chat.tcss +0 -0
  310. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/chat_provider.py +0 -0
  311. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/headless.py +0 -0
  312. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/modals/__init__.py +0 -0
  313. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/modals/turn_inspector.py +0 -0
  314. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/state.py +0 -0
  315. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/__init__.py +0 -0
  316. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/budget_bar.py +0 -0
  317. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/chat_view.py +0 -0
  318. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/input_box.py +0 -0
  319. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/tag_panel.py +0 -0
  320. {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/turn_list.py +0 -0
@@ -8,6 +8,9 @@ build/
8
8
  # Virtual environment
9
9
  .venv/
10
10
 
11
+ # Infrastructure configs (contains secrets — never commit)
12
+ infrastructure/
13
+
11
14
  # Testing
12
15
  .pytest_cache/
13
16
 
@@ -27,15 +30,22 @@ scripts/*
27
30
  !scripts/install.ps1
28
31
  !scripts/install.sh
29
32
 
33
+ # Environment files (API keys)
34
+ .env
35
+
30
36
  # Config files (may contain API keys)
31
37
  virtual-context-*.yaml
32
38
  !virtual-context.yaml.example
33
39
 
34
40
  # Internal docs
35
41
  CHANGELOG.md
42
+ CLAUDE.md
36
43
  docs/superpowers/
37
44
  docs/competitive-analysis.md
38
45
  diagnostics/
46
+ specs/
47
+ tests_timing_report.md
48
+ tests_by_section.sh
39
49
  uv.lock
40
50
  extraneous/paper/virtual_context_paper_latest.md
41
51
  extraneous/paper/data/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtual-context
3
- Version: 0.3.8
3
+ Version: 0.4.2
4
4
  Summary: OS-style virtual memory for LLM session context management
5
5
  Project-URL: Homepage, https://virtual-context.com
6
6
  Project-URL: Repository, https://github.com/virtual-context/virtual-context
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "virtual-context"
7
- version = "0.3.8"
7
+ version = "0.4.2"
8
8
  description = "OS-style virtual memory for LLM session context management"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-or-later"
@@ -67,6 +67,15 @@ exclude = [
67
67
  ".virtualcontext/",
68
68
  "*.db",
69
69
  "*.log",
70
+ # Internal / private — never ship in sdist
71
+ "CLAUDE.md",
72
+ "tests_timing_report.md",
73
+ "tests_by_section.sh",
74
+ "specs/",
75
+ "virtual-context.yaml",
76
+ ".github/",
77
+ ".gitignore",
78
+ ".worktrees/",
70
79
  ]
71
80
  artifacts = [
72
81
  "virtual_context/proxy/static/*",
@@ -94,8 +94,8 @@ class TestCompactionCycle:
94
94
  history = _simulate_turns(engine, pairs)
95
95
 
96
96
  # Compaction should have fired
97
- assert engine._engine_state.compacted_through > 0, (
98
- f"Expected compaction, but _compacted_through={engine._engine_state.compacted_through}"
97
+ assert engine._engine_state.compacted_prefix_messages > 0, (
98
+ f"Expected compaction, but _compacted_through={engine._engine_state.compacted_prefix_messages}"
99
99
  )
100
100
 
101
101
  # Post-compaction retrieval should still work
@@ -204,7 +204,7 @@ class TestCompactionCycle:
204
204
  history = _simulate_turns(engine, pairs)
205
205
 
206
206
  # Should have compacted multiple times
207
- assert engine._engine_state.compacted_through > 0
207
+ assert engine._engine_state.compacted_prefix_messages > 0
208
208
 
209
209
  # Engine should still respond without errors
210
210
  result = engine.on_message_inbound("What topics did we cover?", history)
@@ -0,0 +1,172 @@
1
+ """Task 16: ProxyState._active_compaction_op four-site lifecycle invariant.
2
+
3
+ Tests:
4
+ 18a: test_takeover_sets_active_compaction_op_before_submit
5
+ 18b: test_wrapper_finally_clears_active_compaction_op_on_normal_exit
6
+ 18c: test_wrapper_finally_clears_active_compaction_op_on_exception
7
+ 18d: test_submit_exception_clears_active_compaction_op (regression guard)
8
+
9
+ Test 18e (same-worker re-enter after submit exception) is deferred to
10
+ tests/test_compacting_phase_takeover.py (Task 18) because it requires the
11
+ handle_prepare_payload compacting-branch takeover wiring that Task 18 adds.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
17
+
18
+ from concurrent.futures import Future
19
+ from pathlib import Path
20
+ from unittest.mock import MagicMock, patch
21
+
22
+ import pytest
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 18a: _submit_compaction_request sets _active_compaction_op BEFORE submitting
27
+ # the future when preexisting_operation_id is provided.
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def test_takeover_sets_active_compaction_op_before_submit(tmp_path: Path):
31
+ """_active_compaction_op must be set to preexisting_operation_id before
32
+ the thread-pool submit call so the takeover predicate is already live if
33
+ a concurrent worker checks between set and submit.
34
+ """
35
+ from tests.test_handle_prepare_payload import _make_proxy_state
36
+
37
+ state = _make_proxy_state(tmp_path)
38
+
39
+ captured_op_at_submit: list[str | None] = []
40
+
41
+ original_submit = state._compact_pool.submit
42
+
43
+ def capturing_submit(fn, *args, **kwargs):
44
+ # Capture the value of _active_compaction_op at the moment submit fires
45
+ captured_op_at_submit.append(state._active_compaction_op)
46
+ # Return a done future so the wrapper doesn't block
47
+ f: Future[None] = Future()
48
+ f.set_result(None)
49
+ return f
50
+
51
+ with patch.object(state._compact_pool, "submit", side_effect=capturing_submit), \
52
+ patch.object(state, "_run_compact_wrapper"):
53
+ state._submit_compaction_request(
54
+ history=[],
55
+ signal=None,
56
+ turn=0,
57
+ target_end=5,
58
+ turn_id="",
59
+ preexisting_operation_id="op-abc-123",
60
+ )
61
+
62
+ assert len(captured_op_at_submit) == 1, "submit must be called exactly once"
63
+ assert captured_op_at_submit[0] == "op-abc-123", (
64
+ f"_active_compaction_op must equal preexisting_operation_id at submit time, "
65
+ f"got {captured_op_at_submit[0]!r}"
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # 18b: _run_compact_wrapper finally clears _active_compaction_op on normal exit
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def test_wrapper_finally_clears_active_compaction_op_on_normal_exit(tmp_path: Path):
74
+ """After _run_compact_wrapper returns normally, _active_compaction_op must
75
+ be None regardless of what it was set to before the call.
76
+ """
77
+ from tests.test_handle_prepare_payload import _make_proxy_state
78
+
79
+ state = _make_proxy_state(tmp_path)
80
+ state._active_compaction_op = "op-should-be-cleared"
81
+
82
+ with patch.object(state, "_run_compact", return_value=None):
83
+ state._run_compact_wrapper(
84
+ history=[],
85
+ signal=None,
86
+ turn=0,
87
+ target_end=5,
88
+ turn_id="",
89
+ preexisting_operation_id="op-should-be-cleared",
90
+ )
91
+
92
+ assert state._active_compaction_op is None, (
93
+ f"_active_compaction_op must be None after wrapper normal exit, "
94
+ f"got {state._active_compaction_op!r}"
95
+ )
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # 18c: _run_compact_wrapper finally clears _active_compaction_op on exception
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def test_wrapper_finally_clears_active_compaction_op_on_exception(tmp_path: Path):
103
+ """Even when _run_compact raises, _run_compact_wrapper's finally block must
104
+ clear _active_compaction_op so future takeover checks are not blocked.
105
+ """
106
+ from tests.test_handle_prepare_payload import _make_proxy_state
107
+
108
+ state = _make_proxy_state(tmp_path)
109
+ state._active_compaction_op = "op-raise-case"
110
+
111
+ with patch.object(state, "_run_compact", side_effect=RuntimeError("boom")):
112
+ # The wrapper must NOT re-raise (it already swallows exceptions via
113
+ # the existing try/finally structure — the finally still runs).
114
+ try:
115
+ state._run_compact_wrapper(
116
+ history=[],
117
+ signal=None,
118
+ turn=0,
119
+ target_end=5,
120
+ turn_id="",
121
+ preexisting_operation_id="op-raise-case",
122
+ )
123
+ except Exception:
124
+ # Whether the wrapper propagates or swallows, _active_compaction_op
125
+ # must be None after the call returns/raises.
126
+ pass
127
+
128
+ assert state._active_compaction_op is None, (
129
+ f"_active_compaction_op must be None after wrapper exception exit, "
130
+ f"got {state._active_compaction_op!r}"
131
+ )
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # 18d: submit exception clears _active_compaction_op (CRITICAL regression guard)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def test_submit_exception_clears_active_compaction_op(tmp_path: Path):
139
+ """If the thread-pool submit() call itself raises (e.g. pool shutdown),
140
+ _submit_compaction_request must clear _active_compaction_op back to None
141
+ so subsequent requests are not permanently blocked by a stale op-id.
142
+
143
+ This is the regression guard: without the except-clause cleanup, a pool
144
+ shutdown exception would leave _active_compaction_op set forever and every
145
+ future takeover check would see a non-None value for an operation that
146
+ never ran.
147
+ """
148
+ from tests.test_handle_prepare_payload import _make_proxy_state
149
+
150
+ state = _make_proxy_state(tmp_path)
151
+
152
+ assert state._active_compaction_op is None, "pre-condition: starts as None"
153
+
154
+ with patch.object(
155
+ state._compact_pool,
156
+ "submit",
157
+ side_effect=RuntimeError("pool is shut down"),
158
+ ):
159
+ with pytest.raises(RuntimeError, match="pool is shut down"):
160
+ state._submit_compaction_request(
161
+ history=[],
162
+ signal=None,
163
+ turn=0,
164
+ target_end=5,
165
+ turn_id="",
166
+ preexisting_operation_id="op-submit-fail",
167
+ )
168
+
169
+ assert state._active_compaction_op is None, (
170
+ f"_active_compaction_op must be reset to None after submit() raises, "
171
+ f"got {state._active_compaction_op!r}"
172
+ )
@@ -0,0 +1,224 @@
1
+ """Tests for epoch-guarded ``iter_untagged_canonical_rows`` +
2
+ ``mark_canonical_row_tagged`` on the SQLite backend.
3
+
4
+ Both methods scope their SQL to ``conversations.lifecycle_epoch`` so a stale
5
+ caller (whose in-memory epoch no longer matches the authoritative row) sees
6
+ zero rows on iter and a ``False`` return on mark. This keeps concurrent
7
+ delete/resurrect workflows from letting a background tagger touch rows that
8
+ belong to a new lifecycle.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from virtual_context.storage.sqlite import SQLiteStore
16
+ from virtual_context.core.canonical_turns import utcnow_iso
17
+
18
+
19
+ def _seed_canonical_row(
20
+ s: SQLiteStore,
21
+ *,
22
+ conv_id: str,
23
+ canonical_id: str,
24
+ sort_key: float,
25
+ tagged: bool = False,
26
+ ) -> None:
27
+ now = utcnow_iso()
28
+ with s._get_conn() as conn:
29
+ conn.execute(
30
+ """
31
+ INSERT INTO canonical_turns (
32
+ canonical_turn_id, conversation_id, turn_hash, hash_version,
33
+ normalized_user_text, normalized_assistant_text,
34
+ user_content, assistant_content,
35
+ sort_key, source_batch_id, first_seen_at, last_seen_at,
36
+ covered_ingestible_entries, tagged_at,
37
+ created_at, updated_at
38
+ ) VALUES (?, ?, ?, 1, 'u','a','u_raw','a_raw', ?, 'b', ?, ?, 1, ?, ?, ?)
39
+ """,
40
+ (
41
+ canonical_id,
42
+ conv_id,
43
+ f"h_{canonical_id}",
44
+ sort_key,
45
+ now,
46
+ now,
47
+ now if tagged else None,
48
+ now,
49
+ now,
50
+ ),
51
+ )
52
+
53
+
54
+ def test_iter_untagged_returns_untagged_in_sort_key_order(tmp_path: Path) -> None:
55
+ s = SQLiteStore(tmp_path / "vc.db")
56
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
57
+ _seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=3000.0)
58
+ _seed_canonical_row(s, conv_id="c", canonical_id="t1", sort_key=1000.0)
59
+ _seed_canonical_row(s, conv_id="c", canonical_id="t2", sort_key=2000.0)
60
+ rows = s.iter_untagged_canonical_rows(
61
+ conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
62
+ )
63
+ ids_in_order = [r.canonical_turn_id for r in rows]
64
+ assert ids_in_order == ["t1", "t2", "t0"] # sorted by sort_key
65
+
66
+
67
+ def test_iter_untagged_respects_batch_size(tmp_path: Path) -> None:
68
+ s = SQLiteStore(tmp_path / "vc.db")
69
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
70
+ for i in range(5):
71
+ _seed_canonical_row(s, conv_id="c", canonical_id=f"t{i}", sort_key=float(i * 1000))
72
+ rows = s.iter_untagged_canonical_rows(
73
+ conversation_id="c", expected_lifecycle_epoch=1, batch_size=2,
74
+ )
75
+ assert len(rows) == 2
76
+
77
+
78
+ def test_iter_untagged_excludes_tagged_rows(tmp_path: Path) -> None:
79
+ s = SQLiteStore(tmp_path / "vc.db")
80
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
81
+ _seed_canonical_row(s, conv_id="c", canonical_id="t_tagged", sort_key=1000.0, tagged=True)
82
+ _seed_canonical_row(s, conv_id="c", canonical_id="t_pending", sort_key=2000.0, tagged=False)
83
+ rows = s.iter_untagged_canonical_rows(
84
+ conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
85
+ )
86
+ assert [r.canonical_turn_id for r in rows] == ["t_pending"]
87
+
88
+
89
+ def test_iter_untagged_returns_empty_on_epoch_mismatch(tmp_path: Path) -> None:
90
+ """SQL-level epoch guard. Stale-epoch caller sees no rows even if rows exist."""
91
+ s = SQLiteStore(tmp_path / "vc.db")
92
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
93
+ _seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0)
94
+ s.mark_conversation_deleted("c")
95
+ s.increment_lifecycle_epoch_on_resurrect("c") # epoch=2
96
+ # Seed a new-lifecycle row.
97
+ _seed_canonical_row(s, conv_id="c", canonical_id="t_new", sort_key=2000.0)
98
+ # Stale caller: epoch=1.
99
+ rows = s.iter_untagged_canonical_rows(
100
+ conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
101
+ )
102
+ assert rows == [] # sees nothing
103
+ # Current caller: epoch=2 — sees both (t0 from old lifecycle not purged, plus t_new).
104
+ rows = s.iter_untagged_canonical_rows(
105
+ conversation_id="c", expected_lifecycle_epoch=2, batch_size=10,
106
+ )
107
+ assert len(rows) == 2
108
+
109
+
110
+ def test_mark_canonical_row_tagged_sets_timestamp(tmp_path: Path) -> None:
111
+ s = SQLiteStore(tmp_path / "vc.db")
112
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
113
+ _seed_canonical_row(s, conv_id="c", canonical_id="t_pending", sort_key=1000.0)
114
+ ok = s.mark_canonical_row_tagged(
115
+ canonical_turn_id="t_pending", conversation_id="c",
116
+ expected_lifecycle_epoch=1,
117
+ )
118
+ assert ok is True
119
+ with s._get_conn() as conn:
120
+ row = conn.execute(
121
+ "SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id='t_pending'"
122
+ ).fetchone()
123
+ assert row[0] is not None
124
+
125
+
126
+ def test_mark_canonical_row_tagged_rejects_on_epoch_mismatch(tmp_path: Path) -> None:
127
+ """Proves SQL-level epoch guard on the mark boundary."""
128
+ s = SQLiteStore(tmp_path / "vc.db")
129
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
130
+ _seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0)
131
+ s.mark_conversation_deleted("c")
132
+ s.increment_lifecycle_epoch_on_resurrect("c") # epoch=2
133
+ # Stale caller tries to mark a row that still exists.
134
+ ok = s.mark_canonical_row_tagged(
135
+ canonical_turn_id="t0", conversation_id="c",
136
+ expected_lifecycle_epoch=1,
137
+ )
138
+ assert ok is False
139
+ with s._get_conn() as conn:
140
+ row = conn.execute(
141
+ "SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id='t0'"
142
+ ).fetchone()
143
+ assert row[0] is None # untouched
144
+
145
+
146
+ def test_mark_canonical_row_tagged_is_idempotent_for_already_tagged(tmp_path: Path) -> None:
147
+ """Calling mark_canonical_row_tagged a second time on an already-tagged row returns False (no-op)."""
148
+ s = SQLiteStore(tmp_path / "vc.db")
149
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
150
+ _seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0, tagged=True)
151
+ ok = s.mark_canonical_row_tagged(
152
+ canonical_turn_id="t0", conversation_id="c",
153
+ expected_lifecycle_epoch=1,
154
+ )
155
+ assert ok is False # already tagged, WHERE tagged_at IS NULL excludes it
156
+
157
+
158
+ def test_mark_canonical_row_tagged_wrong_conversation_id_returns_false(tmp_path: Path) -> None:
159
+ """Belt-and-suspenders: mismatched conversation_id doesn't accidentally mark another conversation's row."""
160
+ s = SQLiteStore(tmp_path / "vc.db")
161
+ s.upsert_conversation(tenant_id="t", conversation_id="c1")
162
+ s.upsert_conversation(tenant_id="t", conversation_id="c2")
163
+ _seed_canonical_row(s, conv_id="c1", canonical_id="t0", sort_key=1000.0)
164
+ ok = s.mark_canonical_row_tagged(
165
+ canonical_turn_id="t0", conversation_id="c2", # wrong conv
166
+ expected_lifecycle_epoch=1,
167
+ )
168
+ assert ok is False
169
+
170
+
171
+ def test_iter_untagged_uses_partial_index(tmp_path: Path) -> None:
172
+ """``iter_untagged_canonical_rows`` must drive the partial index
173
+ ``idx_canonical_turns_conv_untagged`` and NOT fall back to a full scan.
174
+
175
+ The A19 implementation originally routed through the
176
+ ``canonical_turns_ordinal`` VIEW so the row parser could read
177
+ ``row["turn_number"]``. SQLite's planner cannot push the partial index
178
+ through the view's ``ROW_NUMBER()`` co-routine, producing a plan like
179
+ ``CO-ROUTINE canonical_turns_ordinal + SCAN ct + USE TEMP B-TREE FOR
180
+ ORDER BY`` — O(all rows in conversation) regardless of
181
+ ``tagged_at``. The tagger calls this in a tight loop; missing the index
182
+ is the regression this test locks in.
183
+ """
184
+ s = SQLiteStore(tmp_path / "vc.db")
185
+ s.upsert_conversation(tenant_id="t", conversation_id="c")
186
+ with s._get_conn() as conn:
187
+ plan = list(conn.execute(
188
+ """
189
+ EXPLAIN QUERY PLAN
190
+ SELECT ct.canonical_turn_id, ct.conversation_id, ct.turn_group_number,
191
+ ct.sort_key, ct.turn_hash, ct.hash_version,
192
+ ct.normalized_user_text, ct.normalized_assistant_text,
193
+ ct.user_content, ct.assistant_content,
194
+ ct.user_raw_content, ct.assistant_raw_content,
195
+ ct.primary_tag, ct.tags_json, ct.session_date, ct.sender,
196
+ ct.fact_signals_json, ct.code_refs_json,
197
+ ct.tagged_at, ct.compacted_at,
198
+ ct.first_seen_at, ct.last_seen_at,
199
+ ct.source_batch_id, ct.created_at, ct.updated_at
200
+ FROM canonical_turns AS ct
201
+ JOIN conversations AS c
202
+ ON c.conversation_id = ct.conversation_id
203
+ WHERE ct.conversation_id = ?
204
+ AND ct.tagged_at IS NULL
205
+ AND c.lifecycle_epoch = ?
206
+ ORDER BY ct.sort_key ASC
207
+ LIMIT ?
208
+ """,
209
+ ("c", 1, 10),
210
+ ).fetchall())
211
+ detail = " | ".join(str(tuple(row)) for row in plan)
212
+ assert "idx_canonical_turns_conv_untagged" in detail, (
213
+ f"iter_untagged_canonical_rows is not using the partial index. "
214
+ f"Plan was: {detail}"
215
+ )
216
+ # Also guard against the view regression: a plan that mentions
217
+ # ``canonical_turns_ordinal`` or ``TEMP B-TREE FOR ORDER BY`` means we
218
+ # fell back to the view path and lost the index.
219
+ assert "canonical_turns_ordinal" not in detail, (
220
+ f"Query must not route through the view. Plan was: {detail}"
221
+ )
222
+ assert "TEMP B-TREE FOR ORDER BY" not in detail, (
223
+ f"Query must not fall back to a temp sort. Plan was: {detail}"
224
+ )
@@ -0,0 +1,200 @@
1
+ """Tests for epoch-guarded ``iter_untagged_canonical_rows`` +
2
+ ``mark_canonical_row_tagged`` on the Postgres backend.
3
+
4
+ Skipped unless ``VC_TEST_POSTGRES_URL`` is set. Mirrors
5
+ ``test_canonical_row_epoch_guards.py`` (SQLite) 1:1 so both backends stay in
6
+ lockstep on the SQL-level epoch fence. ``conversations.conversation_id`` is
7
+ ``TEXT PRIMARY KEY`` (aligned with ``canonical_turns.conversation_id``), and
8
+ ``canonical_turns.canonical_turn_id`` is ``UUID``, so test conversation IDs
9
+ use ``str(uuid.uuid4())`` and canonical IDs use ``uuid.uuid4()``.
10
+ ``source_batch_id`` is also UUID, so the seed helper generates a fresh UUID
11
+ per row. psycopg3 adapts ``datetime`` directly so we pass
12
+ ``datetime.now(timezone.utc)`` rather than ISO strings for the
13
+ ``TIMESTAMPTZ`` lifecycle columns on ``canonical_turns`` — except that
14
+ ``tagged_at`` / ``first_seen_at`` / ``last_seen_at`` / ``created_at`` /
15
+ ``updated_at`` on ``canonical_turns`` are ``TEXT`` in the Postgres schema
16
+ (not TIMESTAMPTZ), so ISO strings are the right shape for those.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+
25
+ import pytest
26
+
27
+ PG_URL = os.environ.get("VC_TEST_POSTGRES_URL")
28
+ pytestmark = pytest.mark.skipif(not PG_URL, reason="VC_TEST_POSTGRES_URL not set")
29
+
30
+
31
+ def _store():
32
+ from virtual_context.storage.postgres import PostgresStore # deferred
33
+ return PostgresStore(PG_URL)
34
+
35
+
36
+ def _fresh(conv_id: str | None = None):
37
+ """Create a fresh conversation with a unique UUID and return (store, conv_id)."""
38
+ conv_id = conv_id or str(uuid.uuid4())
39
+ s = _store()
40
+ s.upsert_conversation(tenant_id="t", conversation_id=conv_id)
41
+ return s, conv_id
42
+
43
+
44
+ def _seed_canonical_row(
45
+ s,
46
+ *,
47
+ conv_id: str,
48
+ canonical_id: str,
49
+ sort_key: float,
50
+ tagged: bool = False,
51
+ ) -> None:
52
+ now_iso = datetime.now(timezone.utc).isoformat()
53
+ conn = s._get_conn()
54
+ conn.execute(
55
+ """
56
+ INSERT INTO canonical_turns (
57
+ canonical_turn_id, conversation_id, turn_hash, hash_version,
58
+ normalized_user_text, normalized_assistant_text,
59
+ user_content, assistant_content,
60
+ sort_key, source_batch_id, first_seen_at, last_seen_at,
61
+ covered_ingestible_entries, tagged_at,
62
+ created_at, updated_at
63
+ ) VALUES (%s, %s, %s, 1, 'u','a','u_raw','a_raw', %s, %s, %s, %s, 1, %s, %s, %s)
64
+ """,
65
+ (
66
+ canonical_id,
67
+ conv_id,
68
+ f"h_{canonical_id}",
69
+ sort_key,
70
+ uuid.uuid4(), # source_batch_id (UUID)
71
+ now_iso,
72
+ now_iso,
73
+ now_iso if tagged else None,
74
+ now_iso,
75
+ now_iso,
76
+ ),
77
+ )
78
+
79
+
80
+ def test_iter_untagged_returns_untagged_in_sort_key_order_pg():
81
+ s, cid = _fresh()
82
+ ids = [str(uuid.uuid4()) for _ in range(3)]
83
+ _seed_canonical_row(s, conv_id=cid, canonical_id=ids[0], sort_key=3000.0)
84
+ _seed_canonical_row(s, conv_id=cid, canonical_id=ids[1], sort_key=1000.0)
85
+ _seed_canonical_row(s, conv_id=cid, canonical_id=ids[2], sort_key=2000.0)
86
+ rows = s.iter_untagged_canonical_rows(
87
+ conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
88
+ )
89
+ ids_in_order = [r.canonical_turn_id for r in rows]
90
+ assert ids_in_order == [ids[1], ids[2], ids[0]] # sorted by sort_key
91
+
92
+
93
+ def test_iter_untagged_respects_batch_size_pg():
94
+ s, cid = _fresh()
95
+ for i in range(5):
96
+ _seed_canonical_row(
97
+ s, conv_id=cid, canonical_id=str(uuid.uuid4()),
98
+ sort_key=float(i * 1000),
99
+ )
100
+ rows = s.iter_untagged_canonical_rows(
101
+ conversation_id=cid, expected_lifecycle_epoch=1, batch_size=2,
102
+ )
103
+ assert len(rows) == 2
104
+
105
+
106
+ def test_iter_untagged_excludes_tagged_rows_pg():
107
+ s, cid = _fresh()
108
+ t_tagged = str(uuid.uuid4())
109
+ t_pending = str(uuid.uuid4())
110
+ _seed_canonical_row(s, conv_id=cid, canonical_id=t_tagged, sort_key=1000.0, tagged=True)
111
+ _seed_canonical_row(s, conv_id=cid, canonical_id=t_pending, sort_key=2000.0, tagged=False)
112
+ rows = s.iter_untagged_canonical_rows(
113
+ conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
114
+ )
115
+ assert [r.canonical_turn_id for r in rows] == [t_pending]
116
+
117
+
118
+ def test_iter_untagged_returns_empty_on_epoch_mismatch_pg():
119
+ """SQL-level epoch guard. Stale-epoch caller sees no rows even if rows exist."""
120
+ s, cid = _fresh()
121
+ old_id = str(uuid.uuid4())
122
+ _seed_canonical_row(s, conv_id=cid, canonical_id=old_id, sort_key=1000.0)
123
+ s.mark_conversation_deleted(cid)
124
+ s.increment_lifecycle_epoch_on_resurrect(cid) # epoch=2
125
+ # Seed a new-lifecycle row.
126
+ new_id = str(uuid.uuid4())
127
+ _seed_canonical_row(s, conv_id=cid, canonical_id=new_id, sort_key=2000.0)
128
+ # Stale caller: epoch=1.
129
+ rows = s.iter_untagged_canonical_rows(
130
+ conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
131
+ )
132
+ assert rows == [] # sees nothing
133
+ # Current caller: epoch=2 — sees both (old from old lifecycle not purged, plus new).
134
+ rows = s.iter_untagged_canonical_rows(
135
+ conversation_id=cid, expected_lifecycle_epoch=2, batch_size=10,
136
+ )
137
+ assert len(rows) == 2
138
+
139
+
140
+ def test_mark_canonical_row_tagged_sets_timestamp_pg():
141
+ s, cid = _fresh()
142
+ t_pending = str(uuid.uuid4())
143
+ _seed_canonical_row(s, conv_id=cid, canonical_id=t_pending, sort_key=1000.0)
144
+ ok = s.mark_canonical_row_tagged(
145
+ canonical_turn_id=t_pending, conversation_id=cid,
146
+ expected_lifecycle_epoch=1,
147
+ )
148
+ assert ok is True
149
+ conn = s._get_conn()
150
+ row = conn.execute(
151
+ "SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id = %s",
152
+ (t_pending,),
153
+ ).fetchone()
154
+ assert row["tagged_at"] is not None
155
+
156
+
157
+ def test_mark_canonical_row_tagged_rejects_on_epoch_mismatch_pg():
158
+ """Proves SQL-level epoch guard on the mark boundary."""
159
+ s, cid = _fresh()
160
+ t0 = str(uuid.uuid4())
161
+ _seed_canonical_row(s, conv_id=cid, canonical_id=t0, sort_key=1000.0)
162
+ s.mark_conversation_deleted(cid)
163
+ s.increment_lifecycle_epoch_on_resurrect(cid) # epoch=2
164
+ # Stale caller tries to mark a row that still exists.
165
+ ok = s.mark_canonical_row_tagged(
166
+ canonical_turn_id=t0, conversation_id=cid,
167
+ expected_lifecycle_epoch=1,
168
+ )
169
+ assert ok is False
170
+ conn = s._get_conn()
171
+ row = conn.execute(
172
+ "SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id = %s",
173
+ (t0,),
174
+ ).fetchone()
175
+ assert row["tagged_at"] is None # untouched
176
+
177
+
178
+ def test_mark_canonical_row_tagged_is_idempotent_for_already_tagged_pg():
179
+ """Calling mark_canonical_row_tagged a second time on an already-tagged row returns False (no-op)."""
180
+ s, cid = _fresh()
181
+ t0 = str(uuid.uuid4())
182
+ _seed_canonical_row(s, conv_id=cid, canonical_id=t0, sort_key=1000.0, tagged=True)
183
+ ok = s.mark_canonical_row_tagged(
184
+ canonical_turn_id=t0, conversation_id=cid,
185
+ expected_lifecycle_epoch=1,
186
+ )
187
+ assert ok is False # already tagged, WHERE tagged_at IS NULL excludes it
188
+
189
+
190
+ def test_mark_canonical_row_tagged_wrong_conversation_id_returns_false_pg():
191
+ """Belt-and-suspenders: mismatched conversation_id doesn't accidentally mark another conversation's row."""
192
+ s1, c1 = _fresh()
193
+ _, c2 = _fresh()
194
+ t0 = str(uuid.uuid4())
195
+ _seed_canonical_row(s1, conv_id=c1, canonical_id=t0, sort_key=1000.0)
196
+ ok = s1.mark_canonical_row_tagged(
197
+ canonical_turn_id=t0, conversation_id=c2, # wrong conv
198
+ expected_lifecycle_epoch=1,
199
+ )
200
+ assert ok is False