strata-agent-memory 0.2.8__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/PKG-INFO +4 -1
  2. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/__init__.py +1 -1
  3. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/cli.py +47 -1
  4. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/compiler.py +22 -1
  5. strata_agent_memory-0.2.9/agent_memory/endpoints.py +258 -0
  6. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/evidence.py +47 -4
  7. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/kit.py +29 -6
  8. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/lane.py +21 -0
  9. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/ledger.py +1 -0
  10. strata_agent_memory-0.2.9/agent_memory/retrieval.py +878 -0
  11. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/README.md +9 -1
  12. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/agent-session.sh +30 -3
  13. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/install-hooks-mac.sh +25 -1
  14. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/pyproject.toml +12 -6
  15. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/PKG-INFO +4 -1
  16. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/SOURCES.txt +7 -1
  17. strata_agent_memory-0.2.9/strata_agent_memory.egg-info/requires.txt +7 -0
  18. strata_agent_memory-0.2.9/tests/test_compact_window_778d310f.py +403 -0
  19. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_deploy_mac.py +9 -5
  20. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_install_lane_cb430a30.py +1 -1
  21. strata_agent_memory-0.2.9/tests/test_retrieval_llm_9e166c2f.py +907 -0
  22. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_retrieval_plugin_9e166c2f.py +1 -1
  23. strata_agent_memory-0.2.9/tests/test_retrieval_vectors_9e166c2f.py +648 -0
  24. strata_agent_memory-0.2.9/tests/test_taskset_66bac366.py +356 -0
  25. strata_agent_memory-0.2.9/tests/test_verify_branch_aff56f06.py +255 -0
  26. strata_agent_memory-0.2.8/agent_memory/retrieval.py +0 -205
  27. strata_agent_memory-0.2.8/strata_agent_memory.egg-info/requires.txt +0 -3
  28. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/LICENSE +0 -0
  29. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/README.md +0 -0
  30. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/__main__.py +0 -0
  31. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/approvals.py +0 -0
  32. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/audit.py +0 -0
  33. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/compact.py +0 -0
  34. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/contracts.py +0 -0
  35. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/export.py +0 -0
  36. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/hooks.py +0 -0
  37. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/identity.py +0 -0
  38. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/instances.py +0 -0
  39. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/mcp_server.py +0 -0
  40. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/measure.py +0 -0
  41. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/memorizer.py +0 -0
  42. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/pathmap.py +0 -0
  43. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/recheck.py +0 -0
  44. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/records.py +0 -0
  45. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/relay/__init__.py +0 -0
  46. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/relay/adapters.py +0 -0
  47. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/relay/cli.py +0 -0
  48. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/relay/client.py +0 -0
  49. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/relay/core.py +0 -0
  50. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/reliability.py +0 -0
  51. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/remote.py +0 -0
  52. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/render.py +0 -0
  53. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/roster.py +0 -0
  54. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/serve.py +0 -0
  55. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/signing.py +0 -0
  56. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/stats.py +0 -0
  57. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/supersession.py +0 -0
  58. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/trust.py +0 -0
  59. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/agent_memory/wake.py +0 -0
  60. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/lib/served-ledger.sh +0 -0
  61. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/agent-keys.sh +0 -0
  62. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/backup-tokens.sh +0 -0
  63. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/install-relay-driver.sh +0 -0
  64. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/install-replica-driver.sh +0 -0
  65. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/install-runner-driver.sh +0 -0
  66. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/instances.example.json +0 -0
  67. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/onboard-agent.sh +0 -0
  68. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/relay-agent.sh +0 -0
  69. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/seed-board.sh +0 -0
  70. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/seed-mail-context.sh +0 -0
  71. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/seed-topics.sh +0 -0
  72. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/mac/upgrade-driver.sh +0 -0
  73. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/.claude-plugin/marketplace.json +0 -0
  74. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/README.md +0 -0
  75. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/.claude-plugin/plugin.json +0 -0
  76. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/bin/agent-memory-approvals +0 -0
  77. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/bin/agent-memory-wake +0 -0
  78. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/hooks/hooks.json +0 -0
  79. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/monitors/monitors.json +0 -0
  80. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/rules/approvals.md +0 -0
  81. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/approvals/SKILL.md +0 -0
  82. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/approve/SKILL.md +0 -0
  83. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/inbox/SKILL.md +0 -0
  84. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/reject/SKILL.md +0 -0
  85. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-answer/SKILL.md +0 -0
  86. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-answer/agents/openai.yaml +0 -0
  87. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/SKILL.md +0 -0
  88. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/agents/openai.yaml +0 -0
  89. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/rules/approvals.md +0 -0
  90. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/scripts/agent-memory-approvals +0 -0
  91. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/README.md +0 -0
  92. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/agent-memory-relay@.service +0 -0
  93. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/install-relay-mac.sh +0 -0
  94. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/install-relay.sh +0 -0
  95. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +0 -0
  96. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/deploy/relay/relay-launch.sh +0 -0
  97. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/setup.cfg +0 -0
  98. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
  99. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/entry_points.txt +0 -0
  100. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/top_level.txt +0 -0
  101. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_assessment_67b26eae.py +0 -0
  102. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_audit.py +0 -0
  103. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_audit_lane_numbers_ea89ba26.py +0 -0
  104. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_audit_measurement_13190a20.py +0 -0
  105. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_backlog_check.py +0 -0
  106. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_binding_refusal_97e36882.py +0 -0
  107. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_build_recheck_image_e4a93fd6.py +0 -0
  108. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_changelog_fragments.py +0 -0
  109. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_changelog_guard_shallow_57a2f78e.py +0 -0
  110. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_ci_docs_only_353ce010.py +0 -0
  111. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_cli.py +0 -0
  112. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_compact.py +0 -0
  113. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_compiler.py +0 -0
  114. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_compiler_render.py +0 -0
  115. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_config.py +0 -0
  116. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_contract_amend_649e0b45.py +0 -0
  117. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_contracts.py +0 -0
  118. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_deploy_relay.py +0 -0
  119. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_deploy_replica.py +0 -0
  120. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_deploy_tokens.py +0 -0
  121. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_evidence.py +0 -0
  122. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_find_root_b4b5019b.py +0 -0
  123. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_hooks.py +0 -0
  124. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_identity.py +0 -0
  125. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_issue_bridge.py +0 -0
  126. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_lane_63b7cf8b.py +0 -0
  127. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_lane_derived_roots_817ab446.py +0 -0
  128. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_lane_nightly_84b22b63.py +0 -0
  129. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_lane_priority_779d51ba.py +0 -0
  130. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_lane_stop_63b7cf8b.py +0 -0
  131. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_ledger.py +0 -0
  132. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_ledger_index.py +0 -0
  133. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_live_wake_c217c336.py +0 -0
  134. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_local_lanes_sibling_1411a2b2.py +0 -0
  135. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_memorizer.py +0 -0
  136. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_memoryagentbench_60a8abfc.py +0 -0
  137. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_memsyco_7b16e8e4.py +0 -0
  138. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_outcome_commit_binding_b1de564d.py +0 -0
  139. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_outcome_needs_evidence_b1de564d.py +0 -0
  140. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_packaging.py +0 -0
  141. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_pathmap.py +0 -0
  142. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_phase2.py +0 -0
  143. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_plugins.py +0 -0
  144. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_proof_exclusions_e4a93fd6.py +0 -0
  145. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_receipt_reports_274e6199.py +0 -0
  146. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_recheck.py +0 -0
  147. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_recheck_away_207af145.py +0 -0
  148. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_recheck_remote_8185d2d3.py +0 -0
  149. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_recheck_sandbox_543e48a7.py +0 -0
  150. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_recheck_sandbox_nits_543e48a7.py +0 -0
  151. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_records.py +0 -0
  152. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_redaction.py +0 -0
  153. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_adapters.py +0 -0
  154. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_client.py +0 -0
  155. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_codex.py +0 -0
  156. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_core.py +0 -0
  157. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_instance_2a2e3887.py +0 -0
  158. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_release_526f5261.py +0 -0
  159. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_relay_release_gate_b3b3e487.py +0 -0
  160. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_reliability.py +0 -0
  161. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_remote.py +0 -0
  162. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_remote_signing.py +0 -0
  163. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_reply_cap_cfbd7681.py +0 -0
  164. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_review2_fixes.py +0 -0
  165. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_review3_fixes.py +0 -0
  166. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_review_fixes.py +0 -0
  167. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_rollback_78710ebf.py +0 -0
  168. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_sandbox_image_e4a93fd6.py +0 -0
  169. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_scale.py +0 -0
  170. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_serve.py +0 -0
  171. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_served_ledger_6e8e49f3.py +0 -0
  172. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_signing.py +0 -0
  173. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_sit_fleet_1b24b0b1.py +0 -0
  174. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_sit_fleet_ci_0d9b76a2.py +0 -0
  175. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_sit_var_bindings_e802ca84.py +0 -0
  176. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_stats.py +0 -0
  177. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_supersedes_61416280.py +0 -0
  178. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_tools_clone_4b72f0c1.py +0 -0
  179. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/tests/test_trust.py +0 -0
  180. {strata_agent_memory-0.2.8 → strata_agent_memory-0.2.9}/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.8
