virtual-context 0.2.4__tar.gz → 0.2.7__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 (221) hide show
  1. {virtual_context-0.2.4 → virtual_context-0.2.7}/PKG-INFO +6 -2
  2. {virtual_context-0.2.4 → virtual_context-0.2.7}/README.md +4 -1
  3. virtual_context-0.2.7/assets/dashboard.png +0 -0
  4. {virtual_context-0.2.4 → virtual_context-0.2.7}/pyproject.toml +2 -1
  5. virtual_context-0.2.7/tests/test_budget_enforcement.py +560 -0
  6. virtual_context-0.2.7/tests/test_format_agnostic.py +614 -0
  7. virtual_context-0.2.7/tests/test_media.py +363 -0
  8. virtual_context-0.2.7/tests/test_turn_grouping.py +150 -0
  9. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/__init__.py +1 -1
  10. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/conversation_identity.py +22 -6
  11. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/composite_store.py +6 -0
  12. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/protocols.py +2 -0
  13. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/store.py +27 -0
  14. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/formats.py +632 -0
  15. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/handlers.py +26 -0
  16. virtual_context-0.2.7/virtual_context/proxy/media.py +333 -0
  17. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/message_filter.py +1153 -567
  18. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/server.py +176 -6
  19. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/postgres.py +68 -0
  20. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/sqlite.py +68 -0
  21. {virtual_context-0.2.4 → virtual_context-0.2.7}/.gitignore +0 -0
  22. {virtual_context-0.2.4 → virtual_context-0.2.7}/LICENSE +0 -0
  23. {virtual_context-0.2.4 → virtual_context-0.2.7}/assets/hero.png +0 -0
  24. {virtual_context-0.2.4 → virtual_context-0.2.7}/models.yaml +0 -0
  25. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/REGRESSION_MAP.md +0 -0
  26. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/conftest.py +0 -0
  27. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/docker-compose.test.yml +0 -0
  28. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/haiku/__init__.py +0 -0
  29. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/haiku/conftest.py +0 -0
  30. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/haiku/test_compaction.py +0 -0
  31. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/haiku/test_retrieval.py +0 -0
  32. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/haiku/test_tagging.py +0 -0
  33. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/__init__.py +0 -0
  34. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/conftest.py +0 -0
  35. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/test_compactor.py +0 -0
  36. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/test_pipeline.py +0 -0
  37. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/test_provider.py +0 -0
  38. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/ollama/test_tag_generator.py +0 -0
  39. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/proxy/__init__.py +0 -0
  40. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/proxy/test_dashboard_cors.py +0 -0
  41. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/proxy/test_metrics.py +0 -0
  42. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_assembler.py +0 -0
  43. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_backend_integration.py +0 -0
  44. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_cli_init.py +0 -0
  45. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_compaction_commit_prune.py +0 -0
  46. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_compactor.py +0 -0
  47. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_compactor_concurrent.py +0 -0
  48. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_composite_store.py +0 -0
  49. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_config.py +0 -0
  50. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_context_bleed.py +0 -0
  51. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_conversation_identity.py +0 -0
  52. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_conversation_lifecycle.py +0 -0
  53. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_conversation_scoping.py +0 -0
  54. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_embedding_tag_generator.py +0 -0
  55. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_empty_turn_skip.py +0 -0
  56. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_engine_integration.py +0 -0
  57. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_engine_lookback.py +0 -0
  58. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_engine_state.py +0 -0
  59. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_enrichment.py +0 -0
  60. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_graph_integration.py +0 -0
  61. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_link_checker.py +0 -0
  62. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_link_query.py +0 -0
  63. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_link_types.py +0 -0
  64. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_fact_links_sqlite.py +0 -0
  65. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_find_quote.py +0 -0
  66. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_headless.py +0 -0
  67. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_history_filter.py +0 -0
  68. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_idf_retrieval.py +0 -0
  69. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_ingest_index_integrity.py +0 -0
  70. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_longmemeval_auth.py +0 -0
  71. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_mcp_server.py +0 -0
  72. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_message_filter.py +0 -0
  73. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_metrics_persistence.py +0 -0
  74. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_model_catalog.py +0 -0
  75. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_model_limits.py +0 -0
  76. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_monitor.py +0 -0
  77. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_multi_instance.py +0 -0
  78. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_noop_fact_link_store.py +0 -0
  79. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_openrouter_provider.py +0 -0
  80. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_paging.py +0 -0
  81. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_passthrough_filter.py +0 -0
  82. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_presets.py +0 -0
  83. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_prev_context_leak.py +0 -0
  84. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_provider_adapters.py +0 -0
  85. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy.py +0 -0
  86. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy_dashboard.py +0 -0
  87. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy_formats.py +0 -0
  88. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy_message_filter.py +0 -0
  89. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy_session.py +0 -0
  90. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_proxy_streaming.py +0 -0
  91. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_raw_content.py +0 -0
  92. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_recall_all.py +0 -0
  93. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_registry_lifecycle.py +0 -0
  94. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_request_captures_persistence.py +0 -0
  95. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_retriever.py +0 -0
  96. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_rrf_scoring.py +0 -0
  97. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_segmenter.py +0 -0
  98. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_semantic_search.py +0 -0
  99. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_sender_identity.py +0 -0
  100. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_session_cache.py +0 -0
  101. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_session_date.py +0 -0
  102. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_storage_protocols.py +0 -0
  103. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_store_sqlite.py +0 -0
  104. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_stub_turn_handling.py +0 -0
  105. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_supersession.py +0 -0
  106. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_supersession_migration.py +0 -0
  107. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tag_canonicalizer.py +0 -0
  108. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tag_consolidator.py +0 -0
  109. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tag_generator.py +0 -0
  110. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tag_splitter.py +0 -0
  111. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_telemetry.py +0 -0
  112. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_telemetry_integration.py +0 -0
  113. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tool_loop.py +0 -0
  114. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tool_output_interceptor.py +0 -0
  115. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tool_result_filter.py +0 -0
  116. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tool_tags.py +0 -0
  117. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_tui.py +0 -0
  118. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_turn_tag_index.py +0 -0
  119. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_unified_budget.py +0 -0
  120. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_upstream_trim.py +0 -0
  121. {virtual_context-0.2.4 → virtual_context-0.2.7}/tests/test_verb_expansion.py +0 -0
  122. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual-context.yaml.example +0 -0
  123. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/cli/__init__.py +0 -0
  124. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/cli/main.py +0 -0
  125. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/config.py +0 -0
  126. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/__init__.py +0 -0
  127. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/assembler.py +0 -0
  128. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/compaction_pipeline.py +0 -0
  129. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/compactor.py +0 -0
  130. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/conversation_store.py +0 -0
  131. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/embedding_provider.py +0 -0
  132. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/embedding_tag_generator.py +0 -0
  133. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/engine_utils.py +0 -0
  134. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/fact_query.py +0 -0
  135. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/fts_preprocessor.py +0 -0
  136. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/hint_builder.py +0 -0
  137. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/llm_utils.py +0 -0
  138. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/math_utils.py +0 -0
  139. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/model_catalog.py +0 -0
  140. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/monitor.py +0 -0
  141. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/paging_manager.py +0 -0
  142. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/provider_adapters.py +0 -0
  143. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/quote_search.py +0 -0
  144. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/retrieval_assembler.py +0 -0
  145. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/retrieval_scoring.py +0 -0
  146. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/retriever.py +0 -0
  147. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/search_engine.py +0 -0
  148. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/segmenter.py +0 -0
  149. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/semantic_search.py +0 -0
  150. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tag_canonicalizer.py +0 -0
  151. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tag_consolidator.py +0 -0
  152. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tag_generator.py +0 -0
  153. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tag_scoring.py +0 -0
  154. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tag_splitter.py +0 -0
  155. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tagging_pipeline.py +0 -0
  156. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/telemetry.py +0 -0
  157. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/temporal_resolver.py +0 -0
  158. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tool_loop.py +0 -0
  159. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/tool_query.py +0 -0
  160. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/core/turn_tag_index.py +0 -0
  161. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/data/anthropic-tokenizer/tokenizer.json +0 -0
  162. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/engine.py +0 -0
  163. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/ingest/__init__.py +0 -0
  164. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/ingest/curator.py +0 -0
  165. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/ingest/date_resolver.py +0 -0
  166. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/ingest/parsers.py +0 -0
  167. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/ingest/supersession.py +0 -0
  168. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/mcp/__init__.py +0 -0
  169. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/mcp/server.py +0 -0
  170. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/model_limits.py +0 -0
  171. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/openclaw/virtual-context.mjs +0 -0
  172. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/patterns.py +0 -0
  173. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/presets/__init__.py +0 -0
  174. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/presets/agentic.py +0 -0
  175. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/presets/base.py +0 -0
  176. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/presets/coding.py +0 -0
  177. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/providers/__init__.py +0 -0
  178. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/providers/anthropic.py +0 -0
  179. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/providers/base.py +0 -0
  180. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/providers/generic_openai.py +0 -0
  181. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/providers/ollama_native.py +0 -0
  182. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/__init__.py +0 -0
  183. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/_envelope.py +0 -0
  184. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/dashboard.html +0 -0
  185. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/dashboard.py +0 -0
  186. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/helpers.py +0 -0
  187. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/metrics.py +0 -0
  188. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/multi.py +0 -0
  189. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/registry.py +0 -0
  190. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/session_cache.py +0 -0
  191. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/state.py +0 -0
  192. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/android-chrome-192x192.png +0 -0
  193. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/android-chrome-512x512.png +0 -0
  194. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/apple-touch-icon.png +0 -0
  195. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/favicon-16x16.png +0 -0
  196. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/favicon-32x32.png +0 -0
  197. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/favicon.ico +0 -0
  198. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/static/site.webmanifest +0 -0
  199. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/proxy/tool_output_interceptor.py +0 -0
  200. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/__init__.py +0 -0
  201. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/falkordb.py +0 -0
  202. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/filesystem.py +0 -0
  203. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/helpers.py +0 -0
  204. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/neo4j.py +0 -0
  205. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/storage/noop_fact_link_store.py +0 -0
  206. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/token_counter.py +0 -0
  207. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/__init__.py +0 -0
  208. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/app.py +0 -0
  209. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/chat.tcss +0 -0
  210. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/chat_provider.py +0 -0
  211. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/headless.py +0 -0
  212. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/modals/__init__.py +0 -0
  213. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/modals/turn_inspector.py +0 -0
  214. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/state.py +0 -0
  215. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/__init__.py +0 -0
  216. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/budget_bar.py +0 -0
  217. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/chat_view.py +0 -0
  218. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/input_box.py +0 -0
  219. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/tag_panel.py +0 -0
  220. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/tui/widgets/turn_list.py +0 -0
  221. {virtual_context-0.2.4 → virtual_context-0.2.7}/virtual_context/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtual-context
