bantamkit 0.35.4__tar.gz → 0.35.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. {bantamkit-0.35.4 → bantamkit-0.35.6}/PKG-INFO +3 -3
  2. {bantamkit-0.35.4 → bantamkit-0.35.6}/README.md +2 -2
  3. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/memory_recall.json +3 -2
  4. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/__init__.py +1 -1
  5. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/hookadapter.py +125 -6
  6. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/component.py +114 -3
  7. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/store.py +34 -0
  8. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/served-tool-surface.json +3 -2
  9. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_hookadapter.py +379 -0
  10. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory.py +27 -0
  11. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_component.py +171 -0
  12. {bantamkit-0.35.4 → bantamkit-0.35.6}/.gitignore +0 -0
  13. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/contracts/default.yaml +0 -0
  14. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/manifest.yaml +0 -0
  15. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/HISTORY.md +0 -0
  16. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/README.md +0 -0
  17. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/docs/architecture.md +0 -0
  18. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/docs/runbook.md +0 -0
  19. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/issues/142-settlement-timeout.md +0 -0
  20. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/patches/0009-retry-budget.patch +0 -0
  21. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/__init__.py +0 -0
  22. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/config.py +0 -0
  23. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/errors.py +0 -0
  24. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/posting.py +0 -0
  25. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/registry.py +0 -0
  26. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/report.py +0 -0
  27. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/retry.py +0 -0
  28. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/settle.py +0 -0
  29. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/src/ledger/validate.py +0 -0
  30. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/tests/test_posting.py +0 -0
  31. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/repo/tests/test_settle.py +0 -0
  32. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-error-contract.yaml +0 -0
  33. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-handler-map.yaml +0 -0
  34. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-patch-before-after.yaml +0 -0
  35. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-retry-attempts.yaml +0 -0
  36. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-settlement-config.yaml +0 -0
  37. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-symbol-home.yaml +0 -0
  38. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-trace-blame.yaml +0 -0
  39. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/devteam/tasks/dt-unread-key.yaml +0 -0
  40. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-in-137.yaml +0 -0
  41. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-in-359.yaml +0 -0
  42. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-in-372.yaml +0 -0
  43. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-out-11764.yaml +0 -0
  44. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-out-4137.yaml +0 -0
  45. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-large-out-8022.yaml +0 -0
  46. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-small-137.yaml +0 -0
  47. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-small-261.yaml +0 -0
  48. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/document/tasks/doc-small-388.yaml +0 -0
  49. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/fixtures/.gitkeep +0 -0
  50. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/fixtures/catalog.json +0 -0
  51. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/perturbations/task-completion.yaml +0 -0
  52. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/.gitkeep +0 -0
  53. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/extract-contact.yaml +0 -0
  54. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/extract-invoice.yaml +0 -0
  55. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/extract-order.yaml +0 -0
  56. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/extract-schedule.yaml +0 -0
  57. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/extract-versions.yaml +0 -0
  58. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/nav-prod-port.yaml +0 -0
  59. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/nav-release-bundle.yaml +0 -0
  60. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-audit-retention.yaml +0 -0
  61. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-cache-ttl.yaml +0 -0
  62. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-db-port.yaml +0 -0
  63. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-deploy.yaml +0 -0
  64. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-env-endpoint.yaml +0 -0
  65. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-oncall-rotation.yaml +0 -0
  66. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-oncall.yaml +0 -0
  67. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-org-quota.yaml +0 -0
  68. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/recall-owner.yaml +0 -0
  69. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-basket-total.yaml +0 -0
  70. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-cheapest.yaml +0 -0
  71. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-compare.yaml +0 -0
  72. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-gadget-value.yaml +0 -0
  73. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-stock-total.yaml +0 -0
  74. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/evals/tasks/shop-total.yaml +0 -0
  75. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/pricing/default.json +0 -0
  76. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/profiles/default.yaml +0 -0
  77. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/profiles/patient.yaml +0 -0
  78. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/rubrics/.gitkeep +0 -0
  79. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/rubrics/code-quality.yaml +0 -0
  80. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/rubrics/grounded-completion.yaml +0 -0
  81. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/rubrics/task-completion.yaml +0 -0
  82. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/schemas/shiftwork-checkpoint.json +0 -0
  83. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/skills/.gitkeep +0 -0
  84. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/skills/file-graph.md +0 -0
  85. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/skills/memory.md +0 -0
  86. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/.gitkeep +0 -0
  87. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/bantamkit_read.json +0 -0
  88. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/bantamkit_status.json +0 -0
  89. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/build_identity.json +0 -0
  90. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/document_list.json +0 -0
  91. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/document_read.json +0 -0
  92. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/file_graph.json +0 -0
  93. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/memory_compact.json +0 -0
  94. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/memory_dream.json +0 -0
  95. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/memory_save.json +0 -0
  96. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/repo_map.json +0 -0
  97. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/shiftwork_clock_in.json +0 -0
  98. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/shiftwork_clock_out.json +0 -0
  99. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/shiftwork_plan.json +0 -0
  100. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/shiftwork_status.json +0 -0
  101. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/skill_audit.json +0 -0
  102. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/token_ledger.json +0 -0
  103. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/validate_json.json +0 -0
  104. {bantamkit-0.35.4 → bantamkit-0.35.6}/_assets/tools/work_plan.json +0 -0
  105. {bantamkit-0.35.4 → bantamkit-0.35.6}/hatch_build.py +0 -0
  106. {bantamkit-0.35.4 → bantamkit-0.35.6}/pyproject.toml +0 -0
  107. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/agent.py +0 -0
  108. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/assets.py +0 -0
  109. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/budget.py +0 -0
  110. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/client.py +0 -0
  111. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/contract.py +0 -0
  112. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/criticreplay.py +0 -0
  113. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/critique.py +0 -0
  114. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/docmanifest.py +0 -0
  115. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/docread.py +0 -0
  116. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/evalrun.py +0 -0
  117. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/eventlog.py +0 -0
  118. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/filegraph.py +0 -0
  119. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/hostinstall.py +0 -0
  120. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/loopguard.py +0 -0
  121. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/mcpreport.py +0 -0
  122. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/mcpserver.py +0 -0
  123. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/__init__.py +0 -0
  124. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/__main__.py +0 -0
  125. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/divergence.py +0 -0
  126. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/dream.py +0 -0
  127. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/memory/layers.py +0 -0
  128. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/pdfread.py +0 -0
  129. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/pricing.py +0 -0
  130. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/profile.py +0 -0
  131. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/repomap.py +0 -0
  132. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/selfupdate.py +0 -0
  133. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/shiftwork.py +0 -0
  134. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/skillaudit.py +0 -0
  135. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/statusline.py +0 -0
  136. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/structured.py +0 -0
  137. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/textutil.py +0 -0
  138. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/tokenledger.py +0 -0
  139. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/updatecheck.py +0 -0
  140. {bantamkit-0.35.4 → bantamkit-0.35.6}/src/bantamkit/workplan.py +0 -0
  141. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/cli_exit_status_probe.py +0 -0
  142. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/conftest.py +0 -0
  143. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/bad-crc.docx +0 -0
  144. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/charref-4301-digits.html +0 -0
  145. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/charset-table.json +0 -0
  146. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/compression-method-9.docx +0 -0
  147. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/corrupt-deflate.docx +0 -0
  148. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/encrypted-member.docx +0 -0
  149. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/encrypted-mimetype.odt +0 -0
  150. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/eszett-cell-ref.xlsx +0 -0
  151. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/internal-dtd-entity.docx +0 -0
  152. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/rfc2231-charset.eml +0 -0
  153. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/rfc822-nested-twice.eml +0 -0
  154. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/unicode-digit-shared-string.xlsx +0 -0
  155. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/docread/x-uuencode.eml +0 -0
  156. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/f8404ab-perturbation-baseline.json +0 -0
  157. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/data/platform-assumption-baseline.json +0 -0
  158. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/docread_fixtures.py +0 -0
  159. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/perturbation_baseline_harness.py +0 -0
  160. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/rbp16_effect_probe.py +0 -0
  161. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/rbp18_payload_probe.py +0 -0
  162. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_adapter.py +0 -0
  163. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_agent.py +0 -0
  164. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_amendguard.py +0 -0
  165. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_bantamkit_gitignore.py +0 -0
  166. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_bantamkit_read_tool.py +0 -0
  167. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_budget.py +0 -0
  168. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_build_identity.py +0 -0
  169. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_client.py +0 -0
  170. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_compaction_corpus_survey.py +0 -0
  171. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_conformance.py +0 -0
  172. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_conformance_harness_resilience.py +0 -0
  173. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_conformance_suite_table_gate.py +0 -0
  174. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_contract_fanout.py +0 -0
  175. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_criticreplay.py +0 -0
  176. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_critique.py +0 -0
  177. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_doc_commands_gate.py +0 -0
  178. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_docread.py +0 -0
  179. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_docread_ceilings.py +0 -0
  180. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_document_manifest_parity.py +0 -0
  181. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_document_setup.py +0 -0
  182. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_document_tasks.py +0 -0
  183. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_document_tools.py +0 -0
  184. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_encoding_gate.py +0 -0
  185. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_evalrun.py +0 -0
  186. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_eventlog.py +0 -0
  187. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_field_program_gates.py +0 -0
  188. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_field_programs.py +0 -0
  189. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_filegraph.py +0 -0
  190. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_hostinstall.py +0 -0
  191. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_hostinstall_hooks.py +0 -0
  192. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_install_shape.py +0 -0
  193. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_ladder_statistics.py +0 -0
  194. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_launcher_which.py +0 -0
  195. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_layers.py +0 -0
  196. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_loopguard.py +0 -0
  197. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_mcp_endpoint.py +0 -0
  198. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_mcpdrift.py +0 -0
  199. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_mcpreport.py +0 -0
  200. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_mcpserver.py +0 -0
  201. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_compact_tool.py +0 -0
  202. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_divergence.py +0 -0
  203. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_dream.py +0 -0
  204. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_layers.py +0 -0
  205. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_memory_store_tripwire.py +0 -0
  206. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_mutmatrix.py +0 -0
  207. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_nativeexport.py +0 -0
  208. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_newline_gate.py +0 -0
  209. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_packaging.py +0 -0
  210. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_pdfread.py +0 -0
  211. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_pinharness_ledger.py +0 -0
  212. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_platform_assumption_gate.py +0 -0
  213. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_pricing.py +0 -0
  214. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_readme_separation.py +0 -0
  215. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_repo_map_tool.py +0 -0
  216. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_repomap.py +0 -0
  217. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_selfupdate.py +0 -0
  218. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_served_tool_count_records.py +0 -0
  219. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_shiftwork.py +0 -0
  220. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_skillaudit.py +0 -0
  221. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_status_surface.py +0 -0
  222. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_statusline.py +0 -0
  223. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_structured.py +0 -0
  224. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_thread_exception_gate.py +0 -0
  225. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_tokenledger.py +0 -0
  226. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_tool_manifest.py +0 -0
  227. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_updatecheck.py +0 -0
  228. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_version_agreement.py +0 -0
  229. {bantamkit-0.35.4 → bantamkit-0.35.6}/tests/test_workplan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bantamkit
