strata-agent-memory 0.2.7__tar.gz → 0.2.8__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 (172) hide show
  1. {strata_agent_memory-0.2.7/strata_agent_memory.egg-info → strata_agent_memory-0.2.8}/PKG-INFO +1 -1
  2. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/__init__.py +1 -1
  3. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/compiler.py +107 -29
  4. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/contracts.py +2 -1
  5. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/evidence.py +112 -12
  6. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/ledger.py +1 -0
  7. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/mcp_server.py +16 -7
  8. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/remote.py +43 -5
  9. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/render.py +7 -1
  10. strata_agent_memory-0.2.8/agent_memory/retrieval.py +205 -0
  11. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/serve.py +17 -0
  12. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/README.md +1 -1
  13. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/backup-tokens.sh +4 -2
  14. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/install-replica-driver.sh +104 -71
  15. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/onboard-agent.sh +9 -4
  16. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/upgrade-driver.sh +23 -7
  17. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/pyproject.toml +13 -2
  18. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8/strata_agent_memory.egg-info}/PKG-INFO +1 -1
  19. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/strata_agent_memory.egg-info/SOURCES.txt +10 -0
  20. strata_agent_memory-0.2.8/tests/test_binding_refusal_97e36882.py +122 -0
  21. strata_agent_memory-0.2.8/tests/test_build_recheck_image_e4a93fd6.py +51 -0
  22. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_deploy_mac.py +16 -0
  23. strata_agent_memory-0.2.8/tests/test_memoryagentbench_60a8abfc.py +374 -0
  24. strata_agent_memory-0.2.8/tests/test_memsyco_7b16e8e4.py +553 -0
  25. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_packaging.py +2 -0
  26. strata_agent_memory-0.2.8/tests/test_proof_exclusions_e4a93fd6.py +80 -0
  27. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_recheck_sandbox_543e48a7.py +24 -2
  28. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_client.py +1 -1
  29. strata_agent_memory-0.2.8/tests/test_reply_cap_cfbd7681.py +229 -0
  30. strata_agent_memory-0.2.8/tests/test_retrieval_plugin_9e166c2f.py +364 -0
  31. strata_agent_memory-0.2.8/tests/test_sandbox_image_e4a93fd6.py +320 -0
  32. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_served_ledger_6e8e49f3.py +4 -0
  33. strata_agent_memory-0.2.8/tests/test_tools_clone_4b72f0c1.py +232 -0
  34. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/LICENSE +0 -0
  35. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/README.md +0 -0
  36. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/__main__.py +0 -0
  37. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/approvals.py +0 -0
  38. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/audit.py +0 -0
  39. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/cli.py +0 -0
  40. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/compact.py +0 -0
  41. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/export.py +0 -0
  42. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/hooks.py +0 -0
  43. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/identity.py +0 -0
  44. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/instances.py +0 -0
  45. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/kit.py +0 -0
  46. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/lane.py +0 -0
  47. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/measure.py +0 -0
  48. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/memorizer.py +0 -0
  49. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/pathmap.py +0 -0
  50. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/recheck.py +0 -0
  51. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/records.py +0 -0
  52. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/relay/__init__.py +0 -0
  53. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/relay/adapters.py +0 -0
  54. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/relay/cli.py +0 -0
  55. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/relay/client.py +0 -0
  56. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/relay/core.py +0 -0
  57. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/reliability.py +0 -0
  58. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/roster.py +0 -0
  59. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/signing.py +0 -0
  60. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/stats.py +0 -0
  61. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/supersession.py +0 -0
  62. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/trust.py +0 -0
  63. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/agent_memory/wake.py +0 -0
  64. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/lib/served-ledger.sh +0 -0
  65. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/agent-keys.sh +0 -0
  66. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/agent-session.sh +0 -0
  67. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/install-hooks-mac.sh +0 -0
  68. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/install-relay-driver.sh +0 -0
  69. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/install-runner-driver.sh +0 -0
  70. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/instances.example.json +0 -0
  71. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/relay-agent.sh +0 -0
  72. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/seed-board.sh +0 -0
  73. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/seed-mail-context.sh +0 -0
  74. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/mac/seed-topics.sh +0 -0
  75. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/.claude-plugin/marketplace.json +0 -0
  76. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/README.md +0 -0
  77. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/.claude-plugin/plugin.json +0 -0
  78. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/bin/agent-memory-approvals +0 -0
  79. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/bin/agent-memory-wake +0 -0
  80. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/hooks/hooks.json +0 -0
  81. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/monitors/monitors.json +0 -0
  82. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/rules/approvals.md +0 -0
  83. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/skills/approvals/SKILL.md +0 -0
  84. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/skills/approve/SKILL.md +0 -0
  85. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/skills/inbox/SKILL.md +0 -0
  86. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/claude-code/skills/reject/SKILL.md +0 -0
  87. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-answer/SKILL.md +0 -0
  88. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-answer/agents/openai.yaml +0 -0
  89. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-approvals/SKILL.md +0 -0
  90. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-approvals/agents/openai.yaml +0 -0
  91. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-approvals/rules/approvals.md +0 -0
  92. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/plugins/codex/skills/agent-memory-approvals/scripts/agent-memory-approvals +0 -0
  93. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/README.md +0 -0
  94. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/agent-memory-relay@.service +0 -0
  95. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/install-relay-mac.sh +0 -0
  96. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/install-relay.sh +0 -0
  97. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +0 -0
  98. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/deploy/relay/relay-launch.sh +0 -0
  99. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/setup.cfg +0 -0
  100. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
  101. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/strata_agent_memory.egg-info/entry_points.txt +0 -0
  102. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/strata_agent_memory.egg-info/requires.txt +0 -0
  103. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/strata_agent_memory.egg-info/top_level.txt +0 -0
  104. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_assessment_67b26eae.py +0 -0
  105. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_audit.py +0 -0
  106. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_audit_lane_numbers_ea89ba26.py +0 -0
  107. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_audit_measurement_13190a20.py +0 -0
  108. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_backlog_check.py +0 -0
  109. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_changelog_fragments.py +0 -0
  110. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_changelog_guard_shallow_57a2f78e.py +0 -0
  111. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_ci_docs_only_353ce010.py +0 -0
  112. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_cli.py +0 -0
  113. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_compact.py +0 -0
  114. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_compiler.py +0 -0
  115. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_compiler_render.py +0 -0
  116. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_config.py +0 -0
  117. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_contract_amend_649e0b45.py +0 -0
  118. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_contracts.py +0 -0
  119. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_deploy_relay.py +0 -0
  120. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_deploy_replica.py +0 -0
  121. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_deploy_tokens.py +0 -0
  122. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_evidence.py +0 -0
  123. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_find_root_b4b5019b.py +0 -0
  124. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_hooks.py +0 -0
  125. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_identity.py +0 -0
  126. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_install_lane_cb430a30.py +0 -0
  127. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_issue_bridge.py +0 -0
  128. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_lane_63b7cf8b.py +0 -0
  129. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_lane_derived_roots_817ab446.py +0 -0
  130. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_lane_nightly_84b22b63.py +0 -0
  131. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_lane_priority_779d51ba.py +0 -0
  132. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_lane_stop_63b7cf8b.py +0 -0
  133. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_ledger.py +0 -0
  134. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_ledger_index.py +0 -0
  135. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_live_wake_c217c336.py +0 -0
  136. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_local_lanes_sibling_1411a2b2.py +0 -0
  137. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_memorizer.py +0 -0
  138. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_outcome_commit_binding_b1de564d.py +0 -0
  139. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_outcome_needs_evidence_b1de564d.py +0 -0
  140. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_pathmap.py +0 -0
  141. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_phase2.py +0 -0
  142. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_plugins.py +0 -0
  143. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_receipt_reports_274e6199.py +0 -0
  144. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_recheck.py +0 -0
  145. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_recheck_away_207af145.py +0 -0
  146. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_recheck_remote_8185d2d3.py +0 -0
  147. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_recheck_sandbox_nits_543e48a7.py +0 -0
  148. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_records.py +0 -0
  149. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_redaction.py +0 -0
  150. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_adapters.py +0 -0
  151. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_codex.py +0 -0
  152. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_core.py +0 -0
  153. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_instance_2a2e3887.py +0 -0
  154. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_release_526f5261.py +0 -0
  155. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_relay_release_gate_b3b3e487.py +0 -0
  156. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_reliability.py +0 -0
  157. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_remote.py +0 -0
  158. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_remote_signing.py +0 -0
  159. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_review2_fixes.py +0 -0
  160. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_review3_fixes.py +0 -0
  161. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_review_fixes.py +0 -0
  162. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_rollback_78710ebf.py +0 -0
  163. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_scale.py +0 -0
  164. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_serve.py +0 -0
  165. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_signing.py +0 -0
  166. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_sit_fleet_1b24b0b1.py +0 -0
  167. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_sit_fleet_ci_0d9b76a2.py +0 -0
  168. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_sit_var_bindings_e802ca84.py +0 -0
  169. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_stats.py +0 -0
  170. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_supersedes_61416280.py +0 -0
  171. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_trust.py +0 -0
  172. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.8}/tests/test_uv_lock_env_274e6199.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strata-agent-memory
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Portable, auditable, cross-vendor memory for LLM agents: evidence-bound claims, trust tiers, and compiled context views.
5
5
  Author: Strata Intelligence