3
- Version: 0.2.4
3
+ Version: 0.2.7
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
@@ -19,6 +19,7 @@ Requires-Dist: fastapi>=0.115
19
19
  Requires-Dist: httpx>=0.27
20
20
  Requires-Dist: mcp>=1.0
21
21
  Requires-Dist: openai>=1.50
22
+ Requires-Dist: pillow>=10.0
22
23
  Requires-Dist: pyyaml>=6.0
23
24
  Requires-Dist: rich>=13.0
24
25
  Requires-Dist: sentence-transformers>=3.0
@@ -45,8 +46,11 @@ Requires-Dist: redis>=5.0; extra == 'redis'
45
46
  Description-Content-Type: text/markdown
46
47
 
47
48
  <p align="center">
48
- <img src="assets/hero.png" alt="virtual-context" width="100%">
49
+ <a href="assets/dashboard.png">
50
+ <img src="assets/dashboard.png" alt="virtual-context dashboard" width="800">
51
+ </a>
49
52
  </p>
53
+ <p align="center"><sub>virtual-context cloud - running 3 million virtual token window at 80k actual tokens</sub></p>
50
54
 
51
55
  # virtual-context
52
56
 
@@ -1,6 +1,9 @@
1
1
  <p align="center">
2
- <img src="assets/hero.png" alt="virtual-context" width="100%">
2
+ <a href="assets/dashboard.png">
3
+ <img src="assets/dashboard.png" alt="virtual-context dashboard" width="800">
4
+ </a>
3
5
  </p>