3
- Version: 0.35.4
3
+ Version: 0.35.6
4
4
  Summary: Memory MCP server for Claude Code, Cursor, VS Code Copilot and Claude Desktop, plus a Python library that lifts small-model agents. Install once, run offline.
5
5
  Project-URL: Homepage, https://github.com/Ink01101011/bantamkit
6
6
  Project-URL: Repository, https://github.com/Ink01101011/bantamkit
@@ -103,9 +103,9 @@ CPU architecture and Python minor version** (some wheels, such as `pydantic_core
103
103
  one platform only), copy `wheels/` across, and install from it:
104
104
 
105
105
  ```bash
106
- python -m pip download "bantamkit[mcp]==0.35.4" -d wheels
106
+ python -m pip download "bantamkit[mcp]==0.35.6" -d wheels
107
107
  python -m venv <env>
108
- <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.4"
108
+ <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.6"
109
109
  <env>/bin/bantamkit-mcp --install cursor
110
110
  ```
111
111
 
@@ -74,9 +74,9 @@ CPU architecture and Python minor version** (some wheels, such as `pydantic_core
74
74
  one platform only), copy `wheels/` across, and install from it:
75
75
 
76
76
  ```bash
77
- python -m pip download "bantamkit[mcp]==0.35.4" -d wheels
77
+ python -m pip download "bantamkit[mcp]==0.35.6" -d wheels
78
78
  python -m venv <env>
79
- <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.4"
79
+ <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.6"
80
80
  <env>/bin/bantamkit-mcp --install cursor
81
81
  ```
