loop-memory 0.4.21__tar.gz → 0.4.22__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 (210) hide show
  1. {loop_memory-0.4.21 → loop_memory-0.4.22}/PKG-INFO +7 -4
  2. {loop_memory-0.4.21 → loop_memory-0.4.22}/README.md +6 -3
  3. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/read.py +29 -4
  4. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/mcp/__init__.py +39 -2
  5. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/sdk.py +50 -8
  6. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/memories.py +26 -0
  7. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/system.py +25 -1
  8. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/storage/retrieval.py +36 -4
  9. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/storage/sqlite_store.py +149 -12
  10. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/PKG-INFO +7 -4
  11. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/SOURCES.txt +1 -0
  12. {loop_memory-0.4.21 → loop_memory-0.4.22}/pyproject.toml +1 -1
  13. loop_memory-0.4.22/tests/test_namespace_isolation_2026_11_15.py +569 -0
  14. {loop_memory-0.4.21 → loop_memory-0.4.22}/LICENSE +0 -0
  15. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/__init__.py +0 -0
  16. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/backends/__init__.py +0 -0
  17. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/backends/embedding.py +0 -0
  18. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/backends/sentence_embedder.py +0 -0
  19. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/backends/vector_store.py +0 -0
  20. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/bench/__init__.py +0 -0
  21. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/bench/fixtures/default.json +0 -0
  22. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/bench/runner.py +0 -0
  23. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/__init__.py +0 -0
  24. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/_common.py +0 -0
  25. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/__init__.py +0 -0
  26. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/bench.py +0 -0
  27. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/cognitive.py +0 -0
  28. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/diag.py +0 -0
  29. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/doctor.py +0 -0
  30. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/graph.py +0 -0
  31. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/hooks.py +0 -0
  32. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/rules.py +0 -0
  33. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/serve.py +0 -0
  34. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/commands/write.py +0 -0
  35. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/cli/main.py +0 -0
  36. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/decorators.py +0 -0
  37. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/doctor.py +0 -0
  38. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/engine/__init__.py +0 -0
  39. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/engine/loop.py +0 -0
  40. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/engine/reflect.py +0 -0
  41. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/examples/__init__.py +0 -0
  42. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/examples/demo.py +0 -0
  43. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/export/__init__.py +0 -0
  44. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/export/memory_md.py +0 -0
  45. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/graph/__init__.py +0 -0
  46. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/graph/build.py +0 -0
  47. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/graph/extract.py +0 -0
  48. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/ingest/__init__.py +0 -0
  49. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/ingest/loader.py +0 -0
  50. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/ingest/pipeline.py +0 -0
  51. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/__init__.py +0 -0
  52. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/cognitive.py +0 -0
  53. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/compact.py +0 -0
  54. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/consolidate.py +0 -0
  55. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/contradiction.py +0 -0
  56. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/dream.py +0 -0
  57. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/evolution.py +0 -0
  58. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/graph.py +0 -0
  59. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/llm_compact_pass.py +0 -0
  60. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/llm_consolidate.py +0 -0
  61. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/jobs/scheduler.py +0 -0
  62. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/llm/__init__.py +0 -0
  63. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/llm/base.py +0 -0
  64. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/llm/openai_adapter.py +0 -0
  65. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/llm/providers.py +0 -0
  66. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/memory/__init__.py +0 -0
  67. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/memory/types.py +0 -0
  68. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/privacy/__init__.py +0 -0
  69. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/privacy/private.py +0 -0
  70. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/privacy/redact.py +0 -0
  71. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/py.typed +0 -0
  72. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/sdk_extensions.py +0 -0
  73. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/security/__init__.py +0 -0
  74. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/security/secrets.py +0 -0
  75. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/security/transport.py +0 -0
  76. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/__init__.py +0 -0
  77. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/app.py +0 -0
  78. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/handlers.py +0 -0
  79. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/_shared.py +0 -0
  80. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/admin.py +0 -0
  81. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/cognitive.py +0 -0
  82. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/export.py +0 -0
  83. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/graph.py +0 -0
  84. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/insights.py +0 -0
  85. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/sessions.py +0 -0
  86. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/routes/wiki.py +0 -0
  87. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/__init__.py +0 -0
  88. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/css/app.css +0 -0
  89. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/css/layout.css +0 -0
  90. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/i18n/en.json +0 -0
  91. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/i18n/zh.json +0 -0
  92. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/img/logo-light.svg +0 -0
  93. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/img/logo-mark.svg +0 -0
  94. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/img/logo.svg +0 -0
  95. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/index.html +0 -0
  96. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/App.js +0 -0
  97. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/api.js +0 -0
  98. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Dashboard.js +0 -0
  99. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Diagnostic.js +0 -0
  100. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/IngestPopover.js +0 -0
  101. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/KnowledgeGraph.js +0 -0
  102. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/RingMeter.js +0 -0
  103. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/RunStrip.js +0 -0
  104. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/SectionTitle.js +0 -0
  105. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Settings.js +0 -0
  106. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Sidebar.js +0 -0
  107. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Tabs.js +0 -0
  108. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Timeline.js +0 -0
  109. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Toast.js +0 -0
  110. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/TopBar.js +0 -0
  111. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/Wiki.js +0 -0
  112. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/components/WikiEditor.js +0 -0
  113. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/composables/useI18n.js +0 -0
  114. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/lib/sanitize.js +0 -0
  115. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js +0 -0
  116. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/main.js +0 -0
  117. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/static/js/store.js +0 -0
  118. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/serve/watcher.py +0 -0
  119. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/storage/__init__.py +0 -0
  120. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/storage/lexical.py +0 -0
  121. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/storage/snapshot.py +0 -0
  122. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/wiki/__init__.py +0 -0
  123. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/wiki/backfill.py +0 -0
  124. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/wiki/classifier.py +0 -0
  125. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/wiki/prompts.py +0 -0
  126. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory/wiki/scope.py +0 -0
  127. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/dependency_links.txt +0 -0
  128. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/entry_points.txt +0 -0
  129. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/requires.txt +0 -0
  130. {loop_memory-0.4.21 → loop_memory-0.4.22}/loop_memory.egg-info/top_level.txt +0 -0
  131. {loop_memory-0.4.21 → loop_memory-0.4.22}/setup.cfg +0 -0
  132. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_admin_ingest_route.py +0 -0
  133. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_agent_memory_api.py +0 -0
  134. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_agent_memory_sdk.py +0 -0
  135. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_agents_init.py +0 -0
  136. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_audit_triggers_2026_09_27.py +0 -0
  137. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_auth_token_rotate.py +0 -0
  138. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_bench_2026_11_01.py +0 -0
  139. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_busy_timeout_2026_09_24.py +0 -0
  140. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cache_cap_2026_09_23.py +0 -0
  141. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_audit_supersede.py +0 -0
  142. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_export_okf.py +0 -0
  143. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_init.py +0 -0
  144. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_memory_stats.py +0 -0
  145. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_recall_as_of.py +0 -0
  146. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_recall_paths.py +0 -0
  147. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_recall_verbose.py +0 -0
  148. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_rules.py +0 -0
  149. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_snapshot.py +0 -0
  150. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_v7.py +0 -0
  151. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_cli_version.py +0 -0
  152. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_content_hash_dedup_2026_10_18.py +0 -0
  153. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_contradictions.py +0 -0
  154. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_decorators_2026_10_18.py +0 -0
  155. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_doctor_2026_10_12.py +0 -0
  156. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_dream_2026_10_25.py +0 -0
  157. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_dream_cli_http_2026_10_25.py +0 -0
  158. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_evolution.py +0 -0
  159. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_evolution_quality.py +0 -0
  160. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_expanduser.py +0 -0
  161. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_export_ask.py +0 -0
  162. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_export_okf.py +0 -0
  163. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_graph.py +0 -0
  164. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_healthz_readyz_2026_09_24.py +0 -0
  165. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_import_okf_2026_09_27.py +0 -0
  166. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_ingest.py +0 -0
  167. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_latest_only_2026_10_25.py +0 -0
  168. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_lexical_ranking_2026_11_01.py +0 -0
  169. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_llm_consolidator.py +0 -0
  170. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  171. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_llm_providers.py +0 -0
  172. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_llm_test_endpoint.py +0 -0
  173. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_loop.py +0 -0
  174. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_mcp.py +0 -0
  175. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_memories_pagination.py +0 -0
  176. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_memory_stats.py +0 -0
  177. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_openclaw_loader.py +0 -0
  178. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_package_version.py +0 -0
  179. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_packaging_2026_11_01.py +0 -0
  180. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_path_safety_2026_09_23.py +0 -0
  181. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_recall_as_of.py +0 -0
  182. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_recall_budget_2026_10_05.py +0 -0
  183. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_recall_paths.py +0 -0
  184. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_recall_provenance.py +0 -0
  185. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_recall_validity_http_2026_10_18.py +0 -0
  186. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_reflection.py +0 -0
  187. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_repo_framing.py +0 -0
  188. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_retrieval_channels_2026_10_18.py +0 -0
  189. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_routes_audit_2026_09_13.py +0 -0
  190. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_routes_audit_2026_09_20.py +0 -0
  191. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_score_api.py +0 -0
  192. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_scoring_v2.py +0 -0
  193. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_secrets.py +0 -0
  194. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_serve_app.py +0 -0
  195. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_serve_handlers.py +0 -0
  196. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_session_order.py +0 -0
  197. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_short_query.py +0 -0
  198. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_snapshot.py +0 -0
  199. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_store.py +0 -0
  200. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_subgraph_hops_2026_09_23.py +0 -0
  201. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_subject_key_2026_10_25.py +0 -0
  202. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_summarization.py +0 -0
  203. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_supersession_chain.py +0 -0
  204. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_transport_security_2026_11_08.py +0 -0
  205. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_universal_memory.py +0 -0
  206. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_vector_store.py +0 -0
  207. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_watcher.py +0 -0
  208. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_wiki_classifier.py +0 -0
  209. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_wiki_export_escape.py +0 -0
  210. {loop_memory-0.4.21 → loop_memory-0.4.22}/tests/test_wiki_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.21