3
+ Version: 0.2.9
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
@@ -20,6 +20,9 @@ Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Provides-Extra: sign
22
22
  Requires-Dist: cryptography>=42; extra == "sign"
23
+ Provides-Extra: retrieval
24
+ Requires-Dist: sqlite-vec>=0.1.9; extra == "retrieval"
25
+ Requires-Dist: fastembed>=0.8; extra == "retrieval"
23
26
  Dynamic: license-file
24
27
 
25
28
  # agent-memory
@@ -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.8"
12
+ __version__ = "0.2.9"
@@ -987,6 +987,39 @@ def cmd_memorize(args) -> int:
987
987
  return 0
988
988
 
989
989
 
990
+ def cmd_retrieval(args) -> int:
991
+ """The retrieval plugin's two steps outside compile (ctr_9e166c2f291b829b): fetch-model,
992
+ the one that opens a socket, and index, which embeds the records (compile embeds only
993
+ the task, so its cost never grows with the ledger)."""
994
+ from . import retrieval as retrieval_mod
995
+ try:
996
+ if args.action == "fetch-model":
997
+ model = args.model
998
+ if model is None:
999
+ try:
1000
+ cfg = retrieval_mod.settings(Ledger.open().config)
1001
+ except FileNotFoundError:
1002
+ cfg = None # no ledger here: the default model
1003
+ embed = (cfg or {}).get("embed") or {}
1004
+ if embed.get("backend") == "endpoint":
1005
+ print(f"retrieval fetch-model: config.retrieval.embed names the endpoint {embed['endpoint']}, "
1006
+ f"which serves its model: there is nothing to fetch", file=sys.stderr)
1007
+ return 2
1008
+ model = embed.get("model", retrieval_mod.DEFAULT_MODEL)
1009
+ _print(retrieval_mod.fetch_model(model))
1010
+ return 0
1011
+ ledger = Ledger.open()
1012
+ cfg = retrieval_mod.settings(ledger.config)
1013
+ if cfg is None:
1014
+ print("config.retrieval names no plugin: there is no index to build", file=sys.stderr)
1015
+ return 2
1016
+ _print(retrieval_mod.index(ledger, cfg))
1017
+ return 0
1018
+ except retrieval_mod.RetrievalError as exc:
1019
+ print(f"retrieval {args.action}: {exc}", file=sys.stderr)
1020
+ return 1
1021
+
1022
+
990
1023
  def cmd_export(args) -> int:
991
1024
  ledger = Ledger.open()
992
1025
  reader = _author(args, ledger) # another user's private receipts are not ours to export
@@ -1073,7 +1106,7 @@ def cmd_serve(args) -> int:
1073
1106
 
1074
1107
  # the local ledger is the whole of what these do; there is nothing to send
1075
1108
  LOCAL_ONLY = ("init", "trust", "untrust", "fsck", "redact", "keygen", "roster", "serve",
1076
- "mcp", "compact", "memorize")
1109
+ "mcp", "compact", "memorize", "retrieval")
1077
1110
  # answered over --remote, --instance or $AGENT_MEMORY_REMOTE, and never because a checkout's
1078
1111
  # committed config names a replica: the replica's own clone carries that file and still
1079
1112
  # re-checks locally, and so does the nightly CI lane in a checkout that names one
@@ -2527,6 +2560,19 @@ def build_parser() -> argparse.ArgumentParser:
2527
2560
  _add_identity(s)
2528
2561
  s.set_defaults(fn=cmd_memorize)
2529
2562
 
2563
+ s = sub.add_parser("retrieval", help="the reader-side retrieval plugin's steps outside compile "
2564
+ "(config.retrieval, docs/PROTOCOL.md section 6)")
2565
+ rs = s.add_subparsers(dest="action", required=True)
2566
+ r = rs.add_parser("fetch-model", help="download the embedding model's weights into the cache: the one "
2567
+ "step that opens a socket; compile loads them offline")
2568
+ r.add_argument("--model", help="one of the models the plugin knows (default: config.retrieval.embed's "
2569
+ "model, else nomic-ai/nomic-embed-text-v1.5-Q)")
2570
+ rs.add_parser("index", help="bring this ledger's retrieval index up to the ledger and, with "
2571
+ "config.retrieval.restate, restate every record without a restatement, and "
2572
+ "with config.retrieval.embed, embed every record without a vector: compile "
2573
+ "does neither, so run this after records arrive")
2574
+ s.set_defaults(fn=cmd_retrieval)
2575
+
2530
2576
  s = sub.add_parser("export", help="export the ledger in a standard format")