82
82
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory_recall",
3
- "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns at most k matching facts.",
3
+ "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns the top word matches, never fewer than the store default of 3 when that many match; a query that is exactly a fact's name returns that fact alone.",
4
4
  "surfaces": [
5
5
  "agent",
6
6
  "mcp"
@@ -18,7 +18,8 @@
18
18
  "k": {
19
19
  "type": "integer",
20
20
  "minimum": 1,
21
- "maximum": 5
21
+ "maximum": 5,
22
+ "description": "Facts wanted. Raised to the store default of 3 when smaller; never lowered below it."
22
23
  }
23
24
  }
24
25
  },
@@ -29,4 +29,4 @@ from bantamkit.structured import StructuredOutputError, extract_json, structured
29
29
  # file as its dynamic version source, so the wheel's metadata and the string the MCP
30
30
  # server advertises are the same committed bytes, and neither is a function of when
31
31
  # someone last ran `pip`.
32
- __version__ = "0.35.4"
32
+ __version__ = "0.35.6"
@@ -375,6 +375,25 @@ def _write_ledger(run: HookRun, session_id: Any, ledger: dict[str, Any]) -> None
375
375
  fh.write(_dumps(ledger))
376
376
 
377
377
 
378
+ def _injected_seen(ledger: dict[str, Any], transcript: str) -> dict[str, Any]:
379
+ """The fact names this CONTEXT has already been shown, `name -> ISO stamp` (job64, J64-2).
380
+
381
+ Lives under `ledger["injected"][<transcript>]`, beside `reads`, so it inherits the
382
+ ledger's reset points: `PostCompact` and `SessionStart source=compact` unlink the file,
383
+ which is exactly when the model's window has been rebuilt and a name it was shown is
384
+ gone again. The context key is the same one `reads` uses -- `transcript_path` when the
385
+ host sends it, `session_id` otherwise -- because a subagent shares the parent's
386
+ `session_id` (J64-0, Q3 step 11) and therefore this FILE, while its window has never
387
+ held what the parent was shown. A missing or malformed entry is "nothing seen", never
388
+ an exception, for the reason `_read_ledger` gives.
389
+ """
390
+ everyone = ledger.get("injected")
391
+ if not isinstance(everyone, dict):
392
+ return {}
393
+ mine = everyone.get(transcript)
394
+ return mine if isinstance(mine, dict) else {}
395
+
396
+
378
397
  def _write_json_safe(file: str, value: Any) -> None:
