virtual-context 0.2.4__tar.gz → 0.2.6__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 (220) hide show
  1. {virtual_context-0.2.4 → virtual_context-0.2.6}/PKG-INFO +2 -1
  2. {virtual_context-0.2.4 → virtual_context-0.2.6}/pyproject.toml +2 -1
  3. virtual_context-0.2.6/tests/test_budget_enforcement.py +560 -0
  4. virtual_context-0.2.6/tests/test_format_agnostic.py +614 -0
  5. virtual_context-0.2.6/tests/test_media.py +363 -0
  6. virtual_context-0.2.6/tests/test_turn_grouping.py +150 -0
  7. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/__init__.py +1 -1
  8. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/conversation_identity.py +18 -6
  9. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/composite_store.py +6 -0
  10. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/protocols.py +2 -0
  11. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/store.py +27 -0
  12. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/formats.py +632 -0
  13. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/handlers.py +26 -0
  14. virtual_context-0.2.6/virtual_context/proxy/media.py +333 -0
  15. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/message_filter.py +1153 -567
  16. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/server.py +176 -6
  17. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/postgres.py +68 -0
  18. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/sqlite.py +68 -0
  19. {virtual_context-0.2.4 → virtual_context-0.2.6}/.gitignore +0 -0
  20. {virtual_context-0.2.4 → virtual_context-0.2.6}/LICENSE +0 -0
  21. {virtual_context-0.2.4 → virtual_context-0.2.6}/README.md +0 -0
  22. {virtual_context-0.2.4 → virtual_context-0.2.6}/assets/hero.png +0 -0
  23. {virtual_context-0.2.4 → virtual_context-0.2.6}/models.yaml +0 -0
  24. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/REGRESSION_MAP.md +0 -0
  25. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/conftest.py +0 -0
  26. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/docker-compose.test.yml +0 -0
  27. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/haiku/__init__.py +0 -0
  28. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/haiku/conftest.py +0 -0
  29. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/haiku/test_compaction.py +0 -0
  30. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/haiku/test_retrieval.py +0 -0
  31. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/haiku/test_tagging.py +0 -0
  32. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/__init__.py +0 -0
  33. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/conftest.py +0 -0
  34. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/test_compactor.py +0 -0
  35. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/test_pipeline.py +0 -0
  36. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/test_provider.py +0 -0
  37. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/ollama/test_tag_generator.py +0 -0
  38. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/proxy/__init__.py +0 -0
  39. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/proxy/test_dashboard_cors.py +0 -0
  40. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/proxy/test_metrics.py +0 -0
  41. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_assembler.py +0 -0
  42. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_backend_integration.py +0 -0
  43. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_cli_init.py +0 -0
  44. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_compaction_commit_prune.py +0 -0
  45. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_compactor.py +0 -0
  46. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_compactor_concurrent.py +0 -0
  47. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_composite_store.py +0 -0
  48. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_config.py +0 -0
  49. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_context_bleed.py +0 -0
  50. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_conversation_identity.py +0 -0
  51. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_conversation_lifecycle.py +0 -0
  52. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_conversation_scoping.py +0 -0
  53. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_embedding_tag_generator.py +0 -0
  54. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_empty_turn_skip.py +0 -0
  55. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_engine_integration.py +0 -0
  56. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_engine_lookback.py +0 -0
  57. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_engine_state.py +0 -0
  58. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_enrichment.py +0 -0
  59. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_graph_integration.py +0 -0
  60. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_link_checker.py +0 -0
  61. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_link_query.py +0 -0
  62. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_link_types.py +0 -0
  63. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_fact_links_sqlite.py +0 -0
  64. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_find_quote.py +0 -0
  65. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_headless.py +0 -0
  66. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_history_filter.py +0 -0
  67. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_idf_retrieval.py +0 -0
  68. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_ingest_index_integrity.py +0 -0
  69. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_longmemeval_auth.py +0 -0
  70. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_mcp_server.py +0 -0
  71. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_message_filter.py +0 -0
  72. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_metrics_persistence.py +0 -0
  73. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_model_catalog.py +0 -0
  74. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_model_limits.py +0 -0
  75. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_monitor.py +0 -0
  76. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_multi_instance.py +0 -0
  77. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_noop_fact_link_store.py +0 -0
  78. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_openrouter_provider.py +0 -0
  79. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_paging.py +0 -0
  80. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_passthrough_filter.py +0 -0
  81. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_presets.py +0 -0
  82. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_prev_context_leak.py +0 -0
  83. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_provider_adapters.py +0 -0
  84. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy.py +0 -0
  85. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy_dashboard.py +0 -0
  86. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy_formats.py +0 -0
  87. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy_message_filter.py +0 -0
  88. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy_session.py +0 -0
  89. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_proxy_streaming.py +0 -0
  90. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_raw_content.py +0 -0
  91. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_recall_all.py +0 -0
  92. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_registry_lifecycle.py +0 -0
  93. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_request_captures_persistence.py +0 -0
  94. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_retriever.py +0 -0
  95. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_rrf_scoring.py +0 -0
  96. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_segmenter.py +0 -0
  97. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_semantic_search.py +0 -0
  98. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_sender_identity.py +0 -0
  99. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_session_cache.py +0 -0
  100. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_session_date.py +0 -0
  101. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_storage_protocols.py +0 -0
  102. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_store_sqlite.py +0 -0
  103. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_stub_turn_handling.py +0 -0
  104. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_supersession.py +0 -0
  105. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_supersession_migration.py +0 -0
  106. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tag_canonicalizer.py +0 -0
  107. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tag_consolidator.py +0 -0
  108. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tag_generator.py +0 -0
  109. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tag_splitter.py +0 -0
  110. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_telemetry.py +0 -0
  111. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_telemetry_integration.py +0 -0
  112. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tool_loop.py +0 -0
  113. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tool_output_interceptor.py +0 -0
  114. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tool_result_filter.py +0 -0
  115. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tool_tags.py +0 -0
  116. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_tui.py +0 -0
  117. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_turn_tag_index.py +0 -0
  118. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_unified_budget.py +0 -0
  119. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_upstream_trim.py +0 -0
  120. {virtual_context-0.2.4 → virtual_context-0.2.6}/tests/test_verb_expansion.py +0 -0
  121. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual-context.yaml.example +0 -0
  122. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/cli/__init__.py +0 -0
  123. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/cli/main.py +0 -0
  124. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/config.py +0 -0
  125. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/__init__.py +0 -0
  126. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/assembler.py +0 -0
  127. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/compaction_pipeline.py +0 -0
  128. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/compactor.py +0 -0
  129. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/conversation_store.py +0 -0
  130. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/embedding_provider.py +0 -0
  131. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/embedding_tag_generator.py +0 -0
  132. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/engine_utils.py +0 -0
  133. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/fact_query.py +0 -0
  134. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/fts_preprocessor.py +0 -0
  135. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/hint_builder.py +0 -0
  136. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/llm_utils.py +0 -0
  137. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/math_utils.py +0 -0
  138. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/model_catalog.py +0 -0
  139. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/monitor.py +0 -0
  140. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/paging_manager.py +0 -0
  141. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/provider_adapters.py +0 -0
  142. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/quote_search.py +0 -0
  143. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/retrieval_assembler.py +0 -0
  144. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/retrieval_scoring.py +0 -0
  145. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/retriever.py +0 -0
  146. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/search_engine.py +0 -0
  147. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/segmenter.py +0 -0
  148. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/semantic_search.py +0 -0
  149. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tag_canonicalizer.py +0 -0
  150. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tag_consolidator.py +0 -0
  151. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tag_generator.py +0 -0
  152. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tag_scoring.py +0 -0
  153. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tag_splitter.py +0 -0
  154. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tagging_pipeline.py +0 -0
  155. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/telemetry.py +0 -0
  156. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/temporal_resolver.py +0 -0
  157. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tool_loop.py +0 -0
  158. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/tool_query.py +0 -0
  159. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/core/turn_tag_index.py +0 -0
  160. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/data/anthropic-tokenizer/tokenizer.json +0 -0
  161. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/engine.py +0 -0
  162. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/ingest/__init__.py +0 -0
  163. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/ingest/curator.py +0 -0
  164. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/ingest/date_resolver.py +0 -0
  165. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/ingest/parsers.py +0 -0
  166. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/ingest/supersession.py +0 -0
  167. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/mcp/__init__.py +0 -0
  168. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/mcp/server.py +0 -0
  169. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/model_limits.py +0 -0
  170. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/openclaw/virtual-context.mjs +0 -0
  171. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/patterns.py +0 -0
  172. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/presets/__init__.py +0 -0
  173. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/presets/agentic.py +0 -0
  174. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/presets/base.py +0 -0
  175. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/presets/coding.py +0 -0
  176. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/providers/__init__.py +0 -0
  177. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/providers/anthropic.py +0 -0
  178. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/providers/base.py +0 -0
  179. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/providers/generic_openai.py +0 -0
  180. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/providers/ollama_native.py +0 -0
  181. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/__init__.py +0 -0
  182. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/_envelope.py +0 -0
  183. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/dashboard.html +0 -0
  184. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/dashboard.py +0 -0
  185. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/helpers.py +0 -0
  186. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/metrics.py +0 -0
  187. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/multi.py +0 -0
  188. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/registry.py +0 -0
  189. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/session_cache.py +0 -0
  190. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/state.py +0 -0
  191. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/android-chrome-192x192.png +0 -0
  192. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/android-chrome-512x512.png +0 -0
  193. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/apple-touch-icon.png +0 -0
  194. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/favicon-16x16.png +0 -0
  195. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/favicon-32x32.png +0 -0
  196. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/favicon.ico +0 -0
  197. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/static/site.webmanifest +0 -0
  198. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/proxy/tool_output_interceptor.py +0 -0
  199. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/__init__.py +0 -0
  200. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/falkordb.py +0 -0
  201. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/filesystem.py +0 -0
  202. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/helpers.py +0 -0
  203. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/neo4j.py +0 -0
  204. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/storage/noop_fact_link_store.py +0 -0
  205. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/token_counter.py +0 -0
  206. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/__init__.py +0 -0
  207. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/app.py +0 -0
  208. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/chat.tcss +0 -0
  209. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/chat_provider.py +0 -0
  210. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/headless.py +0 -0
  211. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/modals/__init__.py +0 -0
  212. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/modals/turn_inspector.py +0 -0
  213. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/state.py +0 -0
  214. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/__init__.py +0 -0
  215. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/budget_bar.py +0 -0
  216. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/chat_view.py +0 -0
  217. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/input_box.py +0 -0
  218. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/tag_panel.py +0 -0
  219. {virtual_context-0.2.4 → virtual_context-0.2.6}/virtual_context/tui/widgets/turn_list.py +0 -0
  220. {virtual_context-0.2.4 → virtual_context-0.2.6}/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.6
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
@@ -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.6"
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