6
6
  License-Expression: Apache-2.0
@@ -9,4 +9,4 @@ see, what did it claim, and can the proof be re-run.
9
9
  The core is dependency-free (Python 3.10+ standard library only).
10
10
  """
11
11
 
12
- __version__ = "0.2.7"
12
+ __version__ = "0.2.8"
@@ -14,6 +14,7 @@ Roles and modes follow the fork/isolate rule:
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
+ import sys
17
18
  from dataclasses import dataclass, field
18
19
  from datetime import timedelta
19
20
  from typing import Callable, Dict, Iterable, List, Optional, Tuple
@@ -142,6 +143,9 @@ class Compiled:
142
143
  pinned_cap: Optional[int] = None
143
144
  pinned_cut: Dict[str, int] = field(default_factory=dict)
144
145
  pinned_full: Dict[str, int] = field(default_factory=dict)
146
+ # the retrieval plugin that took part (ctr_9e166c2f291b829b): {plugin, share, ids}, and
147
+ # error when it could not run; None when config.retrieval names none
148
+ retrieval: Optional[Dict] = None
145
149
 
146
150
  def included_ids(self) -> List[str]:
147
151
  return [i.id for items in self.sections.values() for i in items]
@@ -590,6 +594,10 @@ def compile_context(
590
594
  else:
591
595
  out.sections["working"].append(item)
592
596
 
597
+ # the retrieval plugin (ctr_9e166c2f291b829b): the ids it finds for the task, of which
598
+ # the reader sees only what this compile's own policy filter let through (candidates)
599
+ retrieval_cfg, retrieved = _retrieval(ledger, task, {item.id: item for item, _ in candidates}, out, assessment)
600
+
593
601
  seen_dnr = set()
594
602
  for it in do_not_retry:
595
603
  key = (it.id, it.statement)
@@ -619,35 +627,72 @@ def compile_context(
619
627
  # costed provisionally here and the difference is charged once it is known
620
628
  provisional = _obligations(role, mode, round_no, bool(out.sections["cross_check"]), bool(contract_id), lens)
621
629
  overhead = overhead_tokens(out, provisional)
622
- used = overhead
623
- kept: Dict[str, List[Item]] = {name: [] for name in SECTION_ORDER}
624
- remaining_by_section = {name: [] for name in SECTION_ORDER}
625
- # pass 1: each section up to its share; pass 2: fill leftovers in priority order
626
- for name in SECTION_ORDER:
627
- cap = int(budget * SECTION_SHARE[name])
628
- # half of the constraints section is kept for its human-filed items, which arrive
629
- # in standing order, so the freshly filed ones compete for the other half only
630
- reserved = int(cap * CONSTRAINT_HUMAN_RESERVE) if name == "constraints" else 0
631
- section_used = unreserved_used = 0
632
- for item in out.sections[name]:
633
- cost = cost_of(item)
634
- reserve_eligible = bool(reserved) and item.is_human
635
- fits_share = reserve_eligible or unreserved_used + cost <= cap - reserved
636
- if section_used + cost <= cap and fits_share and used + cost <= budget:
637
- kept[name].append(item)
638
- section_used += cost
639
- unreserved_used += 0 if reserve_eligible else cost
640
- used += cost
641
- else:
642
- remaining_by_section[name].append(item)
643
- for name in SECTION_ORDER:
644
- for item in remaining_by_section[name]:
645
- cost = cost_of(item)
646
- if used + cost <= budget:
647
- kept[name].append(item)
648
- used += cost
649
- else:
650
- out.excluded.append((item.id, "budget"))
630
+ def allocate(reserve: int, found: List[Item], fixed: Optional[List[Item]] = None):
631
+ """(kept, used, left out for the budget): pass one fills each section up to its
632
+ share, the retrieval plugin's ``found`` fill its section up to ``reserve``, and pass
633
+ two fills leftovers in priority order. ``fixed`` is the constraints section as
634
+ compile alone keeps it: the plugin's share is never taken from what always applies
635
+ (the review of ctr_9e166c2f291b829b)."""
636
+ used = overhead
637
+ kept: Dict[str, List[Item]] = {name: [] for name in SECTION_ORDER}
638
+ remaining_by_section: Dict[str, List[Item]] = {name: [] for name in SECTION_ORDER}
639
+ # pass 1: each section up to its share; pass 2: fill leftovers in priority order
640
+ for name in SECTION_ORDER:
641
+ if name == "constraints" and fixed is not None:
642
+ kept[name] = list(fixed)
643
+ used += sum(cost_of(item) for item in fixed)
644
+ held = {id(item) for item in fixed}
645
+ remaining_by_section[name] = [item for item in out.sections[name] if id(item) not in held]
646
+ continue
647
+ cap = int(budget * SECTION_SHARE[name])
648
+ # half of the constraints section is kept for its human-filed items, which arrive
649
+ # in standing order, so the freshly filed ones compete for the other half only
650
+ reserved = int(cap * CONSTRAINT_HUMAN_RESERVE) if name == "constraints" else 0
651
+ section_used = unreserved_used = 0
652
+ for item in out.sections[name]:
653
+ cost = cost_of(item)
654
+ reserve_eligible = bool(reserved) and item.is_human
655
+ fits_share = reserve_eligible or unreserved_used + cost <= cap - reserved
656
+ if section_used + cost <= cap and fits_share and used + cost <= budget - reserve:
657
+ kept[name].append(item)
658
+ section_used += cost
659
+ unreserved_used += 0 if reserve_eligible else cost
660
+ used += cost
661
+ else:
662
+ remaining_by_section[name].append(item)
663
+ taken = {item.id for items in kept.values() for item in items}
664
+ if found:
665
+ # what the plugin found and pass 1 did not already keep, in the plugin's order,
666
+ # up to its share: a record shown once, in the section that placed it first
667
+ kept["retrieved"], spent = [], 0
668
+ for item in found:
669
+ cost = cost_of(item)
670
+ if item.id not in taken and spent + cost <= reserve and used + cost <= budget:
671
+ kept["retrieved"].append(item)
672
+ taken.add(item.id)
673
+ spent += cost
674
+ used += cost
675
+ left_out: List[str] = []
676
+ for name in SECTION_ORDER:
677
+ for item in remaining_by_section[name]:
678
+ if item.id in taken:
679
+ continue # the retrieved section placed it
680
+ cost = cost_of(item)
681
+ if used + cost <= budget:
682
+ kept[name].append(item)
683
+ used += cost
684
+ else:
685
+ left_out.append(item.id)
686
+ return kept, used, left_out
687
+
688
+ # compile alone; and when the retrieval plugin found something, again with its share
689
+ # held back from every section but the constraints, which keep what compile alone
690
+ # gives them. A plugin that finds nothing changes nothing (BENCHMARK-PLAN 1.2)
691
+ kept, used, left_out = allocate(0, [])
692
+ if retrieved:
693
+ kept, used, left_out = allocate(int(budget * retrieval_cfg["share"]), retrieved, fixed=kept["constraints"])
694
+ out.retrieval["ids"] = [item.id for item in kept["retrieved"]]
695
+ out.excluded += [(rid, "budget") for rid in left_out]
651
696
  out.sections = kept
652
697
  out.used_tokens = used
653
698
 
@@ -668,6 +713,34 @@ def compile_context(
668
713
  return out
669
714
 
670
715
 
716
+ def _retrieval(ledger, task: Optional[str], eligible: Dict[str, "Item"], out: "Compiled",
717
+ assessment: Optional[dict] = None):
718
+ """(the plugin's settings or None, the eligible items it found, best first), and
719
+ ``out.retrieval`` set to what the receipt records. A plugin that cannot run leaves the
720
+ view as compile alone makes it, and says so: on the receipt, and in one stderr line."""
721
+ from . import retrieval as retrieval_mod # local: retrieval imports this module
722
+ try:
723
+ cfg = retrieval_mod.settings(ledger.config)
724
+ except retrieval_mod.RetrievalError as exc:
725
+ out.retrieval = {"plugin": retrieval_mod.configured_name(ledger.config), "share": None, "ids": [],
726
+ "error": str(exc)}
727
+ print(f"agent-memory: the retrieval plugin did not run ({exc}); compiled without it", file=sys.stderr)
728
+ return None, []
729
+ if cfg is None:
730
+ return None, []
731
+ out.retrieval = {"plugin": cfg["plugin"], "share": cfg["share"], "ids": []}
732
+ if not task:
733
+ return cfg, [] # nothing to search by: a view with no task is compile's alone
734
+ try:
735
+ found = retrieval_mod.retrieve(ledger, task, cfg, assessment)
736
+ except retrieval_mod.RetrievalError as exc:
737
+ out.retrieval["error"] = str(exc)
738
+ print(f"agent-memory: the retrieval plugin {cfg['plugin']} did not run ({exc}); compiled without it",
739
+ file=sys.stderr)
740
+ return cfg, []
741
+ return cfg, [eligible[rid] for rid in found if rid in eligible]
742
+
743
+
671
744
  def compiled_from_json(payload: dict) -> Compiled:
672
745
  """Rebuild a Compiled from ``render.render_json``'s payload.
673
746
 
@@ -690,6 +763,7 @@ def compiled_from_json(payload: dict) -> Compiled:
690
763
  # saying it was cut
691
764
  board=[dict(row) for row in payload.get("board") or []],
692
765
  )