2531
2577
  s.add_argument("--format", choices=list(export_mod.FORMATS), default="prov-json",
2532
2578
  help="prov-json (W3C PROV for auditors), in-toto (one unsigned Statement per receipt), "
@@ -729,15 +729,36 @@ def _retrieval(ledger, task: Optional[str], eligible: Dict[str, "Item"], out: "C
729
729
  if cfg is None:
730
730
  return None, []
731
731
  out.retrieval = {"plugin": cfg["plugin"], "share": cfg["share"], "ids": []}
732
+ for part in ("embed", "analyse", "restate"): # the configuration a view came from, whether it ran or not
733
+ if part in cfg:
734
+ out.retrieval[part] = dict(cfg[part])
732
735
  if not task:
733
736
  return cfg, [] # nothing to search by: a view with no task is compile's alone
737
+ report: dict = {}
738
+
739
+ def reported():
740
+ for part in ("analyse", "embed"): # what answered, what it cost, and the scope it chose
741
+ if part in report:
742
+ out.retrieval[part].update(report[part])
743
+ if "withheld" in report: # user-scoped records kept off an endpoint, and sent to one
744
+ out.retrieval["withheld"], out.retrieval["sent_private"] = report["withheld"], report["sent_private"]
745
+ out.retrieval["pending_for"] = report["pending_for"] # which part of the index is behind
746
+ if "error" in report.get("analyse", {}):
747
+ print(f"agent-memory: the retrieval plugin's query analysis did not run ({report['analyse']['error']}); "
748
+ f"searched by the task's own terms", file=sys.stderr)
734
749
  try:
735
- found = retrieval_mod.retrieve(ledger, task, cfg, assessment)
750
+ # the reader's policy filter goes into the query, so another reader's records never
751
+ # take this reader's places under the plugin's cuts (an index is never global)
752
+ found, pending = retrieval_mod.retrieve(ledger, task, cfg, assessment, allowed=eligible, report=report)
753
+ if pending is not None: # records BM25 alone could find: this view is not the configured C1's yet
754
+ out.retrieval["pending"] = pending
736
755
  except retrieval_mod.RetrievalError as exc:
756
+ reported() # an analysis call made before the failure still cost what it cost
737
757
  out.retrieval["error"] = str(exc)
738
758
  print(f"agent-memory: the retrieval plugin {cfg['plugin']} did not run ({exc}); compiled without it",
739
759
  file=sys.stderr)
740
760
  return cfg, []
761
+ reported()
741
762
  return cfg, [eligible[rid] for rid in found if rid in eligible]
742
763
 
743
764
 
@@ -0,0 +1,258 @@
1
+ """The endpoint registry's config half (the operator's decision clm_f1fe183b55ed42e5).
2
+
3
+ The model endpoints this machine's components may call are named once, in
4
+ ``config.endpoints``, each for the model it serves in short, and a component names the
5
+ one it uses:
6
+
7
+ "endpoints": {
8
+ "Qwen3.5-122B": {"url": "https://node-1.example/v1", "model": "Intel/Qwen3.5-122B-A10B-int4-AutoRound",
9
+ "key_env": "NODE_1_API_KEY", "send_private": true}
10
+ }
11
+
12
+ ``url`` is an OpenAI-compatible base (``/chat/completions`` and ``/embeddings`` are appended
13
+ to it) and ``model`` the id asked for; what model actually answered is read from each
14
+ response. The key is named, never held: ``key_env`` is an environment variable and
15
+ ``key_keychain`` a login-keychain service (the endpoint's name is the item's account), read
16
+ when a call is made and never when the config is checked, so a key is never in a config, an
17
+ argv, a receipt or an error. ``plaintext_key`` lets a key go over ``http://``, as memorize's
18
+ ``--llm-insecure`` does. ``send_private`` says the endpoint may receive user-scoped records,
19
+ which no endpoint does by default: it is the endpoint's property (the fleet's own machine,
20
+ yes; a vendor's, no), and each reader of the registry honours it. Over ``http://`` it also
21
+ needs ``plaintext_private``, whatever the key: a key authenticates, and only https hides the
22
+ records in flight; a receipt then says ``plaintext``. ``timeout`` is a call's
23
+ limit in seconds.
24
+
25
+ The retrieval plugin reads the registry first (ctr_9e166c2f291b829b); the memorizer, the
26
+ assessor and the benchmark runners adopt it in their own stories. ``endpoints`` is an
27
+ untrusted-policy key (ledger.UNTRUSTED_POLICY): a clone that named its own would be choosing
28
+ where this machine sends its records, and which secret goes with them.
29
+
30
+ The client opens a socket to the endpoint the config names and nowhere else: it takes no
31
+ proxy from the environment (``HTTP_PROXY`` and its kin), and it follows no redirect, since
32
+ urllib would carry the ``Authorization`` header to wherever a proxy or a redirect pointed.
33
+ It reads a reply only up to MAX_REPLY_BYTES.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import math
39
+ import os
40
+ import re
41
+ import urllib.error
42
+ import urllib.parse
43
+ import urllib.request
44
+ from dataclasses import dataclass
45
+ from typing import Dict, List, Mapping, Optional, Tuple
46
+
47
+ ENTRY_KEYS = ("url", "model", "key_env", "key_keychain", "plaintext_key", "send_private", "plaintext_private",
48
+ "timeout")
49
+ # fields that would hold a secret itself: refused by name, so a key pasted into the config
50
+ # is an error rather than a key committed beside it
51
+ SECRET_FIELDS = ("key", "api_key", "apikey", "token", "bearer", "password", "secret")
52
+ NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$")
53
+ KEY_ENV = re.compile(r"^[A-Z_][A-Z0-9_]*$")
54
+ DEFAULT_TIMEOUT = 60.0
55
+ MAX_TIMEOUT = 600.0
56
+ MAX_REPLY_BYTES = 16 * 1024 * 1024 # a completion or a batch of embeddings is far smaller
57
+ DETAIL_CHARS = 300 # of an error reply, quoted in a degraded run's reason
58
+
59
+
60
+ class EndpointError(ValueError):
61
+ """An entry is malformed, or a call to it failed: the sentence says which, never the key."""
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Endpoint:
66
+ name: str
67
+ url: str
68
+ model: str
69
+ key_env: Optional[str] = None
70
+ key_keychain: Optional[str] = None
71
+ plaintext_key: bool = False
72
+ send_private: bool = False
73
+ plaintext_private: bool = False
74
+ timeout: float = DEFAULT_TIMEOUT
75
+
76
+ def key(self, env: Optional[Mapping[str, str]] = None) -> Optional[str]:
77
+ """The key this endpoint's entry names, read now; None when it names none. A named
78
+ key that is not there is an error: the config says this endpoint wants one."""
79
+ if self.key_env:
80
+ found = (os.environ if env is None else env).get(self.key_env)
81
+ if not found:
82
+ raise EndpointError(f"endpoint {self.name!r}: its key_env {self.key_env} is not set")
83
+ return found
84
+ if self.key_keychain:
85
+ from .remote import keychain_token # local: the keychain is read only for a call
86
+ found = keychain_token(self.key_keychain, self.name)
87
+ if not found:
88
+ raise EndpointError(f"endpoint {self.name!r}: no login-keychain item with service "
89
+ f"{self.key_keychain} and account {self.name}")
90
+ return found
91
+ return None
92
+
93
+ def describe(self) -> dict:
94
+ """What a receipt says of the endpoint: never its key, nor where the key is kept; and
95
+ ``plaintext`` when it is sent user-scoped records over http://."""
96
+ out = {"endpoint": self.name, "url": self.url, "model": self.model, "send_private": self.send_private}
97
+ if self.send_private and self.url.startswith("http://"):
98
+ out["plaintext"] = True
99
+ return out
100
+
101
+
102
+ def check(name: str, entry) -> Endpoint:
103
+ """The entry ``name`` as an Endpoint, or EndpointError saying what is wrong with it."""
104
+ if not isinstance(name, str) or not NAME.match(name):
105
+ raise EndpointError(f"an endpoint's name is letters, digits and _.:- (up to 64), not {name!r}")
106
+ if not isinstance(entry, dict):
107
+ raise EndpointError(f"endpoint {name!r} must be an object with url and model")
108
+ secrets = sorted(set(entry) & set(SECRET_FIELDS))
109
+ if secrets:
110
+ raise EndpointError(f"endpoint {name!r}: a key never goes in the config ({', '.join(secrets)}); name "
111
+ f"where it is kept with key_env or key_keychain")
112
+ unknown = sorted(set(entry) - set(ENTRY_KEYS))
113
+ if unknown:
114
+ raise EndpointError(f"endpoint {name!r} has keys the registry does not read: {unknown}")
115
+ url = entry.get("url")
116
+ parts = urllib.parse.urlsplit(url) if isinstance(url, str) else None
117
+ if parts is None or parts.scheme not in ("https", "http") or not parts.hostname:
118
+ raise EndpointError(f"endpoint {name!r}: url must be an http(s) URL, not {url!r}")
119
+ if parts.username is not None or parts.password is not None or parts.query or parts.fragment:
120
+ # a receipt names the url, so nothing that could be a secret may ride in it
121
+ raise EndpointError(f"endpoint {name!r}: url must carry no credentials, query or fragment")
122
+ model = entry.get("model")
123
+ if not isinstance(model, str) or not model.strip():
124
+ raise EndpointError(f"endpoint {name!r}: model is required (the id to ask for)")
125
+ key_env, key_keychain = entry.get("key_env"), entry.get("key_keychain")
126
+ if key_env is not None and key_keychain is not None:
127
+ raise EndpointError(f"endpoint {name!r}: name one key, key_env or key_keychain")
128
+ if key_env is not None and not (isinstance(key_env, str) and KEY_ENV.match(key_env)):
129
+ raise EndpointError(f"endpoint {name!r}: key_env must be an environment variable's name")
130
+ if key_keychain is not None and not (isinstance(key_keychain, str) and key_keychain.strip()):
131
+ raise EndpointError(f"endpoint {name!r}: key_keychain must be a keychain service's name")
132
+ flags = {}
133
+ for flag in ("plaintext_key", "send_private", "plaintext_private"):
134
+ value = entry.get(flag, False)
135
+ if not isinstance(value, bool):
136
+ raise EndpointError(f"endpoint {name!r}: {flag} must be true or false")
137
+ flags[flag] = value
138
+ if (key_env or key_keychain) and parts.scheme == "http" and not flags["plaintext_key"]:
139
+ raise EndpointError(f"endpoint {name!r}: refusing to send its key to a plaintext endpoint ({url}); use "
140
+ f"https, or set plaintext_key if you accept the risk")
141
+ if flags["send_private"] and parts.scheme == "http" and not flags["plaintext_private"]:
142
+ # whatever its key: a key authenticates, and only https hides the records in flight
143
+ raise EndpointError(f"endpoint {name!r}: refusing to send user-scoped records to a plaintext endpoint "
144
+ f"({url}); use https, or set plaintext_private if you accept the risk")
145
+ timeout = entry.get("timeout", DEFAULT_TIMEOUT)
146
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0 < timeout <= MAX_TIMEOUT:
147
+ raise EndpointError(f"endpoint {name!r}: timeout must be seconds in (0, {MAX_TIMEOUT:g}]")
148
+ return Endpoint(name=name, url=url.rstrip("/"), model=model, key_env=key_env, key_keychain=key_keychain,
149
+ timeout=float(timeout), **flags)
150
+
151
+
152
+ def endpoint(config: Optional[dict], name) -> Endpoint:
153
+ """The endpoint ``name`` from ``config.endpoints``, or EndpointError."""
154
+ table = (config or {}).get("endpoints")
155
+ if table is None:
156
+ table = {}
157
+ if not isinstance(table, dict):
158
+ raise EndpointError("config.endpoints must be an object of endpoints by name")
159
+ if not isinstance(name, str) or name not in table:
160
+ raise EndpointError(f"no endpoint named {name!r} in config.endpoints")
161
+ return check(name, table[name])
162
+
163
+
164
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
165
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
166
+ return None # the 3xx is answered as an error: the key goes nowhere but the named url
167
+
168
+
169
+ def _opener() -> urllib.request.OpenerDirector:
170
+ """No proxy, which urllib would otherwise take from HTTP_PROXY and its kin (an http one
171
+ reads the Authorization header), and no redirect: a socket to the named url and nowhere
172
+ else. Built for each call, as cheap as the call is not."""
173
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect)
174
+
175
+
176
+ def _scrub(text: str, key: Optional[str]) -> str:
177
+ return text.replace(key, "<key>") if key else text
178
+
179
+
180
+ def call(ep: Endpoint, path: str, payload: dict, env: Optional[Mapping[str, str]] = None) -> dict:
181
+ """POST ``payload`` to the endpoint's ``path`` and return the reply's JSON object."""
182
+ key = ep.key(env)
183
+ headers = {"Content-Type": "application/json"}
184
+ if key:
185
+ headers["Authorization"] = f"Bearer {key}"
186
+ req = urllib.request.Request(ep.url + path, data=json.dumps(payload).encode("utf-8"), headers=headers,
187
+ method="POST")
188
+ try:
189
+ with _opener().open(req, timeout=ep.timeout) as resp:
190
+ raw = resp.read(MAX_REPLY_BYTES + 1)
191
+ except urllib.error.HTTPError as exc:
192
+ try:
193
+ detail = exc.read(DETAIL_CHARS * 4).decode("utf-8", "replace")[:DETAIL_CHARS]
194
+ except OSError:
195
+ detail = ""
196
+ raise EndpointError(f"endpoint {ep.name!r} answered {exc.code}: {_scrub(detail, key)}") from None
197
+ except (urllib.error.URLError, OSError, ValueError) as exc: # refused, unreachable, timed out
198
+ raise EndpointError(f"endpoint {ep.name!r} could not be reached ({type(exc).__name__}: "
199
+ f"{_scrub(str(exc), key)[:DETAIL_CHARS]})") from None
200
+ if len(raw) > MAX_REPLY_BYTES:
201
+ raise EndpointError(f"endpoint {ep.name!r} replied with more than {MAX_REPLY_BYTES} bytes")
202
+ try:
203
+ body = json.loads(raw.decode("utf-8"))
204
+ except (UnicodeDecodeError, ValueError):
205
+ raise EndpointError(f"endpoint {ep.name!r} replied with something other than JSON") from None
206
+ if not isinstance(body, dict):
207
+ raise EndpointError(f"endpoint {ep.name!r} replied with JSON that is not an object")
208
+ return body
209
+
210
+
211
+ def usage(body: dict) -> dict:
212
+ """The reply's token counts, as numbers only: what the read cost is counted from."""
213
+ found = body.get("usage") if isinstance(body.get("usage"), dict) else {}
214
+ return {k: v for k, v in found.items() if k in ("prompt_tokens", "completion_tokens", "total_tokens")
215
+ and isinstance(v, int) and not isinstance(v, bool) and v >= 0}
216
+
217
+
218
+ def served(body: dict) -> Optional[str]:
219
+ model = body.get("model")
220
+ return model[:200] if isinstance(model, str) and model else None
221
+
222
+
223
+ def chat(ep: Endpoint, messages: List[dict], max_tokens: int, env: Optional[Mapping[str, str]] = None
224
+ ) -> Tuple[str, Optional[str], dict]:
225
+ """(the reply's text, the served model id, its usage) for one chat completion."""
226
+ body = call(ep, "/chat/completions", {"model": ep.model, "messages": messages, "temperature": 0.0,
227
+ "max_tokens": max_tokens, "stream": False}, env)
228
+ try:
229
+ text = body["choices"][0]["message"]["content"]
230
+ except (KeyError, IndexError, TypeError):
231
+ text = None
232
+ if not isinstance(text, str):
233
+ raise EndpointError(f"endpoint {ep.name!r} replied with no message content")
234
+ return text, served(body), usage(body)
235
+
236
+
237
+ def embeddings(ep: Endpoint, texts: List[str], env: Optional[Mapping[str, str]] = None
238
+ ) -> Tuple[List[List[float]], Optional[str], dict]:
239
+ """(one vector per text, in order; the served model id; the usage) from ``/embeddings``."""
240
+ body = call(ep, "/embeddings", {"model": ep.model, "input": list(texts)}, env)
241
+ data = body.get("data")
242
+ if not isinstance(data, list) or len(data) != len(texts):
243
+ raise EndpointError(f"endpoint {ep.name!r} replied with {len(data) if isinstance(data, list) else 'no'} "
244
+ f"embeddings for {len(texts)} texts")
245
+ by_index: Dict[int, List[float]] = {}
246
+ for position, item in enumerate(data):
247
+ index = item.get("index", position) if isinstance(item, dict) else None
248
+ vector = item.get("embedding") if isinstance(item, dict) else None
249
+ if (not isinstance(index, int) or isinstance(index, bool) or not 0 <= index < len(texts)
250
+ or index in by_index or not isinstance(vector, list) or not vector
251
+ or not all(isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x)
252
+ for x in vector)):
253
+ raise EndpointError(f"endpoint {ep.name!r} replied with a malformed embedding")
254
+ by_index[index] = [float(x) for x in vector]
255
+ dims = {len(v) for v in by_index.values()}
256
+ if len(dims) != 1:
257
+ raise EndpointError(f"endpoint {ep.name!r} replied with embeddings of different lengths")
258
+ return [by_index[i] for i in range(len(texts))], served(body), usage(body)
@@ -1530,6 +1530,49 @@ def _git(args: Sequence[str], cwd: str) -> subprocess.CompletedProcess:
1530
1530
  timeout=GIT_CHECK_SECONDS)