3
+ Version: 0.4.22
4
4
  Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
5
5
  Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -67,13 +67,14 @@ Dynamic: license-file
67
67
 
68
68
  ---
69
69
 
70
- > **What's new in 0.4.21** — **the remote SDK can finally authenticate, and it refuses to send memory data over a plaintext connection** (audit 2026-11-08, weekly ecosystem sync). The audit for this release (2026-11-08) turned up a real hole rather than a hypothetical one. `MemoryClient.http(base_url=...)` had **no transport guard at all**: point it at `http://mem.example.com` and it opened a plain socket and shipped the user's memory text across the network — and because the HTTP backend had **no way to send the bearer token** (`sqlite_store.py` even carried the comment *"header when `MemoryClient.http(..., token=...)` is given"* for a parameter that did not exist), the only way for a remote client to talk to a token-protected `loop-memory serve` was to turn auth **off** — exactly the configuration in which both the memory text and the token cross the network in cleartext. Two upstream projects closed the same hole in the same week: `rohitg00/agentmemory` v0.9.32 (2026-11-04, Apache-2.0) made *"hooks send nothing to a remote plain `http://` server; use HTTPS for a remote server"* a **breaking change**, and `topoteretes/cognee` v1.6.7 (2026-11-06, Apache-2.0) refuses *"server file paths over HTTP"* by default — both are pattern references only, no code copied. **What shipped:** a new `loop_memory.security.transport` that classifies a base URL and refuses plaintext `http://` to a **non-loopback** host (plaintext to a **loopback** address stays allowed — that traffic never leaves the host, and it is the default deployment), refuses a URL with embedded credentials, and refuses an unsupported scheme; `Authorization: Bearer` support in the SDK and in the shared `http_*_json` helpers, so the documented remote path works against a protected server instead of requiring auth to be turned off; a redirect guard that refuses an `https://` → `http://` downgrade (urllib's stock handler follows it, which would undo the TLS guarantee one hop from the server) and refuses a cross-origin redirect while a token is attached (urllib replays the `Authorization` header onto the redirect request); and the server half — `loop-memory serve` records its bind address and a new `loop-memory doctor` check (`plaintext_exposure`) warns when the API is reachable from the network over plaintext. The rule is about the *destination*, not the scheme: a private tunnel or a VPN'd LAN is a legitimate deployment, so `allow_insecure=True` (or `LOOP_MEMORY_ALLOW_INSECURE=1`) exists — but it is never silent, it warns on every construction. 37 new regression cases; suite at **1041 passed / 4 skipped**. See [`docs/transport-security.md`](./docs/transport-security.md) for the full contract. [Full CHANGELOG →](./CHANGELOG.md#0421---2026-11-08)
70
+ > **What's new in 0.4.22** — **the agent-namespace filter now works, and it is enforced in the query instead of on top of the answer** (audit 2026-11-15, weekly ecosystem sync). The audit for this release (2026-11-15) turned up a real hole rather than a hypothetical one. `GET /api/v1/recall?agent_id=X` was documented as *"Unified recall filtered to a single Agent namespace"* — but the filter was applied to the store's **output**, and it had two separate failure modes. **On the legacy path it was a silent no-op:** `MemoryStore.recall()` never returned the `agent_id` / `user_id` columns, so the route's predicate read `None`/`None` for every row, decided every row was "global", and let all of them through — **an agent that explicitly asked for its own namespace was handed every other agent's memories as well.** **On the hybrid path it ran after the `LIMIT`:** the filter worked, but it ran on the store's already-trimmed result set, so a foreign namespace could spend the caller's *entire* result budget — an agent asking for 5 hits could get **zero**, while its own matching rows sat further down the ranking. Both failures have the same root cause: **the predicate was in the caller instead of in the query** — exactly what the upstream projects in this window converged on. `plastic-labs/honcho` v3.6.0 (2026-11-10, AGPL-3.0) made `tenant_id` lead every primary key, with Postgres row-level security scoping **every read and write**, and refused to hand a tenant an empty namespace; `EverMind-AI/EverOS` v1.6.0 (2026-11-11) made deletion owner-scoped and answered `503 SESSION_BUSY` rather than deleting half of a session; `thedotmack/claude-mem` (v15.16.x) gave each scope its own project by default and made *"an explicitly empty project selection show nothing instead of observations from every project"* (#5866); `vectorize-io/hindsight` v1.2.1 stopped a bank clone from taking over another bank's alias (#6280) — pattern references only, no code copied, and the two AGPL-3.0 projects contributed nothing but an idea. **What shipped:** a single predicate builder (`_scope_clause()`) that every retrieval channel builds its query from, so the namespace lives in the **SQL** of the BM25 channel (both the FTS5 query and the `LIKE` backstop), the semantic channel, the entity channel, `recall()`, `recall_hybrid()` and `recall_as_of()` — the fused ranking, and the `limit` that trims it, now only ever sees the caller's own rows; every hit now reports its `agent_id` / `user_id`, which is what makes the legacy no-op impossible to reintroduce silently; all four public surfaces expose it — `GET /api/v1/recall` and `GET /api/recall` accept `agent_id` / `user_id`, the MCP `recall` tool scopes to `LOOP_MEMORY_AGENT_ID` / `LOOP_MEMORY_USER_ID` (the MCP server is started once per client, so the environment already identifies the caller) and accepts explicit overrides, and `loop-memory recall` gained `--agent-id` / `--user-id`; and a client that **writes** into a namespace now **reads** from it — `MemoryClient.memory(store, agent_id="x")` and `MemoryClient.http(url, agent_id="x")` fall back to that identity on `recall()`, which is precisely the asymmetry that hid the leak (pass `agent_id=""` for the explicit escape hatch). A memory with no `agent_id` stays **global** and visible to everyone, because the ingest pipeline, the consolidator and the dream job all write global rows. 43 new regression cases; suite at **1084 passed / 4 skipped**. See [`docs/namespace-isolation.md`](./docs/namespace-isolation.md) for the full contract. [Full CHANGELOG →](./CHANGELOG.md#0422---2026-11-15)
71
71
 
72
72
  <details>
73
- <summary><b>Older releases</b> — 0.4.20 and earlier, one line each (the CHANGELOG has the full detail)</summary>
73
+ <summary><b>Older releases</b> — 0.4.21 and earlier, one line each (the CHANGELOG has the full detail)</summary>
74
74
 
75
75
  | Version | Date | What changed | Full notes |
76
76
  | --- | --- | --- | --- |
77
+ | [0.4.21](./CHANGELOG.md#0421---2026-11-08) | 2026-11-08 | The remote SDK can authenticate, and it refuses to send memory data over a plaintext connection |
77
78
  | [0.4.20](./CHANGELOG.md#0420---2026-11-01) | 2026-11-01 | The packaging test works on the oldest supported Python (no `tomllib` on 3.10) |
78
79
  | [0.4.19](./CHANGELOG.md#0419---2026-11-01) | 2026-11-01 | The wheel ships the whole dashboard and the benchmark fixture (recursive package-data globs) |
79
80
  | [0.4.18](./CHANGELOG.md#0418---2026-11-01) | 2026-11-01 | The BM25 channel no longer publishes a score that collapses to zero — plus a retrieval benchmark |
@@ -268,6 +269,7 @@ Hermes, clawx) can read and write, we built this for you.
268
269
  | [docs/agent-memory-api.md](docs/agent-memory-api.md) | Stable four-verb SDK / HTTP / MCP contract for any Agent |
269
270
  | [docs/universal-agent-memory.md](docs/universal-agent-memory.md) | v7 graph memory, cognitive sleep, portable bundles, namespaces, and MCP/CLI extensions |
270
271
  | [docs/transport-security.md](docs/transport-security.md) | How a remote client is allowed to talk to `loop-memory serve` — the transport guard, bearer-token auth, redirect hardening, and the `plaintext_exposure` doctor check (0.4.21) |
272
+ | [docs/namespace-isolation.md](docs/namespace-isolation.md) | Who may read which memory — the agent / user namespace predicate, why it lives in the SQL instead of on top of the answer, and the global-row semantics (0.4.22) |
271
273
  | [docs/providers.md](docs/providers.md) | LLM provider reference — built-in providers, defaults, base URLs, and how to add a new one |
272
274
  | [docs/auto-capture.md](docs/auto-capture.md) | Hooking Codex / Claude / Hermes / OpenClaw watchers (filesystem, launchd, systemd, cron) |
273
275
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Local dev loop, pytest, where secrets live, how to add a provider/source |
@@ -631,12 +633,13 @@ loop_memory/
631
633
  memory/types.py # MemoryItem + 4 tiers
632
634
  examples/demo.py # runnable, zero-API-key demo
633
635
  py.typed
634
- tests/ # 1041 unit tests across memory / SDK / serve / CLI / scripts
636
+ tests/ # 1084 unit tests across memory / SDK / serve / CLI / scripts
635
637
  docs/
636
638
  auto-capture.md # launchd / systemd / cron recipes
637
639
  architecture.md # layered view + 5-stage evolution pipeline
638
640
  api.md # HTTP API reference
639
641
  transport-security.md # how a remote client may talk to `serve` (0.4.21)
642
+ namespace-isolation.md # who may read which memory (0.4.22)
640
643
  agent-memory-api.md # four-verb SDK / HTTP / MCP contract
641
644
  universal-agent-memory.md # v7 graph memory + cognitive sleep + bundles
642
645
  providers.md # LLM provider reference
@@ -22,13 +22,14 @@
22
22
 
23
23
  ---
24
24
 
25
- > **What's new in 0.4.21** — **the remote SDK can finally authenticate, and it refuses to send memory data over a plaintext connection** (audit 2026-11-08, weekly ecosystem sync). The audit for this release (2026-11-08) turned up a real hole rather than a hypothetical one. `MemoryClient.http(base_url=...)` had **no transport guard at all**: point it at `http://mem.example.com` and it opened a plain socket and shipped the user's memory text across the network — and because the HTTP backend had **no way to send the bearer token** (`sqlite_store.py` even carried the comment *"header when `MemoryClient.http(..., token=...)` is given"* for a parameter that did not exist), the only way for a remote client to talk to a token-protected `loop-memory serve` was to turn auth **off** — exactly the configuration in which both the memory text and the token cross the network in cleartext. Two upstream projects closed the same hole in the same week: `rohitg00/agentmemory` v0.9.32 (2026-11-04, Apache-2.0) made *"hooks send nothing to a remote plain `http://` server; use HTTPS for a remote server"* a **breaking change**, and `topoteretes/cognee` v1.6.7 (2026-11-06, Apache-2.0) refuses *"server file paths over HTTP"* by default — both are pattern references only, no code copied. **What shipped:** a new `loop_memory.security.transport` that classifies a base URL and refuses plaintext `http://` to a **non-loopback** host (plaintext to a **loopback** address stays allowed — that traffic never leaves the host, and it is the default deployment), refuses a URL with embedded credentials, and refuses an unsupported scheme; `Authorization: Bearer` support in the SDK and in the shared `http_*_json` helpers, so the documented remote path works against a protected server instead of requiring auth to be turned off; a redirect guard that refuses an `https://` → `http://` downgrade (urllib's stock handler follows it, which would undo the TLS guarantee one hop from the server) and refuses a cross-origin redirect while a token is attached (urllib replays the `Authorization` header onto the redirect request); and the server half — `loop-memory serve` records its bind address and a new `loop-memory doctor` check (`plaintext_exposure`) warns when the API is reachable from the network over plaintext. The rule is about the *destination*, not the scheme: a private tunnel or a VPN'd LAN is a legitimate deployment, so `allow_insecure=True` (or `LOOP_MEMORY_ALLOW_INSECURE=1`) exists — but it is never silent, it warns on every construction. 37 new regression cases; suite at **1041 passed / 4 skipped**. See [`docs/transport-security.md`](./docs/transport-security.md) for the full contract. [Full CHANGELOG →](./CHANGELOG.md#0421---2026-11-08)
25
+ > **What's new in 0.4.22** — **the agent-namespace filter now works, and it is enforced in the query instead of on top of the answer** (audit 2026-11-15, weekly ecosystem sync). The audit for this release (2026-11-15) turned up a real hole rather than a hypothetical one. `GET /api/v1/recall?agent_id=X` was documented as *"Unified recall filtered to a single Agent namespace"* — but the filter was applied to the store's **output**, and it had two separate failure modes. **On the legacy path it was a silent no-op:** `MemoryStore.recall()` never returned the `agent_id` / `user_id` columns, so the route's predicate read `None`/`None` for every row, decided every row was "global", and let all of them through — **an agent that explicitly asked for its own namespace was handed every other agent's memories as well.** **On the hybrid path it ran after the `LIMIT`:** the filter worked, but it ran on the store's already-trimmed result set, so a foreign namespace could spend the caller's *entire* result budget — an agent asking for 5 hits could get **zero**, while its own matching rows sat further down the ranking. Both failures have the same root cause: **the predicate was in the caller instead of in the query** — exactly what the upstream projects in this window converged on. `plastic-labs/honcho` v3.6.0 (2026-11-10, AGPL-3.0) made `tenant_id` lead every primary key, with Postgres row-level security scoping **every read and write**, and refused to hand a tenant an empty namespace; `EverMind-AI/EverOS` v1.6.0 (2026-11-11) made deletion owner-scoped and answered `503 SESSION_BUSY` rather than deleting half of a session; `thedotmack/claude-mem` (v15.16.x) gave each scope its own project by default and made *"an explicitly empty project selection show nothing instead of observations from every project"* (#5866); `vectorize-io/hindsight` v1.2.1 stopped a bank clone from taking over another bank's alias (#6280) — pattern references only, no code copied, and the two AGPL-3.0 projects contributed nothing but an idea. **What shipped:** a single predicate builder (`_scope_clause()`) that every retrieval channel builds its query from, so the namespace lives in the **SQL** of the BM25 channel (both the FTS5 query and the `LIKE` backstop), the semantic channel, the entity channel, `recall()`, `recall_hybrid()` and `recall_as_of()` — the fused ranking, and the `limit` that trims it, now only ever sees the caller's own rows; every hit now reports its `agent_id` / `user_id`, which is what makes the legacy no-op impossible to reintroduce silently; all four public surfaces expose it — `GET /api/v1/recall` and `GET /api/recall` accept `agent_id` / `user_id`, the MCP `recall` tool scopes to `LOOP_MEMORY_AGENT_ID` / `LOOP_MEMORY_USER_ID` (the MCP server is started once per client, so the environment already identifies the caller) and accepts explicit overrides, and `loop-memory recall` gained `--agent-id` / `--user-id`; and a client that **writes** into a namespace now **reads** from it — `MemoryClient.memory(store, agent_id="x")` and `MemoryClient.http(url, agent_id="x")` fall back to that identity on `recall()`, which is precisely the asymmetry that hid the leak (pass `agent_id=""` for the explicit escape hatch). A memory with no `agent_id` stays **global** and visible to everyone, because the ingest pipeline, the consolidator and the dream job all write global rows. 43 new regression cases; suite at **1084 passed / 4 skipped**. See [`docs/namespace-isolation.md`](./docs/namespace-isolation.md) for the full contract. [Full CHANGELOG →](./CHANGELOG.md#0422---2026-11-15)
26
26
 
27
27
  <details>
28
- <summary><b>Older releases</b> — 0.4.20 and earlier, one line each (the CHANGELOG has the full detail)</summary>
28
+ <summary><b>Older releases</b> — 0.4.21 and earlier, one line each (the CHANGELOG has the full detail)</summary>
29
29
 
30
30
  | Version | Date | What changed | Full notes |
31
31
  | --- | --- | --- | --- |
32
+ | [0.4.21](./CHANGELOG.md#0421---2026-11-08) | 2026-11-08 | The remote SDK can authenticate, and it refuses to send memory data over a plaintext connection |
32
33
  | [0.4.20](./CHANGELOG.md#0420---2026-11-01) | 2026-11-01 | The packaging test works on the oldest supported Python (no `tomllib` on 3.10) |
33
34
  | [0.4.19](./CHANGELOG.md#0419---2026-11-01) | 2026-11-01 | The wheel ships the whole dashboard and the benchmark fixture (recursive package-data globs) |
34
35
  | [0.4.18](./CHANGELOG.md#0418---2026-11-01) | 2026-11-01 | The BM25 channel no longer publishes a score that collapses to zero — plus a retrieval benchmark |
@@ -223,6 +224,7 @@ Hermes, clawx) can read and write, we built this for you.
223
224
  | [docs/agent-memory-api.md](docs/agent-memory-api.md) | Stable four-verb SDK / HTTP / MCP contract for any Agent |
224
225
  | [docs/universal-agent-memory.md](docs/universal-agent-memory.md) | v7 graph memory, cognitive sleep, portable bundles, namespaces, and MCP/CLI extensions |
225
226
  | [docs/transport-security.md](docs/transport-security.md) | How a remote client is allowed to talk to `loop-memory serve` — the transport guard, bearer-token auth, redirect hardening, and the `plaintext_exposure` doctor check (0.4.21) |
227
+ | [docs/namespace-isolation.md](docs/namespace-isolation.md) | Who may read which memory — the agent / user namespace predicate, why it lives in the SQL instead of on top of the answer, and the global-row semantics (0.4.22) |
226
228
  | [docs/providers.md](docs/providers.md) | LLM provider reference — built-in providers, defaults, base URLs, and how to add a new one |
227
229
  | [docs/auto-capture.md](docs/auto-capture.md) | Hooking Codex / Claude / Hermes / OpenClaw watchers (filesystem, launchd, systemd, cron) |
228
230
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Local dev loop, pytest, where secrets live, how to add a provider/source |
@@ -586,12 +588,13 @@ loop_memory/
586
588
  memory/types.py # MemoryItem + 4 tiers
587
589
  examples/demo.py # runnable, zero-API-key demo
588
590
  py.typed
589
- tests/ # 1041 unit tests across memory / SDK / serve / CLI / scripts
591
+ tests/ # 1084 unit tests across memory / SDK / serve / CLI / scripts
590
592
  docs/
591
593
  auto-capture.md # launchd / systemd / cron recipes
592
594
  architecture.md # layered view + 5-stage evolution pipeline
593
595
  api.md # HTTP API reference
594
596
  transport-security.md # how a remote client may talk to `serve` (0.4.21)
597
+ namespace-isolation.md # who may read which memory (0.4.22)
595
598
  agent-memory-api.md # four-verb SDK / HTTP / MCP contract
596
599
  universal-agent-memory.md # v7 graph memory + cognitive sleep + bundles
597
600
  providers.md # LLM provider reference
@@ -62,10 +62,14 @@ def run_recall(args) -> int:
62
62
  caller can ask "what did we know about this query at that
63
63
  moment?". The wiki and entity sections are unchanged — only
64
64
  the raw-memory block grows the new line.
65
+
66
+ ``--agent-id`` / ``--user-id`` (audit 2026-11-15, loop-memory
67
+ 0.4.22) restrict the answer to one namespace. Memories written
68
+ without an agent or user id are global and stay visible.
65
69
  """
66
70
  from ...storage.sqlite_store import MemoryStore
67
71
  if not args:
68
- return die("usage: loop-memory recall <query> [--verbose] [--limit N]")
72
+ return die("usage: loop-memory recall <query> [--verbose] [--limit N] [--agent-id <id>] [--user-id <id>]")
69
73
  # Parse the small flag subset we expose. Keep it dumb-on-purpose:
70
74
  # we don't want argparse to swallow a token that happens to start
71
75
  # with "--" inside the user's query (e.g. ``recall --foo bar``).
@@ -80,6 +84,11 @@ def run_recall(args) -> int:
80
84
  level = 1
81
85
  # Audit 2026-10-25 (loop-memory 0.4.16) -- latest-only mode.
82
86
  latest_only = False
87
+ # Audit 2026-11-15 (loop-memory 0.4.22) -- agent / user
88
+ # namespace. ``None`` means "no restriction", which keeps the
89
+ # legacy see-everything behaviour.
90
+ agent_id: str | None = None
91
+ user_id: str | None = None
83
92
  qargs: list[str] = []
84
93
  i = 0
85
94
  while i < len(args):
@@ -158,6 +167,17 @@ def run_recall(args) -> int:
158
167
  except ValueError:
159
168
  return die(f"--level must be 0, 1, or 2, got {args[i + 1]!r}")
160
169
  i += 2
170
+ elif a == "--agent-id" and i + 1 < len(args):
171
+ # Audit 2026-11-15 (loop-memory 0.4.22) -- restrict the
172
+ # answer to one agent namespace. Memories with no agent
173
+ # id are global and stay visible.
174
+ agent_id = args[i + 1].strip() or None
175
+ i += 2
176
+ elif a == "--user-id" and i + 1 < len(args):
177
+ # Audit 2026-11-15 (loop-memory 0.4.22) -- restrict the
178
+ # answer to one user namespace.
179
+ user_id = args[i + 1].strip() or None
180
+ i += 2
161
181
  elif a == "--limit" and i + 1 < len(args):
162
182
  try:
163
183
  limit = int(args[i + 1])
@@ -170,13 +190,14 @@ def run_recall(args) -> int:
170
190
  qargs.append(a)
171
191
  i += 1
172
192
  if not qargs:
173
- return die("usage: loop-memory recall <query> [--verbose] [--outline] [--as-of <ISO|epoch>] [--latest-only] [--limit N] [--max-chars N] [--level 0|1|2]")
193
+ return die("usage: loop-memory recall <query> [--verbose] [--outline] [--as-of <ISO|epoch>] [--latest-only] [--agent-id <id>] [--user-id <id>] [--limit N] [--max-chars N] [--level 0|1|2]")
174
194
  store = MemoryStore(default_db_path())
175
195
  query = " ".join(qargs)
176
196
  if as_of is not None:
177
197
  try:
178
198
  r = store.recall_as_of(query, as_of, limit=limit,
179
- max_chars=max_chars, level=level)
199
+ max_chars=max_chars, level=level,
200
+ agent_id=agent_id, user_id=user_id)
180
201
  except ValueError as e:
181
202
  return die(str(e))
182
203
  elif outline:
@@ -190,7 +211,11 @@ def run_recall(args) -> int:
190
211
  # "everything" semantics.
191
212
  as_of_ts=validity_at,
192
213
  # Audit 2026-10-25: one answer per fact.
193
- latest_only=latest_only)
214
+ latest_only=latest_only,
215
+ # Audit 2026-11-15: namespace predicate in
216
+ # SQL -- the limit counts only the caller's
217
+ # own rows.
218
+ agent_id=agent_id, user_id=user_id)
194
219
  has = False
195
220
  if r["wiki"]:
196
221
  has = True
@@ -77,7 +77,27 @@ def tool_recall(arguments: dict[str, Any]) -> list[dict[str, Any]]:
77
77
  if not query:
78
78
  return [_err("missing 'query' argument")]
79
79
  store = _store()
80
- source = arguments.get("source") or _agent_context()[0]
80
+ ctx_agent, ctx_user = _agent_context()
81
+ source = arguments.get("source") or ctx_agent
82
+ # Audit 2026-11-15 (loop-memory 0.4.22): namespace isolation.
83
+ # The MCP server is started once per client (Codex, Claude Code,
84
+ # Hermes, ...), so ``LOOP_MEMORY_AGENT_ID`` / ``LOOP_MEMORY_USER_ID``
85
+ # already identify the caller. The recall tool now scopes the
86
+ # answer to that namespace -- inside the store's SQL, so a foreign
87
+ # namespace can neither leak a row nor eat the result budget.
88
+ # An explicit ``agent_id`` / ``user_id`` argument overrides the
89
+ # environment; passing an empty string means "no restriction",
90
+ # which keeps the legacy see-everything behaviour reachable.
91
+ agent_id = arguments.get("agent_id")
92
+ if agent_id is None:
93
+ agent_id = ctx_agent
94
+ elif not str(agent_id).strip():
95
+ agent_id = None
96
+ user_id = arguments.get("user_id")
97
+ if user_id is None:
98
+ user_id = ctx_user
99
+ elif not str(user_id).strip():
100
+ user_id = None
81
101
  # Audit 2026-10-05: thread ``max_chars`` (token / char budget cap)
82
102
  # + ``level`` (tiered-loader) through the MCP tool so an MCP-aware
83
103
  # agent loop can ask for a bounded retrieval inline. ``None`` and
@@ -98,7 +118,8 @@ def tool_recall(arguments: dict[str, Any]) -> list[dict[str, Any]]:
98
118
  except (TypeError, ValueError):
99
119
  level = 1
100
120
  r = store.recall(query, limit=limit, source=source,
101
- max_chars=max_chars, level=level)
121
+ max_chars=max_chars, level=level,
122
+ agent_id=agent_id, user_id=user_id)
102
123
  n_mem = len(r["memories"])
103
124
  n_wiki = len(r["wiki"])
104
125
  n_ent = len(r["entities"])
@@ -528,6 +549,22 @@ TOOLS = [
528
549
  "properties": {
529
550
  "query": {"type": "string", "description": "Free-text query"},
530
551
  "limit": {"type": "integer", "description": "Max results (default 8, max 50)"},
552
+ "agent_id": {
553
+ "type": "string",
554
+ "description": (
555
+ "Agent namespace to scope the answer to "
556
+ "(overrides LOOP_MEMORY_AGENT_ID). Empty string "
557
+ "means no restriction."
558
+ ),
559
+ },
560
+ "user_id": {
561
+ "type": "string",
562
+ "description": (
563
+ "User namespace to scope the answer to "
564
+ "(overrides LOOP_MEMORY_USER_ID). Empty string "
565
+ "means no restriction."
566
+ ),
567
+ },
531
568
  },
532
569
  "required": ["query"],
533
570
  },
@@ -298,6 +298,14 @@ class MemoryClient(MemoryClientExt, AbstractContextManager):
298
298
  validity window are dropped, subject-key slots collapse to
299
299
  their newest live row, and near-duplicate texts collapse to
300
300
  the newest one.
301
+
302
+ ``agent_id`` / ``user_id`` (audit 2026-11-15, loop-memory
303
+ 0.4.22) restrict the answer to one namespace. A client built
304
+ with ``MemoryClient.memory(..., agent_id=...)`` /
305
+ ``MemoryClient.http(..., agent_id=...)`` falls back to that
306
+ identity, so a client that *writes* into a namespace also
307
+ *reads* from it. Memories written without an agent / user id
308
+ are global and stay visible to everyone.
301
309
  """
302
310
  raise NotImplementedError
303
311
 
@@ -416,12 +424,28 @@ class _InProcessClient(MemoryClient):
416
424
  # the legacy LIKE-based ``recall`` if a stale DB doesn't have
417
425
  # it (older installs, tests).
418
426
  wanted = tuple(s.strip() for s in include.split(",") if s.strip())
427
+ # Audit 2026-11-15 (loop-memory 0.4.22): the namespace the
428
+ # client was built with is the namespace it reads from. The
429
+ # predicate is applied inside the store's SQL so the limit
430
+ # counts only the caller's own rows.
431
+ # An explicit empty string means "no restriction"; ``None``
432
+ # falls back to the identity the client was built with.
433
+ if agent_id is None:
434
+ scope_agent = self._default_agent_id
435
+ else:
436
+ scope_agent = (str(agent_id).strip() or None)
437
+ if user_id is None:
438
+ scope_user = self._default_user_id
439
+ else:
440
+ scope_user = (str(user_id).strip() or None)
419
441
  if hasattr(self._store, "recall_hybrid"):
420
442
  r = self._store.recall_hybrid(
421
443
  query, limit=limit, include=wanted,
422
444
  bump_signals=True, source=source, level=level,
423
445
  max_chars=max_chars,
424
446
  latest_only=latest_only,
447
+ agent_id=scope_agent,
448
+ user_id=scope_user,
425
449
  )
426
450
  else:
427
451
  r = self._store.recall(
@@ -432,9 +456,13 @@ class _InProcessClient(MemoryClient):
432
456
  max_chars=max_chars,
433
457
  level=level,
434
458
  latest_only=latest_only,
459
+ agent_id=scope_agent,
460
+ user_id=scope_user,
435
461
  )
436
- # Optional post-filter: even if the store returned hits, the
437
- # caller may want only their agent's namespace.
462
+ # Defence in depth: the store already applied the namespace
463
+ # predicate inside its SQL, so this can only ever remove a
464
+ # row the SQL should not have returned anyway. It is kept so
465
+ # a future regression in the SQL cannot leak a row.
438
466
  def _own(h: dict[str, Any]) -> bool:
439
467
  ag = h.get("agent_id")
440
468
  ur = h.get("user_id")
@@ -443,9 +471,9 @@ class _InProcessClient(MemoryClient):
443
471
  # match the caller's filter or be unset on the memory.
444
472
  if ag is None and ur is None:
445
473
  return True
446
- if agent_id is not None and ag not in (None, agent_id):
474
+ if scope_agent is not None and ag not in (None, scope_agent):
447
475
  return False
448
- if user_id is not None and ur not in (None, user_id):
476
+ if scope_user is not None and ur not in (None, scope_user):
449
477
  return False
450
478
  return True
451
479
  return RecallResult(
@@ -780,10 +808,24 @@ class _HttpClient(MemoryClient):
780
808
  params["latest_only"] = "1"
781
809
  if max_chars is not None:
782
810
  params["max_chars"] = int(max_chars)
783
- if agent_id is not None:
784
- params["agent_id"] = agent_id
785
- if user_id is not None:
786
- params["user_id"] = user_id
811
+ # Audit 2026-11-15 (loop-memory 0.4.22): a client built with
812
+ # ``MemoryClient.http(..., agent_id=...)`` writes into that
813
+ # namespace, so it reads from it too. The server applies the
814
+ # namespace predicate inside its SQL.
815
+ # An explicit empty string means "no restriction"; ``None``
816
+ # falls back to the identity the client was built with.
817
+ if agent_id is None:
818
+ scope_agent = self._default_agent_id
819
+ else:
820
+ scope_agent = (str(agent_id).strip() or None)
821
+ if user_id is None:
822
+ scope_user = self._default_user_id
823
+ else:
824
+ scope_user = (str(user_id).strip() or None)
825
+ if scope_agent is not None:
826
+ params["agent_id"] = scope_agent
827
+ if scope_user is not None:
828
+ params["user_id"] = scope_user
787
829
  qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
788
830
  resp = self._request("GET", f"/api/v1/recall?{qs}")
789
831
  return RecallResult(
@@ -307,6 +307,24 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
307
307
  "global" and visible to every caller (matches the SDK
308
308
  semantics).
309
309
 
310
+ Audit 2026-11-15 (loop-memory 0.4.22) -- the namespace
311
+ predicate is now applied **inside** the store's SQL, not on
312
+ top of its output. Before this release the filter ran after
313
+ the store had already applied its ``LIMIT``, which meant:
314
+
315
+ * in ``hybrid`` mode a foreign namespace could spend the
316
+ caller's whole result budget, so an agent could get zero
317
+ hits while its own matching rows sat further down the
318
+ ranking; and
319
+ * in ``legacy`` mode the filter was a **silent no-op** --
320
+ ``store.recall()`` did not return ``agent_id`` /
321
+ ``user_id``, so every row looked "global" and *every*
322
+ namespace saw *every* other namespace's memories.
323
+
324
+ The Python filter below is kept as a defence-in-depth net so
325
+ a future regression in the SQL cannot leak a row; the SQL is
326
+ what makes the ``limit`` meaningful.
327
+
310
328
  ``latest_only=1`` collapses the answer to one row per logical
311
329
  fact: rows outside their validity window are dropped, subject
312
330
  key slots collapse to their newest live row, and near-duplicate
@@ -315,6 +333,10 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
315
333
  wanted = tuple(s.strip() for s in include.split(",") if s.strip())
316
334
  if not wanted:
317
335
  wanted = ("memories", "wiki", "entities")
336
+ # Audit 2026-11-15 (loop-memory 0.4.22): a blank namespace
337
+ # means "not specified" -- never "match nothing".
338
+ agent_id = (agent_id or "").strip() or None
339
+ user_id = (user_id or "").strip() or None
318
340
  if mode == "legacy" or not hasattr(store, "recall_hybrid"):
319
341
  r = store.recall(
320
342
  q,
@@ -323,12 +345,16 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
323
345
  bump_signals=True,
324
346
  source=source,
325
347
  latest_only=bool(latest_only),
348
+ agent_id=agent_id,
349
+ user_id=user_id,
326
350
  )
327
351
  else:
328
352
  r = store.recall_hybrid(
329
353
  q, limit=limit, include=wanted,
330
354
  bump_signals=True, source=source, level=1,
331
355
  latest_only=bool(latest_only),
356
+ agent_id=agent_id,
357
+ user_id=user_id,
332
358
  )
333
359
  if agent_id is not None or user_id is not None:
334
360
  def _own(m: dict) -> bool:
@@ -403,7 +403,14 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
403
403
  # their validity window are dropped, subject-key slots
404
404
  # collapse to their newest live row, and near-duplicate
405
405
  # texts collapse to the newest one.
406
- latest_only: int = 0):
406
+ latest_only: int = 0,
407
+ # Audit 2026-11-15 (loop-memory 0.4.22): optional
408
+ # agent / user namespace. The predicate is applied in
409
+ # the store's SQL, so the ``limit`` counts only the
410
+ # caller's own rows. Unset (the default) keeps the
411
+ # dashboard's "see everything" behaviour.
412
+ agent_id: str | None = None,
413
+ user_id: str | None = None):
407
414
  """Unified recall — wiki + memories + entities in one ranked stream.
408
415
 
409
416
  ``mode`` is 'hybrid' (default) for BM25+semantic+entity RRF,
@@ -437,7 +444,16 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
437
444
  vectorize-io/hindsight v0.10.2 "concise extraction by
438
445
  default" + aiming-lab/SimpleMem ICML'26 semantic-lossless-
439
446
  compression.
447
+
448
+ ``agent_id`` / ``user_id`` (audit 2026-11-15, loop-memory
449
+ 0.4.22) restrict the answer to one agent / user namespace.
450
+ Rows with a NULL ``agent_id`` / ``user_id`` are global and
451
+ stay visible to everyone.
440
452
  """
453
+ # Audit 2026-11-15 (loop-memory 0.4.22): a blank namespace
454
+ # means "not specified" -- never "match nothing".
455
+ agent_id = (agent_id or "").strip() or None
456
+ user_id = (user_id or "").strip() or None
441
457
  wanted = tuple(s.strip() for s in include.split(",") if s.strip())
442
458
  if not wanted:
443
459
  wanted = ("memories", "wiki", "entities")
@@ -461,6 +477,8 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
461
477
  source=source,
462
478
  max_chars=max_chars,
463
479
  level=level,
480
+ agent_id=agent_id,
481
+ user_id=user_id,
464
482
  )
465
483
  recall_mode = "as_of"
466
484
  except ValueError as e:
@@ -481,6 +499,9 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
481
499
  as_of_ts=(validity_at if (validity_at and str(validity_at).strip()) else None),
482
500
  # Audit 2026-10-25: one answer per fact.
483
501
  latest_only=bool(latest_only),
502
+ # Audit 2026-11-15: namespace predicate in SQL.
503
+ agent_id=agent_id,
504
+ user_id=user_id,
484
505
  )
485
506
  except ValueError as e:
486
507
  # Invalid ``validity_at`` -> 400 (audit 2026-10-18)
@@ -496,6 +517,9 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
496
517
  as_of_ts=(validity_at if (validity_at and str(validity_at).strip()) else None),
497
518
  # Audit 2026-10-25: one answer per fact.
498
519
  latest_only=bool(latest_only),
520
+ # Audit 2026-11-15: namespace predicate in SQL.
521
+ agent_id=agent_id,
522
+ user_id=user_id,
499
523
  )
500
524
  except ValueError as e:
501
525
  raise HTTPException(400, str(e))
@@ -90,6 +90,11 @@ def bm25_search(
90
90
  kind: str = "memories",
91
91
  limit: int = 50,
92
92
  source_filter: str | None = None,
93
+ # Audit 2026-11-15 (loop-memory 0.4.22): agent / user namespace.
94
+ # Applied inside the SQL so a foreign namespace cannot consume
95
+ # this channel's ``LIMIT`` budget.
96
+ agent_id: str | None = None,
97
+ user_id: str | None = None,
93
98
  ) -> list[dict]:
94
99
  """Run an FTS5 query and return a list of ``{"id": ..., "_score": bm25}``.
95
100
 
@@ -105,6 +110,13 @@ def bm25_search(
105
110
  AND total length <3, FTS5 returns nothing; we then issue a
106
111
  ``LIKE '%<q>%'`` over the source table as a backstop. For our
107
112
  scale (<10k memories, <1k wiki) LIKE is well under 5 ms.
113
+
114
+ ``agent_id`` / ``user_id`` (audit 2026-11-15, loop-memory
115
+ 0.4.22) restrict the ``memories`` branch to one namespace. The
116
+ predicate is applied in SQL -- both on the FTS5 query and on the
117
+ ``LIKE`` backstop -- so the row budget is spent on the caller's
118
+ own memories. Rows with a NULL ``agent_id`` / ``user_id`` are
119
+ global and stay visible to everyone.
108
120
  """
109
121
  import re as _re
110
122
  fts_q = _escape_fts(query)
@@ -128,15 +140,19 @@ def bm25_search(
128
140
  # answer. Without this filter the BM25 channel
129
141
  # happily returned both versions of a fact, which
130
142
  # broke the new ``latest_only`` semantics.
143
+ scope_sql, scope_params = store._scope_clause(
144
+ agent_id, user_id, alias="m"
145
+ )
131
146
  sql = (
132
147
  f"SELECT m.id AS id, {score_col} AS s "
133
148
  f"FROM {table} t "
134
149
  f"JOIN memories m ON m.rowid = t.rowid "
135
150
  f"WHERE {table} MATCH ? "
136
151
  f" AND m.superseded_by IS NULL "
152
+ f" {scope_sql} "
137
153
  f"ORDER BY s LIMIT ?"
138
154
  )
139
- rows = c.execute(sql, (fts_q, limit)).fetchall()
155
+ rows = c.execute(sql, (fts_q, *scope_params, limit)).fetchall()
140
156
  else:
141
157
  # Wiki: also join back to wiki_pages so we can apply the
142
158
  # per-source scope filter at the SQL layer. We negate
@@ -158,7 +174,8 @@ def bm25_search(
158
174
  # lists are merged by id; LIKE rows get a synthetic importance
159
175
  # score so the RRF caller can rank them alongside BM25.
160
176
  like_rows = _like_fallback(store, query, kind=kind, limit=limit,
161
- source_filter=source_filter)
177
+ source_filter=source_filter,
178
+ agent_id=agent_id, user_id=user_id)
162
179
  merged: dict[str, dict] = {}
163
180
  for r in (rows or []):
164
181
  # ``_fts_score`` keeps the raw SQLite score for diagnostics.
@@ -250,6 +267,8 @@ def _like_fallback(
250
267
  kind: str,
251
268
  limit: int,
252
269
  source_filter: str | None = None,
270
+ agent_id: str | None = None,
271
+ user_id: str | None = None,
253
272
  ) -> list:
254
273
  """Brute-force LIKE fallback used when FTS5 trigram can't index
255
274
  a query -- short strings, all-CJK with <3 chars, or the FTS5
@@ -273,13 +292,20 @@ def _like_fallback(
273
292
  pat = f"%{q}%"
274
293
  with store._conn() as c:
275
294
  if kind == "memories":
295
+ scope_sql, scope_params = store._scope_clause(
296
+ agent_id, user_id, alias="m"
297
+ )
276
298
  sql = (
277
299
  "SELECT m.id AS id, m.importance AS importance "
278
300
  "FROM memories m WHERE LOWER(text) LIKE LOWER(?) "
279
301
  " AND m.superseded_by IS NULL "
302
+ f" {scope_sql} "
280
303
  "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
281
304
  )
282
- whole = [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()]
305
+ whole = [
306
+ dict(r)
307
+ for r in c.execute(sql, (pat, *scope_params, limit)).fetchall()
308
+ ]
283
309
  if whole:
284
310
  return whole
285
311
  # Per-token OR pass -- at least one query word must hit.
@@ -293,9 +319,15 @@ def _like_fallback(
293
319
  "SELECT m.id AS id, m.importance AS importance "
294
320
  f"FROM memories m WHERE ({placeholders}) "
295
321
  " AND m.superseded_by IS NULL "
322
+ f" {scope_sql} "
296
323
  "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
297
324
  )
298
- return [dict(r) for r in c.execute(sql_t, (*params, limit)).fetchall()]
325
+ return [
326
+ dict(r)
327
+ for r in c.execute(
328
+ sql_t, (*params, *scope_params, limit)
329
+ ).fetchall()
330
+ ]
299
331
  sql = (
300
332
  "SELECT w.id AS id, w.importance AS importance, w.scope AS scope "
301
333
  "FROM wiki_pages w "