379
398
  """Best-effort marker write. A marker that cannot be written costs a repeated dream."""
380
399
  try:
@@ -909,6 +928,19 @@ def _session_start(run: HookRun, payload: dict[str, Any]) -> None:
909
928
  os.unlink(_ledger_path(run, payload.get("session_id")))
910
929
  except OSError:
911
930
  pass
931
+ elif payload.get("source") == "clear":
932
+ # `/clear` empties the window the way a compaction does, and the host MAY keep the
933
+ # `session_id` across it (the real log cannot settle this: 4 kept, 12 changed, 15
934
+ # unknown over 31 clears, with concurrent sessions confounding every count --
935
+ # `.shiftwork/notes-job64/J64-2.md`). So the names this session was shown are
936
+ # forgotten here either way; if the id changed there is no ledger and this is a
937
+ # no-op. Only the seen-set: whether a re-read after `/clear` should still be
938
+ # refused is the read ledger's own question, and `resume` restores the window, so
939
+ # it resets nothing. No ledger is created when none exists (job64, J64-2).
940
+ ledger = _read_ledger(run, payload.get("session_id"))
941
+ if "injected" in ledger:
942
+ del ledger["injected"]
943
+ _write_ledger(run, payload.get("session_id"), ledger)
912
944
  # `profileFacts` keeps its old meaning -- files in the store -- so older records stay
913
945
  # comparable; `profileInjected` / `profileDropped` are what the block carried and did
914
946
  # not, and `dropRule` is the order the drop followed. The project trio appears only when
@@ -1027,7 +1059,12 @@ def _user_prompt_submit(run: HookRun, payload: dict[str, Any]) -> None:
1027
1059
  _log(run, {"event": "UserPromptSubmit", "action": "skip", "reason": "short-or-command"})
1028
1060
  return
1029
1061
  m = Memory.layered(str(payload.get("cwd") or os.getcwd()))
1030
- o = m.recall_outcome(prompt, 3)
1062
+ # `stamp=False` (job64, J64-1): an injection is the HOOK reading the store, not the model
1063
+ # asking for a fact, so it must not date `last_recalled`. Before this it did, on every
1064
+ # prompt, for up to three files -- and the rules keyed on that date (compaction's
1065
+ # stalest-first, the SessionStart drop rule, the Stop dream's store fingerprint) were
1066
+ # reading this arm's traffic. An explicit `memory_recall` still stamps.
1067
+ o = m.recall_outcome(prompt, 3, stamp=False)
1031
1068
  if o.status != "answered":