1531
1531
 
1532
1532
 
1533
+ def _branch_copies(branch: str, repo: str) -> List[str]:
1534
+ """The names a binding's branch may go by in ``repo``, in the order they are asked: as
1535
+ given, then origin/<branch>, then <remote>/<branch> under the clone's other remotes. A
1536
+ clean clone holds a pull request's branch only as origin/<branch>, and a lane's local
1537
+ branches are the ones it was cloned with, never moved since (ctr_aff56f06786902ec)."""
1538
+ others = sorted(name for name in _git(["remote"], repo).stdout.split() if name != "origin")
1539
+ # a name that begins with a dash is no ref (check-ref-format refuses it) and would be an option
1540
+ given = [] if branch.startswith("-") else [branch]
1541
+ return [*given, *(f"{remote}/{branch}" for remote in ["origin", *others])]
1542
+
1543
+
1544
+ def _on_branch(sha: str, branch: str, repo: str) -> Tuple[str, str]:
1545
+ """Whether the commit is on the binding's branch, asked of every copy of the branch this
1546
+ clone holds: a match when one contains it, a mismatch only when every copy that resolves
1547
+ answers that it does not (git exits 1), and an error when none resolves or git cannot
1548
+ answer -- a branch this clone does not hold is a question it could not ask, never a
1549
+ refutation (ctr_aff56f06786902ec). A copy that still holds the commit matches though
1550
+ another was rewritten past it: a refutation taints what was built on the claim, so it
1551
+ needs every copy to say no."""
1552
+ tried: List[str] = []
1553
+ behind: List[str] = []
1554
+ failed = ""
1555
+ for name in _branch_copies(branch, repo):
1556
+ tried.append(name)
1557
+ ref = name if name == branch else f"refs/remotes/{name}"
1558
+ tip = _git(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], repo).stdout.strip()
1559
+ if not tip:
1560
+ continue
1561
+ r = _git(["merge-base", "--is-ancestor", sha, tip, "--"], repo)
1562
+ if r.returncode == 0:
1563
+ return "match", f"commit {sha[:12]} present, on {branch}" + (f" as {name}" if name != branch else "")
1564
+ if r.returncode == 1:
1565
+ behind.append(f"{name} at {tip[:12]}")
1566
+ else:
1567
+ failed = failed or f"git merge-base on {name} exited {r.returncode}: {(r.stderr or '').strip()[:200]}"
1568
+ if failed:
1569
+ return "error", failed
1570
+ if behind:
1571
+ return "mismatch", f"commit {sha} is not an ancestor of {branch} ({', '.join(behind)})"
1572
+ return "error", (f"branch {branch} is not in this clone of {repo} (tried {', '.join(tried)}); "
1573
+ f"fetch it to re-check")
1574
+
1575
+
1533
1576
  class BindingError(ValueError):