6
+ <p align="center"><sub>virtual-context cloud - running 3 million virtual token window at 80k actual tokens</sub></p>
4
7
 
5
8
  # virtual-context
6
9
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "virtual-context"
7
- version = "0.2.4"
7
+ version = "0.2.7"
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"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "mcp>=1.0",
31
31
  "textual>=3.0",
32
32
  "rich>=13.0",
33
+ "Pillow>=10.0",
33
34
  ]
34
35
 
35
36
  [project.optional-dependencies]
@@ -0,0 +1,560 @@
1
+ import copy
2
+ import json
3
+ import pytest
4
+ from virtual_context.proxy.message_filter import scan_reducible_items, apply_reduction, ReducibleItem, enforce_payload_budget
5
+ from virtual_context.proxy.formats import detect_format
6
+
7
+
8
+ def _make_payload(messages, system=None, tools=None):
9
+ body = {"model": "claude-sonnet-4-6", "messages": messages}
10
+ if system:
11
+ body["system"] = system
12
+ if tools:
13
+ body["tools"] = tools
14
+ return body
15
+
16
+
17
+ class TestScanReducibleItems:
18
+ def test_finds_thinking_signatures(self):
19
+ msgs = [
20
+ {"role": "user", "content": "hello"},
21
+ {"role": "assistant", "content": [
22
+ {"type": "thinking", "thinking": "", "signature": "A" * 4000},
23
+ {"type": "text", "text": "hi there"},
24
+ ]},
25
+ {"role": "user", "content": "bye"},
26
+ ]
27
+ body = _make_payload(msgs)
28
+ fmt = detect_format(body)
29
+ items = scan_reducible_items(body, fmt)
30
+ thinking_items = [i for i in items if i.category == "thinking_sig"]
31
+ assert len(thinking_items) == 1
32
+ assert thinking_items[0].msg_index == 1
33
+ assert thinking_items[0].block_index == 0
34
+ assert thinking_items[0].size_bytes > 0
35
+
36
+ def test_finds_tool_results(self):
37
+ # Need 3+ turn groups so the tool result falls outside the last-2 window.
38
+ msgs = [
39
+ {"role": "user", "content": "old question"},
40
+ {"role": "assistant", "content": "old answer"},
41
+ {"role": "user", "content": "do something"},
42
+ {"role": "assistant", "content": [
43
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
44
+ ]},
45
+ {"role": "user", "content": [
46
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
47
+ ]},
48
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
49
+ {"role": "user", "content": "thanks"},
50
+ {"role": "assistant", "content": "welcome"},
51
+ {"role": "user", "content": "current"},
52
+ ]
53
+ body = _make_payload(msgs)
54
+ fmt = detect_format(body)
55
+ items = scan_reducible_items(body, fmt)
56
+ tr_items = [i for i in items if i.category == "tool_result"]
57
+ assert len(tr_items) == 1
58
+ assert tr_items[0].msg_index == 4
59
+
60
+ def test_last_2_turns_tool_results_categorized_separately(self):
61
+ msgs = [
62
+ {"role": "user", "content": "old"},
63
+ {"role": "assistant", "content": "old response"},
64
+ {"role": "user", "content": "do something"},
65
+ {"role": "assistant", "content": [
66
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
67
+ ]},
68
+ {"role": "user", "content": [
69
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
70
+ ]},
71
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
72
+ {"role": "user", "content": "thanks"},
73
+ ]
74
+ body = _make_payload(msgs)
75
+ fmt = detect_format(body)
76
+ items = scan_reducible_items(body, fmt)
77
+ last2_items = [i for i in items if i.category == "tool_result_last2"]
78
+ assert len(last2_items) == 1
79
+
80
+ def test_finds_conversation_text(self):
81
+ msgs = [
82
+ {"role": "user", "content": "hello " * 1000},
83
+ {"role": "assistant", "content": "response " * 1000},
84
+ {"role": "user", "content": "bye"},
85
+ ]
86
+ body = _make_payload(msgs)
87
+ fmt = detect_format(body)
88
+ items = scan_reducible_items(body, fmt)
89
+ text_items = [i for i in items if i.category == "conversation_text"]
90
+ assert len(text_items) >= 2
91
+
92
+ def test_finds_vc_context_block(self):
93
+ msgs = [
94
+ {"role": "user", "content": "hello"},
95
+ {"role": "assistant", "content": "hi"},
96
+ ]
97
+ system = [
98
+ {"type": "text", "text": "You are Claude Code."},
99
+ {"type": "text", "text": "<system-reminder>\n<context-topics>VC content here</context-topics>\n</system-reminder>"},
100
+ ]
101
+ body = _make_payload(msgs, system=system)
102
+ fmt = detect_format(body)
103
+ items = scan_reducible_items(body, fmt)
104
+ vc_items = [i for i in items if i.category == "vc_context"]
105
+ assert len(vc_items) == 1
106
+
107
+ def test_skips_compacted_stubs(self):
108
+ msgs = [
109
+ {"role": "user", "content": "[Compacted turn 0]"},
110
+ {"role": "assistant", "content": [{"type": "text", "text": "[Compacted turn 0 | topics=foo | Read(...).\\nTo restore: ...]"}]},
111
+ {"role": "user", "content": "hello"},
112
+ {"role": "assistant", "content": "hi"},
113
+ ]
114
+ body = _make_payload(msgs)
115
+ fmt = detect_format(body)
116
+ items = scan_reducible_items(body, fmt)
117
+ for item in items:
118
+ assert item.category != "compacted_stub"
119
+
120
+ def test_skips_system_prompt_non_vc(self):
121
+ msgs = [
122
+ {"role": "user", "content": "hello"},
123
+ {"role": "assistant", "content": "hi"},
124
+ ]
125
+ system = [{"type": "text", "text": "You are Claude Code. " * 500}]
126
+ body = _make_payload(msgs, system=system)
127
+ fmt = detect_format(body)
128
+ items = scan_reducible_items(body, fmt)
129
+ categories = {i.category for i in items}
130
+ assert "system_prompt" not in categories
131
+
132
+ def test_finds_large_image_blocks(self):
133
+ msgs = [
134
+ {"role": "user", "content": [
135
+ {"type": "image", "source": {"type": "base64", "data": "A" * 50000}},
136
+ {"type": "text", "text": "what is this?"},
137
+ ]},
138
+ {"role": "assistant", "content": "It's an image."},
139
+ ]
140
+ body = _make_payload(msgs)
141
+ fmt = detect_format(body)
142
+ items = scan_reducible_items(body, fmt)
143
+ image_items = [i for i in items if i.category == "image"]
144
+ assert len(image_items) == 1
145
+ assert image_items[0].size_bytes == 50000
146
+
147
+ def test_skips_small_image_blocks(self):
148
+ msgs = [
149
+ {"role": "user", "content": [
150
+ {"type": "image", "source": {"type": "base64", "data": "A" * 100}},
151
+ ]},
152
+ {"role": "assistant", "content": "tiny image."},
153
+ ]
154
+ body = _make_payload(msgs)
155
+ fmt = detect_format(body)
156
+ items = scan_reducible_items(body, fmt)
157
+ assert not any(i.category == "image" for i in items)
158
+
159
+ def test_finds_openai_responses_tool_outputs(self):
160
+ body = {"model": "gpt-5", "input": [
161
+ {"role": "user", "content": "do something"},
162
+ {"role": "assistant", "content": [{"type": "output_text", "text": "calling"}]},
163
+ {"type": "function_call", "call_id": "fc1", "name": "read", "arguments": "{}"},
164
+ {"type": "function_call_output", "call_id": "fc1", "output": "x" * 10000},
165
+ {"role": "assistant", "content": [{"type": "output_text", "text": "done"}]},
166
+ {"role": "user", "content": "thanks"},
167
+ ]}
168
+ fmt = detect_format(body)
169
+ items = scan_reducible_items(body, fmt)
170
+ tr_items = [i for i in items if i.category in ("tool_result", "tool_result_last2")]
171
+ assert len(tr_items) == 1
172
+ assert tr_items[0].msg_index == 3
173
+
174
+ def test_finds_openai_chat_tool_outputs(self):
175
+ body = {"model": "gpt-4", "messages": [
176
+ {"role": "user", "content": "do something"},
177
+ {"role": "assistant", "content": None, "tool_calls": [
178
+ {"id": "t1", "type": "function", "function": {"name": "read", "arguments": "{}"}}
179
+ ]},
180
+ {"role": "tool", "tool_call_id": "t1", "content": "x" * 10000},
181
+ {"role": "assistant", "content": "done"},
182
+ {"role": "user", "content": "thanks"},
183
+ ]}
184
+ fmt = detect_format(body)
185
+ items = scan_reducible_items(body, fmt)
186
+ tr_items = [i for i in items if i.category in ("tool_result", "tool_result_last2")]
187
+ assert len(tr_items) == 1
188
+ assert tr_items[0].msg_index == 2
189
+
190
+ def test_finds_openai_responses_conversation_text(self):
191
+ """Verify we find text in output_text blocks, not just string content."""
192
+ body = {"model": "gpt-5", "input": [
193
+ {"role": "user", "content": "msg " + "x" * 2000},
194
+ {"role": "assistant", "content": [{"type": "output_text", "text": "resp " + "y" * 2000}]},
195
+ {"role": "user", "content": "current"},
196
+ ]}
197
+ fmt = detect_format(body)
198
+ items = scan_reducible_items(body, fmt)
199
+ text_items = [i for i in items if i.category == "conversation_text"]
200
+ # Should find both the user string content AND the assistant output_text block
201
+ assert len(text_items) >= 2
202
+
203
+
204
+ class TestApplyReduction:
205
+ def test_remove_thinking_signature(self):
206
+ msgs = [
207
+ {"role": "user", "content": "hello"},
208
+ {"role": "assistant", "content": [
209
+ {"type": "thinking", "thinking": "", "signature": "A" * 4000},
210
+ {"type": "text", "text": "response"},
211
+ ]},
212
+ ]
213
+ body = {"model": "claude-sonnet-4-6", "messages": msgs}
214
+ item = ReducibleItem(msg_index=1, block_index=0, category="thinking_sig",
215
+ size_bytes=4000, location="messages")
216
+ freed = apply_reduction(body, item, detect_format(body))
217
+ assert freed > 0
218
+ content = body["messages"][1]["content"]
219
+ assert not any(b.get("type") == "thinking" for b in content)
220
+
221
+ def test_stub_tool_result(self):
222
+ msgs = [
223
+ {"role": "user", "content": "do something"},
224
+ {"role": "assistant", "content": [
225
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "/tmp/x"}},
226
+ ]},
227
+ {"role": "user", "content": [
228
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
229
+ ]},
230
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
231
+ {"role": "user", "content": "thanks"},
232
+ ]
233
+ body = {"model": "claude-sonnet-4-6", "messages": msgs}
234
+ item = ReducibleItem(msg_index=2, block_index=0, category="tool_result",
235
+ size_bytes=10000, location="messages")
236
+ freed = apply_reduction(body, item, detect_format(body), store=None)
237
+ assert freed > 0
238
+ tr = body["messages"][2]["content"][0]
239
+ assert "vc_restore_tool" in tr.get("content", "") or len(tr.get("content", "")) < 500
240
+
241
+ def test_truncate_tool_result_last2(self):
242
+ msgs = [
243
+ {"role": "user", "content": [
244
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
245
+ ]},
246
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
247
+ ]
248
+ body = {"model": "claude-sonnet-4-6", "messages": msgs}
249
+ item = ReducibleItem(msg_index=0, block_index=0, category="tool_result_last2",
250
+ size_bytes=10000, location="messages")
251
+ freed = apply_reduction(body, item, detect_format(body))
252
+ assert freed > 0
253
+ tr = body["messages"][0]["content"][0]
254
+ assert len(tr.get("content", "")) < 10000
255
+
256
+ def test_truncate_conversation_text_string(self):
257
+ msgs = [
258
+ {"role": "user", "content": "hello " * 2000},
259
+ {"role": "assistant", "content": "bye"},
260
+ ]
261
+ body = {"model": "claude-sonnet-4-6", "messages": msgs}
262
+ item = ReducibleItem(msg_index=0, block_index=-1, category="conversation_text",
263
+ size_bytes=12000, location="messages")
264
+ freed = apply_reduction(body, item, detect_format(body))
265
+ assert freed > 0
266
+ assert len(body["messages"][0]["content"]) < 12000
267
+
268
+ def test_truncate_vc_context(self):
269
+ system = [
270
+ {"type": "text", "text": "You are Claude Code."},
271
+ {"type": "text", "text": "<system-reminder>\n<context-topics>" + "x" * 10000 + "</context-topics>\n</system-reminder>"},
272
+ ]
273
+ body = {"model": "claude-sonnet-4-6", "messages": [{"role": "user", "content": "hi"}], "system": system}
274
+ item = ReducibleItem(msg_index=-1, block_index=1, category="vc_context",
275
+ size_bytes=10050, location="system")
276
+ freed = apply_reduction(body, item, detect_format(body))
277
+ assert freed > 0
278
+ assert len(body["system"][1]["text"]) < 10050
279
+
280
+ def test_compress_image(self):
281
+ import base64
282
+ from io import BytesIO
283
+ try:
284
+ from PIL import Image
285
+ except ImportError:
286
+ pytest.skip("Pillow not installed")
287
+ img = Image.new("RGB", (2000, 2000), color="red")
288
+ buf = BytesIO()
289
+ img.save(buf, format="PNG")
290
+ b64_data = base64.b64encode(buf.getvalue()).decode("ascii")
291
+ msgs = [
292
+ {"role": "user", "content": [
293
+ {"type": "image", "source": {"type": "base64", "data": b64_data, "media_type": "image/png"}},
294
+ {"type": "text", "text": "what is this?"},
295
+ ]},
296
+ {"role": "assistant", "content": "red square"},
297
+ ]
298
+ body = _make_payload(msgs)
299
+ item = ReducibleItem(msg_index=0, block_index=0, category="image",
300
+ size_bytes=len(b64_data), location="messages")
301
+ freed = apply_reduction(body, item, detect_format(body))
302
+ assert freed > 0
303
+ assert body["messages"][0]["content"][0]["source"]["media_type"] == "image/jpeg"
304
+ assert len(body["messages"][0]["content"][0]["source"]["data"]) < len(b64_data)
305
+ new_data = base64.b64decode(body["messages"][0]["content"][0]["source"]["data"])
306
+ new_img = Image.open(BytesIO(new_data))
307
+ assert new_img.width <= 1024
308
+ assert new_img.height <= 1024
309
+
310
+
311
+ class TestEnforcePayloadBudget:
312
+ def _fmt(self):
313
+ fmt = detect_format({"model": "claude-sonnet-4-6", "messages": []})
314
+ fmt_copy = copy.copy(fmt)
315
+ fmt_copy.set_token_counter(lambda text: len(text) // 4)
316
+ return fmt_copy
317
+
318
+ def test_no_reduction_when_under_budget(self):
319
+ body = _make_payload([
320
+ {"role": "user", "content": "hello"},
321
+ {"role": "assistant", "content": "hi"},
322
+ ])
323
+ fmt = self._fmt()
324
+ result, reductions, freed = enforce_payload_budget(body, fmt, 100000)
325
+ assert reductions == 0
326
+ assert freed == 0
327
+
328
+ def test_reduces_until_under_budget(self):
329
+ msgs = [
330
+ {"role": "user", "content": "hello"},
331
+ {"role": "assistant", "content": [
332
+ {"type": "thinking", "thinking": "", "signature": "A" * 8000},
333
+ {"type": "text", "text": "response"},
334
+ ]},
335
+ {"role": "user", "content": "more"},
336
+ {"role": "assistant", "content": [
337
+ {"type": "thinking", "thinking": "", "signature": "B" * 8000},
338
+ {"type": "text", "text": "more response"},
339
+ ]},
340
+ {"role": "user", "content": "bye"},
341
+ ]
342
+ body = _make_payload(msgs)
343
+ fmt = self._fmt()
344
+ total_before = fmt._count(json.dumps(body))
345
+ budget = total_before - 2000
346
+ result, reductions, freed = enforce_payload_budget(body, fmt, budget)
347
+ total_after = fmt._count(json.dumps(result))
348
+ assert total_after <= budget
349
+ assert reductions > 0
350
+ assert freed > 0
351
+
352
+ def test_targets_largest_first(self):
353
+ msgs = [
354
+ {"role": "user", "content": "hello"},
355
+ {"role": "assistant", "content": [
356
+ {"type": "thinking", "thinking": "", "signature": "A" * 2000},
357
+ {"type": "text", "text": "response"},
358
+ ]},
359
+ {"role": "user", "content": "more"},
360
+ {"role": "assistant", "content": [
361
+ {"type": "thinking", "thinking": "", "signature": "B" * 8000},
362
+ {"type": "text", "text": "more"},
363
+ ]},
364
+ {"role": "user", "content": "bye"},
365
+ ]
366
+ body = _make_payload(msgs)
367
+ fmt = self._fmt()
368
+ total = fmt._count(json.dumps(body))
369
+ budget = total - 1000
370
+ result, reductions, freed = enforce_payload_budget(body, fmt, budget)
371
+ content_3 = result["messages"][3]["content"]
372
+ has_thinking_3 = any(b.get("type") == "thinking" for b in content_3)
373
+ assert not has_thinking_3
374
+
375
+ def test_respects_max_iterations(self):
376
+ msgs = []
377
+ for i in range(300):
378
+ msgs.append({"role": "user", "content": "x" * 500})
379
+ msgs.append({"role": "assistant", "content": "y" * 500})
380
+ body = _make_payload(msgs)
381
+ fmt = self._fmt()
382
+ result, reductions, freed = enforce_payload_budget(body, fmt, 1)
383
+ assert reductions <= 200
384
+
385
+ def test_returns_unchanged_when_nothing_reducible(self):
386
+ body = _make_payload([
387
+ {"role": "user", "content": "hi"},
388
+ {"role": "assistant", "content": "bye"},
389
+ ])
390
+ fmt = self._fmt()
391
+ result, reductions, freed = enforce_payload_budget(body, fmt, 1)
392
+ assert reductions == 0
393
+
394
+ def test_terminates_when_largest_not_reducible(self):
395
+ msgs = [
396
+ {"role": "user", "content": "x" * 200},
397
+ {"role": "assistant", "content": "y" * 200},
398
+ ]
399
+ body = _make_payload(msgs)
400
+ fmt = self._fmt()
401
+ result, reductions, freed = enforce_payload_budget(body, fmt, 1)
402
+ assert reductions <= 2
403
+
404
+ def test_reduces_openai_responses_payload(self):
405
+ items = []
406
+ for i in range(20):
407
+ items.append({"role": "user", "content": "msg " + "x" * 2000})
408
+ items.append({"role": "assistant", "content": [{"type": "output_text", "text": "resp " + "y" * 2000}]})
409
+ items.append({"role": "user", "content": "current"})
410
+ body = {"model": "gpt-5", "input": items}
411
+ fmt = detect_format(body)
412
+ fmt_copy = copy.copy(fmt)
413
+ fmt_copy.set_token_counter(lambda text: len(text) // 4)
414
+ result, reductions, freed = enforce_payload_budget(body, fmt_copy, 5000)
415
+ assert reductions > 0
416
+
417
+
418
+ class TestSmallPayloadProtection:
419
+ """P1-1: With 1-2 turn groups, ALL tool results must be in the last2 protected window."""
420
+
421
+ def test_single_tool_turn_all_protected(self):
422
+ """A payload with one tool chain turn — tool result must be tool_result_last2."""
423
+ msgs = [
424
+ {"role": "user", "content": "do something"},
425
+ {"role": "assistant", "content": [
426
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
427
+ ]},
428
+ {"role": "user", "content": [
429
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
430
+ ]},
431
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
432
+ ]
433
+ body = _make_payload(msgs)
434
+ fmt = detect_format(body)
435
+ items = scan_reducible_items(body, fmt)
436
+ tr_items = [i for i in items if i.category.startswith("tool_result")]
437
+ assert len(tr_items) == 1
438
+ # With only 1 turn group, the tool result must be in the protected window.
439
+ assert tr_items[0].category == "tool_result_last2"
440
+
441
+ def test_two_turns_all_protected(self):
442
+ """A payload with 2 turn groups — tool results in both must be last2."""
443
+ msgs = [
444
+ {"role": "user", "content": "do something"},
445
+ {"role": "assistant", "content": [
446
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
447
+ ]},
448
+ {"role": "user", "content": [
449
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
450
+ ]},
451
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
452
+ {"role": "user", "content": "thanks"},
453
+ {"role": "assistant", "content": "welcome"},
454
+ ]
455
+ body = _make_payload(msgs)
456
+ fmt = detect_format(body)
457
+ items = scan_reducible_items(body, fmt)
458
+ tr_items = [i for i in items if i.category.startswith("tool_result")]
459
+ assert len(tr_items) == 1
460
+ assert tr_items[0].category == "tool_result_last2"
461
+
462
+ def test_no_unprotected_tool_results_with_small_payload(self):
463
+ """With <=2 turns, no tool_result items should exist in the unprotected category."""
464
+ msgs = [
465
+ {"role": "user", "content": "do something"},
466
+ {"role": "assistant", "content": [
467
+ {"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
468
+ ]},
469
+ {"role": "user", "content": [
470
+ {"type": "tool_result", "tool_use_id": "t1", "content": "x" * 10000},
471
+ ]},
472
+ {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
473
+ {"role": "user", "content": "current"},
474
+ ]
475
+ body = _make_payload(msgs)
476
+ fmt = detect_format(body)
477
+ items = scan_reducible_items(body, fmt)
478
+ unprotected = [i for i in items if i.category == "tool_result"]
479
+ assert len(unprotected) == 0
480
+
481
+
482
+ class TestGeminiMediaScan:
483
+ """P1-3: Gemini inline_data images must be detected by scan_reducible_items."""
484
+
485
+ def test_finds_gemini_inline_data_image(self):
486
+ body = {"contents": [
487
+ {"role": "user", "parts": [
488
+ {"inline_data": {"mime_type": "image/png", "data": "A" * 50000}},
489
+ {"text": "what is this?"},
490
+ ]},
491
+ {"role": "model", "parts": [{"text": "It is an image."}]},
492
+ {"role": "user", "parts": [{"text": "q2"}]},
493
+ {"role": "model", "parts": [{"text": "a2"}]},
494
+ {"role": "user", "parts": [{"text": "current"}]},
495
+ ]}
496
+ fmt = detect_format(body)
497
+ items = scan_reducible_items(body, fmt)
498
+ image_items = [i for i in items if i.category == "image"]
499
+ assert len(image_items) == 1
500
+ assert image_items[0].size_bytes == 50000
501
+
502
+ def test_skips_small_gemini_image(self):
503
+ body = {"contents": [
504
+ {"role": "user", "parts": [
505
+ {"inline_data": {"mime_type": "image/png", "data": "A" * 100}},
506
+ ]},
507
+ {"role": "model", "parts": [{"text": "tiny."}]},
508
+ ]}
509
+ fmt = detect_format(body)
510
+ items = scan_reducible_items(body, fmt)
511
+ assert not any(i.category == "image" for i in items)
512
+
513
+
514
+ class TestResponsesInputImageScan:
515
+ """P1-2: OpenAI Responses input_image blocks must be yielded by iter_media_blocks."""
516
+
517
+ def test_iter_media_blocks_finds_input_image(self):
518
+ body = {"model": "gpt-5", "input": [
519
+ {"role": "user", "content": [
520
+ {"type": "input_image", "image_url": f"data:image/png;base64,{'A' * 50000}"},
521
+ {"type": "input_text", "text": "describe this"},
522
+ ]},
523
+ {"role": "assistant", "content": [{"type": "output_text", "text": "a photo"}]},
524
+ ]}
525
+ fmt = detect_format(body)
526
+ media_blocks = list(fmt.iter_media_blocks(body))
527
+ assert len(media_blocks) == 1
528
+ assert media_blocks[0].msg_index == 0
529
+ assert media_blocks[0].block_index == 0
530
+
531
+ def test_input_image_replace_uses_input_text_for_user(self):
532
+ body = {"model": "gpt-5", "input": [
533
+ {"role": "user", "content": [
534
+ {"type": "input_image", "image_url": f"data:image/png;base64,{'A' * 50000}"},
535
+ ]},
536
+ {"role": "assistant", "content": [{"type": "output_text", "text": "done"}]},
537
+ ]}
538
+ fmt = detect_format(body)
539
+ for media_info in fmt.iter_media_blocks(body):
540
+ media_info.replace_with_text("[image removed]")
541
+ break
542
+ replaced = body["input"][0]["content"][0]
543
+ assert replaced["type"] == "input_text"
544
+ assert replaced["text"] == "[image removed]"
545
+
546
+ def test_scan_finds_input_image_as_image_category(self):
547
+ body = {"model": "gpt-5", "input": [
548
+ {"role": "user", "content": [
549
+ {"type": "input_image", "image_url": f"data:image/png;base64,{'A' * 50000}"},
550
+ ]},
551
+ {"role": "assistant", "content": [{"type": "output_text", "text": "a photo"}]},
552
+ {"role": "user", "content": "q2"},
553
+ {"role": "assistant", "content": [{"type": "output_text", "text": "a2"}]},
554
+ {"role": "user", "content": "current"},
555
+ ]}
556
+ fmt = detect_format(body)
557
+ items = scan_reducible_items(body, fmt)
558
+ image_items = [i for i in items if i.category == "image"]
559
+ assert len(image_items) == 1
560
+ assert image_items[0].size_bytes == 50000