1032
1069
  _log(
1033
1070
  run,
@@ -1046,7 +1083,42 @@ def _user_prompt_submit(run: HookRun, payload: dict[str, Any]) -> None:
1046
1083
  if not heads:
1047
1084
  _log(run, {"event": "UserPromptSubmit", "action": "none", "reason": "no-headers"})
1048
1085
  return
1049
- joined = "\n".join(heads)
1086
+ # WHAT THIS CONTEXT HAS ALREADY BEEN SHOWN IS NOT SHOWN AGAIN (job64, J64-2). Measured
1087
+ # 2026-09-25: 333 of 598 injections in a week repeated a name injected earlier in the
1088
+ # same session. The top 3 are still asked for (RB-P1's floor stays) and the seen ones are
1089
+ # DROPPED, not refilled from rank 4 onward: what leaves is always a subset of what the
1090
+ # un-deduped arm would have sent, so its precision can only rise, and a repeated prompt
1091
+ # says nothing rather than walking down the ranking on every repeat. The seen-set is the
1092
+ # session ledger's, so it is forgotten exactly when the window is (compaction, `/clear`).
1093
+ transcript = _js_str(payload.get("transcript_path") or payload.get("session_id") or "")
1094
+ ledger = _read_ledger(run, payload.get("session_id"))
1095
+ seen = _injected_seen(ledger, transcript)
1096
+ fresh: list[str] = []
1097
+ suppressed: list[str] = []
1098
+ for line in heads:
1099
+ matched = RECALL_HEADER.match(line)
1100
+ name = matched.group(2) if matched else ""
1101
+ if name in seen:
1102
+ suppressed.append(name)
1103
+ else:
1104
+ fresh.append(line)
1105
+ if not fresh:
1106
+ # Everything picked was already in the window. Nothing is emitted, and the record
1107
+ # says WHY with the names, so an audit can count what dedupe withheld.
1108
+ _log(
1109
+ run,
1110
+ {
1111
+ "event": "UserPromptSubmit",
1112
+ "action": "suppress",
1113
+ "hits": len(heads),
1114
+ "source": o.source,
1115
+ "session": payload.get("session_id"),
1116
+ "prompt": _prompt_fingerprint(prompt),
1117
+ "suppressed": suppressed,
1118
+ },
1119
+ )
1120
+ return
1121
+ joined = "\n".join(fresh)
1050
1122
  ctx = _cap_lines(
1051
1123
  "[bantamkit recall — memories that match this prompt; call "
1052
1124
  'mcp__bantamkit__memory_recall with {"query":"<name>"} for the body]\n'
@@ -1058,13 +1130,25 @@ def _user_prompt_submit(run: HookRun, payload: dict[str, Any]) -> None:
1058
1130
  # asks "was an INJECTED name later used", a question a name the model never saw would
1059
1131
  # poison. `hits` keeps its old meaning (headers picked, pre-cap) so the 487 records
1060
1132
  # written before this change stay comparable; `dropped` is the difference the old shape
1061
- # could not show.
1133
+ # could not show, counted over the unseen headers only, so that
1134
+ # `hits == injected + dropped + suppressed` on every record.
1062
1135
  query_tokens = tokens(prompt)
1063
1136
  injected = [
1064
1137
  scored
1065
1138
  for scored in (_score_header(line, query_tokens) for line in ctx.split("\n"))
1066
1139
  if scored is not None
1067
1140
  ]
1141
+ # Remembered AFTER the cap, off `injected`: a name the cap cut never reached the window
1142
+ # and must be eligible next time.
1143
+ at = _stamp()
1144
+ for scored in injected:
1145
+ seen[scored["name"]] = at
1146
+ everyone = ledger.get("injected")
1147
+ if not isinstance(everyone, dict):
1148
+ everyone = {}
1149
+ ledger["injected"] = everyone
1150
+ everyone[transcript] = seen
1151
+ _write_ledger(run, payload.get("session_id"), ledger)
1068
1152
  _log(
1069
1153
  run,
1070
1154
  {
@@ -1079,7 +1163,8 @@ def _user_prompt_submit(run: HookRun, payload: dict[str, Any]) -> None:
1079
1163
  "session": payload.get("session_id"),
1080
1164
  "prompt": _prompt_fingerprint(prompt),
1081
1165
  "injected": injected,
1082
- "dropped": len(heads) - len(injected),
1166
+ "dropped": len(fresh) - len(injected),
1167
+ "suppressed": suppressed,
1083
1168
  },
1084
1169
  )
1085
1170
  _emit({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ctx}})
@@ -1773,7 +1858,40 @@ def _same_path(a: str, b: str) -> bool:
1773
1858
  return _real_dir(a) == _real_dir(b)
1774
1859
 
1775
1860
 
1861
+ def _fact_content_digest(data: bytes) -> str:
1862
+ """sha256 of a fact file with the frontmatter's `last_recalled:` line left out.
1863
+
1864
+ THE DATE IS NOT CONTENT. Every recall rewrites that one line (and the file's mtime), so a
1865
+ fingerprint that saw it re-armed the dream preview on every `memory_recall` -- measured
1866
+ 2026-09-25 (job64): 103 of 236 previews in a week reported the identical `wouldMerge 14 /
1867
+ wouldConsume 14`. Name, description, type, created, links and the body all stay in.
1868
+
1869
+ Byte-exact and identical in both runtimes (`factContentDigest` in
1870
+ `runtime-ts/src/hookadapter.ts`): the split is on `\\n`, a trailing `\\r` is ignored only
1871
+ for the two comparisons (a Windows-written file keeps its bytes in the hash), and only the
1872
+ lines between the first two `---` fences are frontmatter -- a body line that happens to
1873
+ start with `last_recalled:` is still content.
1874
+ """
1875
+ h = hashlib.sha256()
1876
+ fences = 0
1877
+ for line in data.split(b"\n"):
1878
+ bare = line[:-1] if line.endswith(b"\r") else line
1879
+ if bare == b"---" and fences < 2:
1880
+ fences += 1
1881
+ elif fences == 1 and bare.startswith(b"last_recalled:"):
1882
+ continue
1883
+ h.update(line)
1884
+ h.update(b"\n")
1885
+ return h.hexdigest()
1886
+
1887
+
1776
1888
  def _store_fingerprint(roots: list[str]) -> str:
1889
+ """One hash over both layers: each root's name, then every `facts/*.md` as name + content
1890
+ digest. Adding, removing or renaming a fact moves it; editing any field but `last_recalled`
1891
+ moves it; a recall (which rewrites only that line and the mtime) does not. Neither size nor
1892
+ mtime is in it any more: J64-0 measured that a same-day re-stamp moves the mtime ALONE, so
1893
+ no stat field could be kept as a content signal (`.shiftwork/notes-job64/J64-0.md`, Q4).
1894
+ """
1777
1895
  h = hashlib.sha256()
1778
1896
  for root in roots:
1779
1897
  h.update(f"\u0000{root}\u0000".encode())
@@ -1785,10 +1903,11 @@ def _store_fingerprint(roots: list[str]) -> str:
1785
1903
  names = [] # a layer with no facts/ contributes its name and nothing else
1786
1904
  for n in names:
1787
1905
  try:
1788
- st = os.stat(os.path.join(root, "facts", n))
1906
+ with open(os.path.join(root, "facts", n), "rb") as f:
1907
+ data = f.read()
1789
1908
  except OSError:
1790
1909
  continue
1791
- h.update(f"{n}\u0000{st.st_size}\u0000{_js_number(_mtime_ms(st))}\u0000".encode())
1910
+ h.update(f"{n}\u0000{_fact_content_digest(data)}\u0000".encode())
1792
1911
  return h.hexdigest()
1793
1912
 
1794
1913
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  from collections.abc import Iterator
7
8
  from contextlib import ExitStack, contextmanager
8
9
  from dataclasses import dataclass
@@ -23,6 +24,7 @@ from bantamkit.memory.layers import (
23
24
  )
24
25
  from bantamkit.memory.store import (
25
26
  DEFAULT_INDEX_BUDGET,
27
+ MIN_RATIO_RANGE,
26
28
  RECALL_MIN_SCORE_RATIO,
27
29
  Fact,
28
30
  MemoryBudgetExceeded,
@@ -69,6 +71,12 @@ class RecallOutcome:
69
71
  `project`, `extra` or `profile` — deliberately not the layer's full label, because
70
72
  `extra:<name>` carries a directory name off the operator's disk and the log this
71
73
  feeds is metadata-only.
74
+
75
+ `lookup` (job64, J64-4) is what the exact-name walk decided, again read off the branch
76
+ and not off the reply: `hit` when the query WAS a fact's name and that fact alone is the
77
+ reply, `miss` when the query was shaped like a name, no layer held it, and the reply
78
+ leads with the line that says so, `None` for every other query — the walk that has
79
+ always run, unchanged.
72
80
  """
73
81
 
74
82
  reply: str
@@ -80,6 +88,7 @@ class RecallOutcome:
80
88
  candidates: int
81
89
  source: str | None = None
82
90
  unreadable: int = 0
91
+ lookup: str | None = None
83
92
 
84
93
 
85
94
  def normalize_name(name: str) -> str:
@@ -96,6 +105,26 @@ def normalize_name(name: str) -> str:
96
105
  return name.strip().lower().replace("_", "-").replace(" ", "-")
97
106
 
98
107
 
108
+ # THE NAME SHAPE (job64, J64-4), spelled once here and once in the port: a recall query,
109
+ # after `str.strip()`, that the store's own `NAME_RE` would accept as a fact name AND that
110
+ # carries a hyphen. `fullmatch` rather than `NAME_RE.match`, because Python's `$` matches
111
+ # before a trailing newline and the strip has already removed one; the port's `pyStrip` +
112
+ # anchored test is the same set. A query in this shape is looked up BY NAME in every layer
113
+ # before any scoring happens, and a miss on it is said out loud. The hyphen is the whole
114
+ # distinction: `deploy` is a word anyone might search, and stays the word search it has
115
+ # always been even when a fact happens to be named `deploy` (the layered dedupe test pins
116
+ # that); `deploy-command` is a name somebody typed from an index.
117
+ _NAME_SHAPE = re.compile(r"[a-z0-9][a-z0-9-]*")
118
+
119
+
120
+ def _is_name_shaped(query: str) -> bool:
121
+ return "-" in query and _NAME_SHAPE.fullmatch(query) is not None
122
+
123
+
124
+ def _miss_line(name: str) -> str:
125
+ return f"no fact named '{name}' in any layer bound here; matching by words instead:"
126
+
127
+
99
128
  @dataclass(frozen=True)
100
129
  class CompactOutcome:
101
130
  """What `compact` DID, beside the sentence it says about it.
@@ -371,6 +400,8 @@ class Memory:
371
400
  query: str,
372
401
  k: int | None = None,
373
402
  min_ratio: float = RECALL_MIN_SCORE_RATIO,
403
+ *,
404
+ stamp: bool = True,
374
405
  ) -> RecallOutcome:
375
406
  """`recall`, carrying the walk it performed as numbers rather than as prose.
376
407
 
@@ -398,8 +429,45 @@ class Memory:
398
429
  store, so it surfaces as the error it is rather than as an `unreadable` count —
399
430
  the read-only-layer `except` below would otherwise file a caller's bad argument as
400
431
  a corrupt grant.
432
+
433
+ `stamp` (job64, J64-1) is whether a hit in the WRITABLE layer gets its
434
+ `last_recalled` dated. It defaults to True, which is what every explicit recall —
435
+ the `memory_recall` tool, the memory CLI, `Memory.recall` — has always done, and
436
+ those callers do not pass it. The one caller that passes False is the hook's
437
+ `UserPromptSubmit` arm: an automatic injection is not the model asking for a fact,
438
+ and dating it as one was measured (2026-09-25) to stamp 25 of 42 facts in one
439
+ store as "recalled today", so every rule keyed on `last_recalled` — compaction's
440
+ stalest-first, SessionStart's drop rule — was reading injection traffic instead of
441
+ model demand. Read-only layers are never stamped whatever this says.
442
+
443
+ THE EXACT-NAME WALK (job64, J64-4) runs first, and only for a query in the name
444
+ shape (`_is_name_shaped`: `strip()`ped, `NAME_RE`-valid, and carrying a hyphen —
445
+ a bare word is a word search even when a fact is named by it). Measured
446
+ 2026-09-25 on the operator's own
447
+ stores: 4 of 28 `memory_recall` calls whose query was exactly a fact name came back
448
+ with OTHER facts. One of those names existed in the PROFILE layer and was never
449
+ reached, because the project layer's fuzzy hits filled the budget and the loop below
450
+ breaks before the next layer is read — a name lookup is a request for one known fact,
451
+ and the `k`-floor (RB-P1, ruled to stay 2026-09-25) has nothing to say about it. So:
452
+ every layer is asked, in precedence order, `store.lookup(name)`; the first that
453
+ holds it answers ALONE, stamped only if it is the writable layer, and no other layer
454
+ is read after it. If no layer holds it, the reply leads with `_miss_line` and the
455
+ ordinary walk follows, byte for byte what it would have said alone. Every query
456
+ outside the shape never enters this block. The range
457
+ check on `min_ratio` runs BEFORE the walk for the same reason it runs before any
458
+ file is read in `MemoryStore.recall`: a caller's bad argument must surface as the
459
+ error it is, not be swallowed by an exact hit.
401
460
  """
461
+ if not 0.0 <= min_ratio <= 1.0:
462
+ raise MemoryValidationError(MIN_RATIO_RANGE)
402
463
  budget = self.k if k is None else max(k, self.k)
464
+ name = query.strip()
465
+ lookup: str | None = None
466
+ if _is_name_shaped(name):
467
+ named = self._lookup_by_name(name, budget, stamp)
468
+ if named is not None:
469
+ return named
470
+ lookup = "miss"
403
471
  picked: list[tuple[str, Fact]] = []
404
472
  seen: set[str] = set()
405
473
  reached = 0
@@ -410,7 +478,7 @@ class Memory:
410
478
  break # budget spent: later (read-only) layers are never even read
411
479
  reached += 1
412
480
  try:
413
- facts = store.recall(query, budget, stamp=writable, min_ratio=min_ratio)
481
+ facts = store.recall(query, budget, stamp=writable and stamp, min_ratio=min_ratio)
414
482
  except (BantamError, OSError, UnicodeDecodeError):
415
483
  if writable:
416
484
  raise # the project layer failing is a real error, as in v1
@@ -434,17 +502,60 @@ class Memory:
434
502
  "returned": len(picked),
435
503
  "candidates": candidates,
436
504
  "unreadable": unreadable,
505
+ "lookup": lookup,
437
506
  }