1534
1577
  """A binding that could not be built on this host: a commit its repository does not
1535
1578
  hold, a file that is missing or not a regular one. The message is this host's detail
@@ -2349,8 +2392,8 @@ def _check_binding(body: dict, cwd: Optional[str] = None, strict: bool = False,
2349
2392
  return "error", f"repo missing: {repo}"
2350
2393
  try:
2351
2394
  r = _git(["cat-file", "-e", "--", f"{body['sha']}^{{commit}}"], repo)
2352
- r2 = (_git(["merge-base", "--is-ancestor", body["sha"], body["branch"], "--"], repo)
2353
- if r.returncode == 0 and body.get("branch") else None)
2395
+ on_branch = (_on_branch(body["sha"], body["branch"], repo)
2396
+ if r.returncode == 0 and body.get("branch") else None)
2354
2397
  except subprocess.TimeoutExpired:
2355
2398
  return "error", f"git did not answer within {GIT_CHECK_SECONDS}s in {repo}"
2356
2399
  if r.returncode != 0:
@@ -2365,8 +2408,8 @@ def _check_binding(body: dict, cwd: Optional[str] = None, strict: bool = False,
2365
2408
  # mismatch here would refute every merged outcome a lane on main re-checks.
2366
2409
  # (No notes["missing"]: the repo was found, so no path map rule is to blame)
2367
2410
  return "error", f"commit {body['sha']} is not in this clone of {repo}; fetch it to re-check"
2368
- if r2 is not None and r2.returncode != 0:
2369
- return "mismatch", f"commit {body['sha']} is not an ancestor of {body['branch']}"
2411
+ if on_branch is not None:
2412
+ return on_branch
2370
2413
  return "match", f"commit {body['sha'][:12]} present"
2371
2414
  if kind == "file":
2372
2415
  p = Path(body["path"])
@@ -109,9 +109,17 @@ def _install_claude(plugins: Path, scope: str, dry_run: bool) -> int:
109
109
  return 0
110
110
 
111
111
 
112
+ # Every SessionStart source Codex has: after it compacts a session, the hooks that match
113
+ # `compact` "run before the next model request" (https://learn.chatgpt.com/docs/hooks), so
114
+ # the approvals line comes back with the rest of the context.
115
+ CODEX_SESSION_SOURCES = "startup|resume|clear|compact"
116
+
117
+
112
118
  def _merge_codex_hook(hooks_file: Path, command: str) -> dict:
113
119
  """~/.codex/hooks.json with the plugin's SessionStart entry in it once: an entry of ours