766
+ out.retrieval = payload.get("retrieval") if isinstance(payload.get("retrieval"), dict) else None
693
767
  display = payload.get("reader_display")
694
768
  out.reader_display = display if isinstance(display, str) and display else None
695
769
  out.config_notes = [n for n in payload.get("config_notes") or [] if isinstance(n, str)]
@@ -840,6 +914,10 @@ def record_compilation(ledger, compiled: Compiled, rendered: str, render_name: s
840
914
  # which reliability numbers the reader was shown, or null when annotation was off
841
915
  "reliability": compiled.reliability_meta,
842
916
  }
917
+ if compiled.retrieval is not None:
918
+ # which configuration produced the view (ctr_9e166c2f291b829b): the plugin, its
919
+ # share, the ids it added, and why it did not run when it did not
920
+ body["retrieval"] = compiled.retrieval
843
921
  # which repository the view was cut to, and where it was served and asked from: set
844
922
  # only when they are, so a plain view's receipt is what it always was
845
923
  for key, value in (("repo", compiled.repo), ("served", compiled.served or None),
@@ -765,7 +765,8 @@ def run_acceptance(ledger, contract_id: str, cwd: Optional[str] = None, timeout:
765
765
  detail = (f"exit {body['exit']} (expected {expect_exit})" + (f"; {body.get('summary')}" if body.get("summary") else "")
766
766
  + ("; timed out, and everything it started was stopped" if body.get("timed_out") else ""))
767
767
  if sandbox is not None:
768
- reason = ev.sandbox_failure(sandbox["image"], body, expect_exit, check["cmd"]) if not ok else None
768
+ reason = (ev.sandbox_failure(sandbox["image"], body, expect_exit, check["cmd"],
769
+ unproven=ev.image_proof(sandbox, where)) if not ok else None)
769
770
  if reason:
770
771
  outcome, detail = "error", f"{reason}; {detail}"
771
772
  detail = f"sandboxed: podman {sandbox['image']}; {detail}"
@@ -1073,6 +1073,14 @@ def resolved_directory(path, what: str) -> str:
1073
1073
  # cannot be had here -- no podman, no image, a directory that is not a worktree, a malformed
1074
1074
  # key -- is an `error`, never a run on the host.
1075
1075
  SANDBOX_IMAGE = "am-recheck:0.2" # deploy/driver/Containerfile.recheck, built once on the driver
1076
+ # what an image deploy/driver/build-recheck-image.sh made says of itself: the sha256 of the
1077
+ # lockfile the whole suite passed against inside it, and how many tests the build left out
1078
+ # by a named marker. Only that script sets them, and only after its proof run is green.
1079
+ PROVEN_LABEL = "io.agent-memory.recheck.proven"
1080
+ EXCLUDED_LABEL = "io.agent-memory.recheck.excluded"
1081
+ SANDBOX_LOCKFILE = "uv.lock"
1082
+ PROOF_CACHE_SECONDS = 300.0 # a resident lane sees an image rebuilt under it within this
1083
+ _PROOF_CACHE: Dict[Tuple[str, str], Tuple[float, str]] = {}
1076
1084
  SANDBOX_HOST_ENV = ("LANG", "TZ")
1077
1085
  _SANDBOX_DEFAULTS = {"memory": "2g", "cpus": 2, "pids": 512}
1078
1086
  _IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$")
@@ -1100,15 +1108,18 @@ class SandboxError(ValueError):
1100
1108
 
1101
1109
  def sandbox_config(policy: Optional[dict]) -> Optional[dict]:
1102
1110
  """The sandbox ``policy`` asks a re-check to run in, or None for none. ``recheck_sandbox``
1103
- is true or ``{engine: podman, image, memory, cpus, pids}``; anything else raises
1104
- SandboxError, because an operator who asked for a sandbox gets one or no re-run."""
1111
+ is true or ``{engine: podman, image, memory, cpus, pids, lockfile}``; anything else raises
1112
+ SandboxError, because an operator who asked for a sandbox gets one or no re-run.
1113
+ ``lockfile`` names the file, relative to a worktree's top, the image is proven against
1114
+ (image_proof); uv.lock unless it says otherwise."""
1105
1115
  value = (policy or {}).get("recheck_sandbox")
1106
1116
  if value is None or value is False:
1107
1117
  return None
1108
1118
  if value is True:
1109
1119
  value = {}
1110
1120
  if not isinstance(value, dict):
1111
- raise SandboxError("recheck_sandbox must be true or an object {engine: podman, image, memory, cpus, pids}")
1121
+ raise SandboxError("recheck_sandbox must be true or an object "
1122
+ "{engine: podman, image, memory, cpus, pids, lockfile}")
1112
1123
  engine = value.get("engine", "podman")
1113
1124
  if engine != "podman":
1114
1125
  raise SandboxError(f"recheck_sandbox.engine {engine!r}: podman is the only engine")
@@ -1116,6 +1127,7 @@ def sandbox_config(policy: Optional[dict]) -> Optional[dict]:
1116
1127
  memory = value.get("memory", _SANDBOX_DEFAULTS["memory"])
1117
1128
  cpus = value.get("cpus", _SANDBOX_DEFAULTS["cpus"])
1118
1129
  pids = value.get("pids", _SANDBOX_DEFAULTS["pids"])
1130
+ lockfile = value.get("lockfile", SANDBOX_LOCKFILE)
1119
1131
  if not isinstance(image, str) or not _IMAGE_RE.match(image):
1120
1132
  raise SandboxError(f"recheck_sandbox.image {image!r} is not an image reference")
1121
1133
  if not isinstance(memory, str) or not _MEMORY_RE.match(memory):
@@ -1124,7 +1136,74 @@ def sandbox_config(policy: Optional[dict]) -> Optional[dict]:
1124
1136
  raise SandboxError(f"recheck_sandbox.cpus {cpus!r} is not a number of cpus")
1125
1137
  if isinstance(pids, bool) or not isinstance(pids, int) or not 0 < pids <= 1_000_000:
1126
1138
  raise SandboxError(f"recheck_sandbox.pids {pids!r} is not a process count")
1127
- return {"engine": "podman", "image": image, "memory": memory, "cpus": cpus, "pids": pids}
1139
+ if not isinstance(lockfile, str) or not lockfile or PurePosixPath(lockfile).is_absolute() \
1140
+ or ".." in PurePosixPath(lockfile).parts:
1141
+ raise SandboxError(f"recheck_sandbox.lockfile {lockfile!r} is not a path inside a worktree")
1142
+ return {"engine": "podman", "image": image, "memory": memory, "cpus": cpus, "pids": pids,
1143
+ "lockfile": lockfile}
1144
+
1145
+
1146
+ def image_proof(sandbox: dict, cwd: str, podman: Optional[str] = None) -> Optional[str]:
1147
+ """None when the sandbox's image is proven complete for a re-run in ``cwd``, else why
1148
+ it is not: no lockfile there, an image with no proof, or one proven for another
1149
+ lockfile. Proven means build-recheck-image.sh ran the whole suite inside the image with
1150
+ a tree's top mounted at /work, against that top's lockfile, and labelled the image with
1151
+ the lockfile's sha256 (PROVEN_LABEL). So only a re-run whose own directory is such a top
1152
+ is what was proven: a container mounts nothing but the re-run's directory, and one below
1153
+ the top sees neither its lockfile nor the package the proof imported from /work (the
1154
+ review of 9a9cbd6). Until then a failure in the sandbox may be the image's and not the
1155
+ code's, so it refutes nothing (sandbox_failure; ctr_e4a93fd6d32e2b66)."""
1156
+ name = sandbox.get("lockfile") or SANDBOX_LOCKFILE
1157
+ lockfile = Path(cwd).resolve() / name
1158
+ if not lockfile.is_file():
1159
+ above = _worktree_file(Path(cwd), name)
1160
+ if above is not None:
1161
+ return (f"the re-run's directory is below its tree's top ({above.parent}), and the image is "
1162
+ f"proven only with a top mounted at /work")
1163
+ return f"the worktree holds no {name}"
1164
+ try:
1165
+ digest = hashlib.sha256(lockfile.read_bytes()).hexdigest()
1166
+ except OSError as exc:
1167
+ return f"{lockfile} cannot be read: {exc}"
1168
+ image = sandbox["image"]
1169
+ proven = _image_label(podman or shutil.which("podman") or "podman", image, PROVEN_LABEL)
1170
+ if not proven:
1171
+ return f"{image} carries no proof"
1172
+ if proven != digest:
1173
+ return f"{image} is proven for lockfile {proven[:12]}, and this worktree's is {digest[:12]}"
1174
+ return None
1175
+
1176
+
1177
+ def _worktree_file(start: Path, name: str) -> Optional[Path]:
1178
+ """``name`` in ``start`` or the nearest directory above it, stopping at the worktree's
1179
+ top (the first directory holding .git): a receipt's cwd is often a subdirectory."""
1180
+ here = start.resolve()
1181
+ while True:
1182
+ candidate = here / name
1183
+ if candidate.is_file():
1184
+ return candidate
1185
+ if (here / ".git").exists() or here.parent == here:
1186
+ return None
1187
+ here = here.parent
1188
+
1189
+
1190
+ def _image_label(podman: str, image: str, label: str) -> str:
1191
+ """One label of a local image, '' when it has none or podman cannot say; asked at most
1192
+ once per PROOF_CACHE_SECONDS per image, since a lane re-runs many receipts in one."""
1193
+ now = time.monotonic()
1194
+ cached = _PROOF_CACHE.get((image, label))
1195
+ if cached is not None and now - cached[0] < PROOF_CACHE_SECONDS:
1196
+ return cached[1]
1197
+ try:
1198
+ done = subprocess.run([podman, "image", "inspect", "--format",
1199
+ "{{ index .Config.Labels \"%s\" }}" % label, image],
1200
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30)
1201
+ value = done.stdout.strip() if done.returncode == 0 else ""
1202
+ except (OSError, subprocess.SubprocessError):
1203
+ value = ""
1204
+ value = "" if value in ("<no value>", "<nil>") else value
1205
+ _PROOF_CACHE[(image, label)] = (now, value)
1206
+ return value
1128
1207
 
1129
1208
 
1130
1209
  def sandbox_refusal(cwd: str) -> Optional[str]:
@@ -1163,7 +1242,11 @@ def sandbox_argv(podman: str, sandbox: dict, cwd: str, argv: Union[str, List[str
1163
1242
  passed += ["-e", f"{key}={host_env[key]}"]
1164
1243
  for key, value in (variables or {}).items():
1165
1244
  passed += ["-e", f"{key}={value}"]
1166
- return [podman, "run", "--rm", "--pull=never", "--name", name,
1245
+ # --init: podman's init (catatonit) is the container's PID 1 and reaps what the command
1246
+ # started and killed. Without one a killed child stays a zombie nobody waits for, and a
1247
+ # receipt that checks its children are gone reads it as alive (the image proof on the
1248
+ # driver, 2026-09-30: two tests failed that way)
1249
+ return [podman, "run", "--rm", "--pull=never", "--init", "--name", name,
1167
1250
  "--network=none", "--userns=keep-id", "--cap-drop=all",
1168
1251
  "--security-opt=no-new-privileges", "--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev",
1169
1252
  "--pids-limit", str(sandbox["pids"]), "--memory", str(sandbox["memory"]),
@@ -1199,9 +1282,9 @@ def network_refusal(text: str) -> Optional[str]:
1199
1282
  return None
1200
1283
 
1201
1284
 
1202
- # what a command says when the image does not hold what it imports or runs: the base image
1203
- # holds an interpreter, uv and pytest and nothing of the project (an image per lockfile is a
1204
- # later story), so a missing module is the image's gap, not the receipt's
1285
+ # what a command says when the image does not hold what it imports or runs: an image not
1286
+ # proven for the worktree's lockfile may lack anything, and even a proven one holds only
1287
+ # what the lockfile names, so a missing module is the image's gap, not the receipt's
1205
1288
  IMAGE_LACKS = "sandboxed, the image lacks"
1206
1289
  _MISSING = re.compile(r"No module named '?([A-Za-z0-9_.]+)'?|(?:^|: )([A-Za-z0-9_.+-]+): (?:command )?not found")
1207
1290
 
@@ -1215,11 +1298,15 @@ def image_lacks(text: str) -> Optional[str]:
1215
1298
  return None
1216
1299
 
1217
1300
 
1218
- def sandbox_failure(image: str, fresh: dict, expected_exit, cmd) -> Optional[str]:
1301
+ def sandbox_failure(image: str, fresh: dict, expected_exit, cmd,
1302
+ unproven: Optional[str] = None) -> Optional[str]:
1219
1303
  """Why a sandboxed run that disagreed with its expectation is no answer, or None when it
1220
1304
  is one: podman's own failure (exit 125: no image by that name), a program the image
1221
- cannot run (126, 127), a network the sandbox does not have, or a module the image does
1222
- not hold. The re-check and the acceptance check read a sandboxed run alike.
1305
+ cannot run (126, 127), a network the sandbox does not have, a module the image does not
1306
+ hold -- and any failure at all in an image not proven complete for the worktree's
1307
+ lockfile (``unproven``, image_proof's reason), since that failure may be the image's.
1308
+ Only a proven image's failure refutes. The re-check and the acceptance check read a
1309
+ sandboxed run alike.
1223
1310
 
1224
1311
  The last two are read from the run's own output, which its author wrote: a failing
1225
1312
  command can print its way to "sandboxed, no network". That buys it no verification --
@@ -1237,6 +1324,8 @@ def sandbox_failure(image: str, fresh: dict, expected_exit, cmd) -> Optional[str
1237
1324
  lacking = image_lacks(tail)
1238
1325
  if lacking:
1239
1326
  return f"{IMAGE_LACKS} {lacking}"
1327
+ if unproven:
1328
+ return f"the re-run failed in {image}, which is not proven for this worktree's lockfile ({unproven}); not a refutation"
1240
1329
  return None
1241
1330
 
1242
1331
 
@@ -1441,6 +1530,12 @@ def _git(args: Sequence[str], cwd: str) -> subprocess.CompletedProcess:
1441
1530
  timeout=GIT_CHECK_SECONDS)
1442
1531
 
1443
1532
 
1533
+ class BindingError(ValueError):
1534
+ """A binding that could not be built on this host: a commit its repository does not
1535
+ hold, a file that is missing or not a regular one. The message is this host's detail
1536
+ (its paths, git's stderr); a server answers a remote caller without it (serve.py)."""
1537
+
1538
+
1444
1539
  def commit_binding(repo: Optional[str] = None, sha: Optional[str] = None, branch: Optional[str] = None) -> dict:
1445
1540
  """Bind to a commit. Raises ValueError when the sha does not resolve in the repo:
1446
1541
  a binding to a commit that does not exist is not evidence of anything."""
@@ -1996,7 +2091,11 @@ def check(body: dict, cwd: Optional[str] = None, strict: bool = False, timeout:
1996
2091
  if notes.get("sandboxed"):
1997
2092
  # a disagreement the sandbox itself caused is a question it could not ask there,
1998
2093
  # not a different answer (the PE's ruling on ctr_543e, for the network)
1999
- reason = (sandbox_failure(notes["sandboxed"], notes.get("fresh") or {}, body.get("exit"), body.get("cmd"))
2094
+ # the image's proof is asked only of a disagreement: a match verifies in any image
2095
+ unproven = (image_proof(notes["sandbox"], notes["sandbox_cwd"])
2096
+ if outcome == "mismatch" and notes.get("sandbox") else None)
2097
+ reason = (sandbox_failure(notes["sandboxed"], notes.get("fresh") or {}, body.get("exit"), body.get("cmd"),
2098
+ unproven=unproven)
2000
2099
  if outcome == "mismatch" else None)
2001
2100
  if reason:
2002
2101
  outcome = "error"
@@ -2181,6 +2280,7 @@ def _check_binding(body: dict, cwd: Optional[str] = None, strict: bool = False,
2181
2280
  if sandbox is not None: # check() reads a disagreement through sandbox_failure
2182
2281
  notes["sandboxed"] = sandbox["image"]
2183
2282
  notes["fresh"] = fresh
2283
+ notes["sandbox"], notes["sandbox_cwd"] = sandbox, run_cwd
2184
2284
  if fresh.get("start_error") and fresh["exit"] != body.get("exit"):
2185
2285
  # The command never started here and the recording does not say it should
2186
2286
  # not have, so nothing was asked and nothing disagreed: a `chmod`, a
@@ -130,6 +130,7 @@ UNTRUSTED_POLICY = {
130
130
  "recheck_allow": list(STARTER_CONFIG["recheck_allow"]), # not the clone's idea of "safe"
131
131
  "recheck_allow_shell": False,
132
132
  "recheck_sandbox": None, # which image a re-check runs in, and whether: the machine's, never a clone's
133
+ "retrieval": None, # the plugin a compile runs (ctr_9e166c2f291b829b): the machine's, never a clone's
133
134
  "redact": [], # an attacker-supplied regex can hang capture (catastrophic backtracking)
134
135
  "path_map": [], # where a re-check runs
135
136
  "org_path": None, # what it reads and writes outside the checkout
@@ -30,7 +30,7 @@ import sys
30
30
  import traceback
31
31
  from datetime import timedelta
32
32
  from pathlib import Path
33
- from typing import Any, Dict, Optional
33
+ from typing import Any, Dict, List, Optional
34
34
 
35
35
  from . import __version__
36
36
  from . import approvals as approvals_mod
@@ -706,12 +706,7 @@ class Server:
706
706
  if any(not isinstance(item, str) for item in evidence):
707
707
  # an inline evidence object is an agent typing its own receipt
708
708
  return self._text("rejected: evidence must be receipt ids; use memory_run", is_error=True)
709
- if args.get("commit"):
710
- evidence.append(ev.commit_binding(str(ledger.root), args["commit"]))
711
- for f in args.get("files") or []:
712
- evidence.append(ev.file_binding(f))
713
- for f in args.get("env_files") or []:
714
- evidence.append(ev.file_binding(f, role="environment"))
709
+ evidence += self._bindings(args, ledger)
715
710
  if evidence:
716
711
  body["evidence"] = evidence
717
712
  for key in ("rationale", "example", "outcome", "source", "contract", "confidence", "expect"):
@@ -736,6 +731,20 @@ class Server:
736
731
  out["warning"] = warning
737
732
  return self._text(json.dumps(out))
738
733
 
734
+ def _bindings(self, args: dict, ledger) -> List[dict]:
735
+ """The bindings a claim's arguments ask for, built on this host: the commit in this
736
+ ledger's repository, then the files by path. What building one raises names this
737
+ host (its repository, git's stderr, which paths exist), which the caller of a local
738
+ server already knows; the served tool keeps it from a remote one (serve.py)."""
739
+ built: List[dict] = []
740
+ if args.get("commit"):
741
+ built.append(ev.commit_binding(str(ledger.root), args["commit"]))
742
+ for f in args.get("files") or []:
743
+ built.append(ev.file_binding(f))
744
+ for f in args.get("env_files") or []:
745
+ built.append(ev.file_binding(f, role="environment"))
746
+ return built
747
+
739
748
  def tool_memory_run(self, args: dict) -> dict:
740
749
  if not self.allow_run:
741
750
  return self._text(
@@ -48,6 +48,21 @@ RECHECK_TIMEOUT = 660.0
48
48
  # the client speaks the revision the stdio server speaks; the server accepts three
49
49
  MCP_VERSION = "2025-06-18"
50
50
 
51
+ # How much of a reply this client will hold (ctr_cfbd768152e255a4). A hostile, broken or
52
+ # intercepted replica could otherwise send a body with no end, and the CLI, a hook or the
53
+ # relay would read all of it into memory. The largest legitimate reply is one record, and
54
+ # serve takes a posted record of up to serve.MAX_BODY_BYTES (8 MiB). On 2026-09-30 the
55
+ # fleet's 3,171 records had a largest of 51 KB, the 500 largest together (a worst page at
56
+ # PAGE_MAX) were 1.8 MiB, /audit was 262 KB and the whole ledger 4 MiB. So 32 MiB is four
57
+ # times the largest record the server accepts and seventeen times today's worst page, and
58
+ # it is all a replica can make the client hold. A test pins these against serve's.
59
+ REPLY_MAX_BYTES = 32 * 1024 * 1024
60
+ # the event stream never ends, so it is capped per line: one data line carries one record
61
+ STREAM_LINE_MAX_BYTES = 16 * 1024 * 1024
62
+ # an error body is {"error": "..."} and only its first 500 characters are ever shown, so it
63
+ # is read this far and no further; a longer one is cut, not refused, as it is already an error
64
+ ERROR_BODY_MAX_BYTES = 64 * 1024
65
+
51
66
  LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"}
52
67
 
53
68
 
@@ -300,11 +315,11 @@ class Client:
300
315
  req.add_header(key, value)
301
316
  try:
302
317
  with self.opener.open(req, timeout=timeout or self.timeout) as resp:
303
- return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")
318
+ return resp.status, dict(resp.headers), _capped_body(resp, path).decode("utf-8", "replace")
304
319
  except urllib.error.HTTPError as exc:
305
320
  if 300 <= exc.code < 400:
306
321
  raise RemoteError(exc.code, _redirect_refusal(exc.headers.get("Location"))) from exc
307
- raise RemoteError(exc.code, _message(exc.code, exc.read())) from exc
322
+ raise RemoteError(exc.code, _message(exc.code, exc.read(ERROR_BODY_MAX_BYTES))) from exc
308
323
  except urllib.error.URLError as exc:
309
324
  raise RemoteError(0, f"cannot reach {self.base}: {exc.reason}") from exc
310
325
  except (TimeoutError, OSError) as exc: # a socket timeout on a read already under way
@@ -428,7 +443,8 @@ class Client:
428
443
  nothing on the wire: every way a stream can stop has the same answer, which is to
429
444
  reconnect with the last id that did arrive. RemoteError is for the answer that
430
445
  never became a stream at all, which is a different thing and the caller's to
431
- report. This method does no work until it is iterated. ``alive``, when given, is
446
+ report, and for a line longer than STREAM_LINE_MAX_BYTES, which no record makes
447
+ and which is read no further. This method does no work until it is iterated. ``alive``, when given, is
432
448
  called for every line the server sends, a heartbeat included: a caller that backs off
433
449
  between connections can tell a live stream that was quiet from one that sent nothing.
434
450
  ``on_open``, when given, is handed a ``close()`` once the stream is open, which any
@@ -452,7 +468,7 @@ class Client:
452
468
  except urllib.error.HTTPError as exc:
453
469
  if 300 <= exc.code < 400:
454
470
  raise RemoteError(exc.code, _redirect_refusal(exc.headers.get("Location"))) from exc
455
- raise RemoteError(exc.code, _message(exc.code, exc.read())) from exc
471
+ raise RemoteError(exc.code, _message(exc.code, exc.read(ERROR_BODY_MAX_BYTES))) from exc
456
472
  except urllib.error.URLError as exc:
457
473
  raise RemoteError(0, f"cannot reach {self.base}: {exc.reason}") from exc
458
474
  except (TimeoutError, OSError) as exc:
@@ -465,11 +481,14 @@ class Client:
465
481
  try:
466
482
  while True:
467
483
  try:
468
- raw = stream.readline()
484
+ raw = stream.readline(STREAM_LINE_MAX_BYTES + 1)
469
485
  except (TimeoutError, OSError): # a socket timeout is the idle stream's end
470
486
  return
471
487
  if not raw:
472
488
  return
489
+ if len(raw) > STREAM_LINE_MAX_BYTES: # a line with no end in sight: stop reading
490
+ raise RemoteError(stream.status, f"/events sent a line of more than {STREAM_LINE_MAX_BYTES} "
491
+ f"bytes (STREAM_LINE_MAX_BYTES); stopped reading the stream")
473
492
  if alive is not None:
474
493
  alive()
475
494
  line = raw.decode("utf-8", "replace").rstrip("\n").rstrip("\r")
@@ -575,6 +594,25 @@ def _redirect_refusal(location: Optional[str]) -> str:
575
594
  "the host it names. Point --remote at the final URL.")
576
595
 
577
596
 
597
+ def _capped_body(resp, path: str) -> bytes:
598
+ """A reply's body, or RemoteError once it is over REPLY_MAX_BYTES.
599
+
600
+ A declared Content-Length over the cap is refused before any of it is read, and a body
601
+ that declares none, or declares less than it sends, is read one byte past the cap and
602
+ no further. The message names the cap and the path; never the query, which carries
603
+ what was asked, and never the token.
604
+ """
605
+ where = path.split("?", 1)[0]
606
+ refusal = f"{where} answered with more than {REPLY_MAX_BYTES} bytes (REPLY_MAX_BYTES); stopped reading"
607
+ declared = resp.headers.get("Content-Length")
608
+ if declared is not None and declared.strip().isdigit() and int(declared) > REPLY_MAX_BYTES:
609
+ raise RemoteError(resp.status, refusal)
610
+ body = resp.read(REPLY_MAX_BYTES + 1)
611
+ if len(body) > REPLY_MAX_BYTES:
612
+ raise RemoteError(resp.status, refusal)
613
+ return body
614
+
615
+
578
616
  def _message(status: int, payload: bytes) -> str:
579
617
  """The server's own message for a failed request: every error body in this API is
580
618
  ``{"error": "..."}``, and anything else (a proxy's HTML) is shown as it arrived."""
@@ -23,6 +23,7 @@ SECTION_TITLES = {
23
23
  "decisions": "Decisions",
24
24
  "directions": "Directions (what to try next, what to avoid)",
25
25
  "working": "Working memory (evidenced or asserted; confirm before relying on)",
26
+ "retrieved": "Retrieved (what the retrieval plugin found for this task; confirm before relying on)",
26
27
  "exemplars": "Exemplars",
27
28
  }
28
29
 
@@ -161,7 +162,8 @@ def _markdown_lines(c: Compiled, h: str) -> Tuple[List[str], int]:
161
162
  that stays the same bytes while only working memory changes (roadmap 8.5.1)."""
162
163
  lines: List[str] = _header_lines(c, h)
163
164
  prefix = None
164
- for name in ("constraints", "contract", "cross_check", "do_not_retry", "verified", "decisions", "directions", "working", "exemplars"):
165
+ for name in ("constraints", "contract", "cross_check", "do_not_retry", "verified", "decisions", "directions", "working",
166
+ "retrieved", "exemplars"):
165
167
  if name == "working":
166
168
  prefix = len(lines)
167
169
  if name == "contract":
@@ -313,6 +315,10 @@ def render_json(c: Compiled) -> str:
313
315
  "obligations": c.obligations,
314
316
  "excluded": [{"id": i, "reason": r} for i, r in c.excluded],
315
317
  }
318
+ if c.retrieval is not None:
319
+ # only when a plugin took part, as the receipt carries it: without the key the json
320
+ # view is what it was (the review of #149)
321
+ payload["retrieval"] = c.retrieval
316
322
  return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
317
323
 
318
324