507
+ lead = f"{_miss_line(name)}\n\n" if lookup == "miss" else ""
438
508
  if not picked:
439
509
  status, reply = self._nothing_to_report()
440
- return RecallOutcome(reply=reply, status=status, **counts)
510
+ return RecallOutcome(reply=lead + reply, status=status, **counts)
441
511
  return RecallOutcome(
442
- reply="\n\n".join(self._format(label, fact) for label, fact in picked),
512
+ reply=lead + "\n\n".join(self._format(label, fact) for label, fact in picked),
443
513
  status="answered",
444
514
  source=picked[0][0].split(":", 1)[0],
445
515
  **counts,
446
516
  )
447
517
 
518
+ def _lookup_by_name(self, name: str, budget: int, stamp: bool) -> RecallOutcome | None:
519
+ """The exact-name walk: the first layer holding a fact named `name` answers alone.
520
+
521
+ The counters mean what they mean in `recall_outcome`, over the layers this walk
522
+ READ — `reached` stops at the layer that answered, `candidates` counts the facts in
523
+ the layers up to and including it, and a corrupt read-only layer raises
524
+ `unreadable` and is skipped exactly as the scoring walk skips it. `None` means no
525
+ layer holds the name, and the caller falls through to the scoring walk with its
526
+ own, fresh counters: the numbers a miss logs are the numbers the ordinary walk logs.
527
+ """
528
+ reached = 0
529
+ unreadable = 0
530
+ candidates = 0
531
+ for label, store, writable in self._layers:
532
+ reached += 1
533
+ try:
534
+ fact = store.lookup(name, stamp=writable and stamp)
535
+ except (BantamError, OSError, UnicodeDecodeError):
536
+ if writable:
537
+ raise # the project layer failing is a real error, as in the scoring walk
538
+ unreadable += 1
539
+ continue
540
+ try:
541
+ candidates += count_facts(store.root)
542
+ except OSError:
543
+ pass # it listed a moment ago; a race must not fail the recall for a log field
544
+ if fact is not None:
545
+ return RecallOutcome(
546
+ reply=self._format(label, fact),
547
+ status="answered",
548
+ source=label.split(":", 1)[0],
549
+ budget=budget,
550
+ layers=len(self._layers),
551
+ reached=reached,
552
+ returned=1,
553
+ candidates=candidates,
554
+ unreadable=unreadable,
555
+ lookup="hit",
556
+ )
557
+ return None
558
+
448
559
  def index_accounting(self) -> tuple[int | None, int]:
449
560
  """`(index bytes, budget)` for the writable project store; bytes may be `None`.
450
561
 
@@ -75,6 +75,10 @@ RECALL_MIN_SCORE_RATIO = 0.0
75
75
  # interpolated: Python renders `2.0` as `2.0` and JavaScript renders it as `2`, so a
76
76
  # sentence carrying the number would be a divergence manufactured by float formatting.
77
77
  _MIN_RATIO_RANGE = "recall min-score ratio must be between 0.0 and 1.0"
78
+ # PUBLIC SINCE J64-4, ON BOTH SIDES, for the same reason `tokens` is: `component.Memory` runs
79
+ # the range check itself now, before an exact-name walk reads a file, and it must raise the
80
+ # store's sentence and not a second spelling of it. The private name is kept as an alias.
81
+ MIN_RATIO_RANGE = _MIN_RATIO_RANGE
78
82
 
79
83
  # The index is loaded into the prompt every session, so this is a context bill, not a
80
84
  # disk limit. It was 4096 and that number was never measured against a real store.
@@ -614,6 +618,36 @@ class MemoryStore:
614
618
  self._stamp(fact)
615
619
  return hits
616
620
 
621
+ def lookup(self, name: str, stamp: bool = True) -> Fact | None:
622
+ """The fact whose `name` field is exactly `name`, or `None`; no scoring, no ranking.
623
+
624
+ WHY THIS EXISTS (job64, J64-4): `component.Memory` walks the layers looking for a
625
+ fact the model asked for BY NAME, and `recall` cannot answer that question. It
626
+ scores by token overlap, so the named fact ties with any fact whose name and
627
+ description happen to contain the same words, the tie is broken by name, and
628
+ every hit inside `k` is stamped — there is no `k` that returns "the one named X
629
+ and dates only that one". A caller reaching for `_facts`/`_stamp` instead would be
630
+ the private-name crossing the `tokens` docstring above spends a paragraph refusing.
631
+
632
+ The comparison is `==` against the `name` field as `_facts` resolved it, not against
633
+ the file name: a hand-edited `name:` that no longer matches its path is found by
634
+ what it SAYS it is, which is what `recall` and the index line already go by. Two
635
+ files claiming one name answer the first in `_fact_paths` order, the same order
636
+ `recall` breaks a tie in. A non-string `name` (`name: 7`) never equals a string, so
637
+ it is never an exact hit; the port compares with `===` for the same reason.
638
+
639
+ `stamp` dates the hit through `_stamp`, exactly as `recall` dates its hits, snapshot
640
+ semantics included; nothing else on disk is touched, and a miss writes nothing.
641
+ An unreadable store raises out of `_facts` here as it does out of `recall`, and
642
+ `component.Memory` treats the two the same way per layer.
643
+ """
644
+ for fact in self._snapshot if self._snapshot is not None else self._facts():
645
+ if fact.name == name:
646
+ if stamp:
647
+ self._stamp(fact)
648
+ return fact
649
+ return None
650
+
617
651
  def lint(self) -> None:
618
652
  """Every fact parses and carries a valid type, and the index fits its budget.
619
653
 
@@ -77,7 +77,7 @@
77
77
  }
78
78
  },
79
79
  "memory_recall": {
80
- "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns at most k matching facts.",
80
+ "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns the top word matches, never fewer than the store default of 3 when that many match; a query that is exactly a fact's name returns that fact alone.",
81
81
  "inputSchema": {
82
82
  "properties": {
83
83
  "query": {
@@ -87,7 +87,8 @@
87
87
  "k": {
88
88
  "type": "integer",
89
89
  "minimum": 1,
90
- "maximum": 5
90
+ "maximum": 5,
91
+ "description": "Facts wanted. Raised to the store default of 3 when smaller; never lowered below it."
91
92
  }
92
93
  },
93
94
  "required": [