114
- already there (a re-run) is replaced, everything else is left as it was."""
120
+ already there (a re-run) is replaced, everything else is left as it was. One under
121
+ another matcher (an older install's startup|resume) moves to a group of its own with
122
+ CODEX_SESSION_SOURCES, and a group that held nothing else goes with it."""
115
123
  doc = {}
116
124
  if hooks_file.is_file():
117
125
  doc = json.loads(hooks_file.read_text(encoding="utf-8") or "{}")
@@ -121,12 +129,27 @@ def _merge_codex_hook(hooks_file: Path, command: str) -> dict:
121
129
  if not isinstance(groups, list):
122
130
  raise ValueError(f"{hooks_file}: hooks.SessionStart is not a list; fix it by hand")
123
131
  entry = {"type": "command", "command": command, "timeout": 25}
132
+
133
+ def mine(have) -> bool:
134
+ return isinstance(have, dict) and PLUGIN_HOOK_MARK in str(have.get("command", ""))
135
+
136
+ placed, kept = False, []
124
137
  for group in groups:
125
- for i, have in enumerate(group.get("hooks") or [] if isinstance(group, dict) else []):
126
- if isinstance(have, dict) and PLUGIN_HOOK_MARK in str(have.get("command", "")):
127
- group["hooks"][i] = entry
128
- return doc
129
- groups.append({"matcher": "startup|resume", "hooks": [entry]})
138
+ hooks = group.get("hooks") if isinstance(group, dict) else None
139
+ if not isinstance(hooks, list) or not any(mine(h) for h in hooks):
140
+ kept.append(group)
141
+ continue
142
+ if not placed and group.get("matcher") == CODEX_SESSION_SOURCES:
143
+ first = next(i for i, h in enumerate(hooks) if mine(h))
144
+ group["hooks"] = [entry if i == first else h for i, h in enumerate(hooks) if i == first or not mine(h)]
145
+ placed = True
146
+ else:
147
+ group["hooks"] = [h for h in hooks if not mine(h)]
148
+ if group["hooks"]:
149
+ kept.append(group)
150
+ if not placed:
151
+ kept.append({"matcher": CODEX_SESSION_SOURCES, "hooks": [entry]})
152
+ groups[:] = kept
130
153
  return doc
