strata-agent-memory 0.2.7__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 (179) hide show
  1. {strata_agent_memory-0.2.7/strata_agent_memory.egg-info → strata_agent_memory-0.2.9}/PKG-INFO +4 -1
  2. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/__init__.py +1 -1
  3. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/cli.py +47 -1
  4. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/compiler.py +128 -29
  5. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/contracts.py +2 -1
  6. strata_agent_memory-0.2.9/agent_memory/endpoints.py +258 -0
  7. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/evidence.py +159 -16
  8. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/kit.py +29 -6
  9. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/lane.py +21 -0
  10. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/ledger.py +2 -0
  11. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/mcp_server.py +16 -7
  12. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/remote.py +43 -5
  13. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/render.py +7 -1
  14. strata_agent_memory-0.2.9/agent_memory/retrieval.py +878 -0
  15. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/serve.py +17 -0
  16. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/README.md +10 -2
  17. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/agent-session.sh +30 -3
  18. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/backup-tokens.sh +4 -2
  19. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/install-hooks-mac.sh +25 -1
  20. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/install-replica-driver.sh +104 -71
  21. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/onboard-agent.sh +9 -4
  22. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/upgrade-driver.sh +23 -7
  23. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/pyproject.toml +21 -4
  24. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9/strata_agent_memory.egg-info}/PKG-INFO +4 -1
  25. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/SOURCES.txt +17 -1
  26. strata_agent_memory-0.2.9/strata_agent_memory.egg-info/requires.txt +7 -0
  27. strata_agent_memory-0.2.9/tests/test_binding_refusal_97e36882.py +122 -0
  28. strata_agent_memory-0.2.9/tests/test_build_recheck_image_e4a93fd6.py +51 -0
  29. strata_agent_memory-0.2.9/tests/test_compact_window_778d310f.py +403 -0
  30. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_deploy_mac.py +25 -5
  31. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_install_lane_cb430a30.py +1 -1
  32. strata_agent_memory-0.2.9/tests/test_memoryagentbench_60a8abfc.py +374 -0
  33. strata_agent_memory-0.2.9/tests/test_memsyco_7b16e8e4.py +553 -0
  34. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_packaging.py +2 -0
  35. strata_agent_memory-0.2.9/tests/test_proof_exclusions_e4a93fd6.py +80 -0
  36. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_recheck_sandbox_543e48a7.py +24 -2
  37. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_client.py +1 -1
  38. strata_agent_memory-0.2.9/tests/test_reply_cap_cfbd7681.py +229 -0
  39. strata_agent_memory-0.2.9/tests/test_retrieval_llm_9e166c2f.py +907 -0
  40. strata_agent_memory-0.2.9/tests/test_retrieval_plugin_9e166c2f.py +364 -0
  41. strata_agent_memory-0.2.9/tests/test_retrieval_vectors_9e166c2f.py +648 -0
  42. strata_agent_memory-0.2.9/tests/test_sandbox_image_e4a93fd6.py +320 -0
  43. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_served_ledger_6e8e49f3.py +4 -0
  44. strata_agent_memory-0.2.9/tests/test_taskset_66bac366.py +356 -0
  45. strata_agent_memory-0.2.9/tests/test_tools_clone_4b72f0c1.py +232 -0
  46. strata_agent_memory-0.2.9/tests/test_verify_branch_aff56f06.py +255 -0
  47. strata_agent_memory-0.2.7/strata_agent_memory.egg-info/requires.txt +0 -3
  48. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/LICENSE +0 -0
  49. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/README.md +0 -0
  50. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/__main__.py +0 -0
  51. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/approvals.py +0 -0
  52. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/audit.py +0 -0
  53. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/compact.py +0 -0
  54. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/export.py +0 -0
  55. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/hooks.py +0 -0
  56. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/identity.py +0 -0
  57. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/instances.py +0 -0
  58. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/measure.py +0 -0
  59. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/memorizer.py +0 -0
  60. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/pathmap.py +0 -0
  61. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/recheck.py +0 -0
  62. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/records.py +0 -0
  63. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/relay/__init__.py +0 -0
  64. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/relay/adapters.py +0 -0
  65. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/relay/cli.py +0 -0
  66. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/relay/client.py +0 -0
  67. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/relay/core.py +0 -0
  68. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/reliability.py +0 -0
  69. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/roster.py +0 -0
  70. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/signing.py +0 -0
  71. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/stats.py +0 -0
  72. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/supersession.py +0 -0
  73. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/trust.py +0 -0
  74. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/agent_memory/wake.py +0 -0
  75. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/lib/served-ledger.sh +0 -0
  76. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/agent-keys.sh +0 -0
  77. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/install-relay-driver.sh +0 -0
  78. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/install-runner-driver.sh +0 -0
  79. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/instances.example.json +0 -0
  80. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/relay-agent.sh +0 -0
  81. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/seed-board.sh +0 -0
  82. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/seed-mail-context.sh +0 -0
  83. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/mac/seed-topics.sh +0 -0
  84. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/.claude-plugin/marketplace.json +0 -0
  85. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/README.md +0 -0
  86. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/.claude-plugin/plugin.json +0 -0
  87. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/bin/agent-memory-approvals +0 -0
  88. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/bin/agent-memory-wake +0 -0
  89. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/hooks/hooks.json +0 -0
  90. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/monitors/monitors.json +0 -0
  91. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/rules/approvals.md +0 -0
  92. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/approvals/SKILL.md +0 -0
  93. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/approve/SKILL.md +0 -0
  94. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/inbox/SKILL.md +0 -0
  95. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/claude-code/skills/reject/SKILL.md +0 -0
  96. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-answer/SKILL.md +0 -0
  97. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-answer/agents/openai.yaml +0 -0
  98. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/SKILL.md +0 -0
  99. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/agents/openai.yaml +0 -0
  100. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/rules/approvals.md +0 -0
  101. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/plugins/codex/skills/agent-memory-approvals/scripts/agent-memory-approvals +0 -0
  102. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/README.md +0 -0
  103. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/agent-memory-relay@.service +0 -0
  104. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/install-relay-mac.sh +0 -0
  105. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/install-relay.sh +0 -0
  106. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +0 -0
  107. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/deploy/relay/relay-launch.sh +0 -0
  108. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/setup.cfg +0 -0
  109. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
  110. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/entry_points.txt +0 -0
  111. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/strata_agent_memory.egg-info/top_level.txt +0 -0
  112. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_assessment_67b26eae.py +0 -0
  113. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_audit.py +0 -0
  114. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_audit_lane_numbers_ea89ba26.py +0 -0
  115. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_audit_measurement_13190a20.py +0 -0
  116. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_backlog_check.py +0 -0
  117. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_changelog_fragments.py +0 -0
  118. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_changelog_guard_shallow_57a2f78e.py +0 -0
  119. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_ci_docs_only_353ce010.py +0 -0
  120. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_cli.py +0 -0
  121. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_compact.py +0 -0
  122. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_compiler.py +0 -0
  123. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_compiler_render.py +0 -0
  124. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_config.py +0 -0
  125. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_contract_amend_649e0b45.py +0 -0
  126. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_contracts.py +0 -0
  127. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_deploy_relay.py +0 -0
  128. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_deploy_replica.py +0 -0
  129. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_deploy_tokens.py +0 -0
  130. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_evidence.py +0 -0
  131. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_find_root_b4b5019b.py +0 -0
  132. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_hooks.py +0 -0
  133. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_identity.py +0 -0
  134. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_issue_bridge.py +0 -0
  135. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_lane_63b7cf8b.py +0 -0
  136. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_lane_derived_roots_817ab446.py +0 -0
  137. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_lane_nightly_84b22b63.py +0 -0
  138. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_lane_priority_779d51ba.py +0 -0
  139. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_lane_stop_63b7cf8b.py +0 -0
  140. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_ledger.py +0 -0
  141. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_ledger_index.py +0 -0
  142. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_live_wake_c217c336.py +0 -0
  143. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_local_lanes_sibling_1411a2b2.py +0 -0
  144. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_memorizer.py +0 -0
  145. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_outcome_commit_binding_b1de564d.py +0 -0
  146. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_outcome_needs_evidence_b1de564d.py +0 -0
  147. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_pathmap.py +0 -0
  148. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_phase2.py +0 -0
  149. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_plugins.py +0 -0
  150. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_receipt_reports_274e6199.py +0 -0
  151. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_recheck.py +0 -0
  152. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_recheck_away_207af145.py +0 -0
  153. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_recheck_remote_8185d2d3.py +0 -0
  154. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_recheck_sandbox_nits_543e48a7.py +0 -0
  155. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_records.py +0 -0
  156. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_redaction.py +0 -0
  157. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_adapters.py +0 -0
  158. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_codex.py +0 -0
  159. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_core.py +0 -0
  160. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_instance_2a2e3887.py +0 -0
  161. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_release_526f5261.py +0 -0
  162. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_relay_release_gate_b3b3e487.py +0 -0
  163. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_reliability.py +0 -0
  164. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_remote.py +0 -0
  165. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_remote_signing.py +0 -0
  166. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_review2_fixes.py +0 -0
  167. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_review3_fixes.py +0 -0
  168. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_review_fixes.py +0 -0
  169. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_rollback_78710ebf.py +0 -0
  170. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_scale.py +0 -0
  171. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_serve.py +0 -0
  172. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_signing.py +0 -0
  173. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_sit_fleet_1b24b0b1.py +0 -0
  174. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_sit_fleet_ci_0d9b76a2.py +0 -0
  175. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_sit_var_bindings_e802ca84.py +0 -0
  176. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_stats.py +0 -0
  177. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_supersedes_61416280.py +0 -0
  178. {strata_agent_memory-0.2.7 → strata_agent_memory-0.2.9}/tests/test_trust.py +0 -0
  179. {strata_agent_memory-0.2.7 → 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.7
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.7"
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), "
@@ -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,55 @@ 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
+ 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])
735
+ if not task:
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)
749
+ try:
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
755
+ except retrieval_mod.RetrievalError as exc:
756
+ reported() # an analysis call made before the failure still cost what it cost
757
+ out.retrieval["error"] = str(exc)
758
+ print(f"agent-memory: the retrieval plugin {cfg['plugin']} did not run ({exc}); compiled without it",
759
+ file=sys.stderr)
760
+ return cfg, []
761
+ reported()
762
+ return cfg, [eligible[rid] for rid in found if rid in eligible]
763
+
764
+
671
765
  def compiled_from_json(payload: dict) -> Compiled:
672
766
  """Rebuild a Compiled from ``render.render_json``'s payload.
673
767
 
@@ -690,6 +784,7 @@ def compiled_from_json(payload: dict) -> Compiled:
690
784
  # saying it was cut
691
785
  board=[dict(row) for row in payload.get("board") or []],
692
786
  )
787
+ out.retrieval = payload.get("retrieval") if isinstance(payload.get("retrieval"), dict) else None
693
788
  display = payload.get("reader_display")
694
789
  out.reader_display = display if isinstance(display, str) and display else None
695
790
  out.config_notes = [n for n in payload.get("config_notes") or [] if isinstance(n, str)]
@@ -840,6 +935,10 @@ def record_compilation(ledger, compiled: Compiled, rendered: str, render_name: s
840
935
  # which reliability numbers the reader was shown, or null when annotation was off
841
936
  "reliability": compiled.reliability_meta,
842
937
  }
938
+ if compiled.retrieval is not None:
939
+ # which configuration produced the view (ctr_9e166c2f291b829b): the plugin, its
940
+ # share, the ids it added, and why it did not run when it did not
941
+ body["retrieval"] = compiled.retrieval
843
942
  # which repository the view was cut to, and where it was served and asked from: set
844
943
  # only when they are, so a plain view's receipt is what it always was
845
944
  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}"
@@ -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)