131
154
 
132
155
 
@@ -233,6 +233,25 @@ def fetch_commit(clone: str, sha: str, log: Optional[Callable[[dict], None]] = N
233
233
  return False
234
234
 
235
235
 
236
+ def fetch_branch(clone: str, branch: str, log: Optional[Callable[[dict], None]] = None) -> None:
237
+ """Bring origin's ``branch`` to refs/remotes/origin/<branch>, so a binding's branch is
238
+ asked about as origin has it now: fetch_commit takes main and the pull requests' heads,
239
+ so a branch pushed after the clone was made is under no name in it at all
240
+ (ctr_aff56f06786902ec). A branch origin no longer has (merged and deleted) keeps the tip
241
+ this clone last saw, or stays absent, which evidence.check calls a question it could
242
+ not ask."""
243
+ # a name that is not a ref under refs/heads is never put in a refspec: a colon in it
244
+ # would name the destination
245
+ if not branch or _git(clone, "check-ref-format", f"refs/heads/{branch}").returncode != 0:
246
+ return
247
+ with clone_lock(clone, FETCH_SECONDS, log, "fetch branch"):
248
+ try:
249
+ _git(clone, "fetch", "--quiet", "--no-tags", "origin", "--",
250
+ f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_SECONDS)
251
+ except subprocess.SubprocessError:
252
+ pass # the tip this clone last saw, if any, is what it can tell
253
+
254
+
236
255
  def main_head(clone: str, log: Optional[Callable[[dict], None]] = None) -> Optional[str]:
237
256
  with clone_lock(clone, FETCH_SECONDS, log, "fetch main"):
238
257
  try:
@@ -799,6 +818,8 @@ class Lane:
799
818
  sha = str(binding.get("sha") or "")
800
819
  if not fetch_commit(self.clone, sha, log=self.log):
801
820
  return None, "none", f"commit {sha[:12]} is not in the lane's clone or its origin"
821
+ if binding.get("branch"):
822
+ fetch_branch(self.clone, str(binding["branch"]), log=self.log)
802
823
  with worktree(self.clone, sha, self.workdir, log=self.log) as wt:
803
824
  base = self._base_map(wt)
804
825
  # last, so it is the rule for its prefix: PathMap keeps the last rule a prefix is
@@ -131,6 +131,7 @@ UNTRUSTED_POLICY = {
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
133
  "retrieval": None, # the plugin a compile runs (ctr_9e166c2f291b829b): the machine's, never a clone's
134
+ "endpoints": {}, # where this machine sends records and which secret goes with them (endpoints.py)
134
135
  "redact": [], # an attacker-supplied regex can hang capture (catastrophic backtracking)
135
136
  "path_map": [], # where a re-check runs
136
137
  "org_path": None, # what it reads and writes outside the checkout