aru-code 0.36.0__tar.gz → 0.37.0__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 (183) hide show
  1. {aru_code-0.36.0/aru_code.egg-info → aru_code-0.37.0}/PKG-INFO +1 -1
  2. aru_code-0.37.0/aru/__init__.py +1 -0
  3. {aru_code-0.36.0 → aru_code-0.37.0}/aru/config.py +14 -1
  4. {aru_code-0.36.0 → aru_code-0.37.0}/aru/memory/__init__.py +9 -1
  5. {aru_code-0.36.0 → aru_code-0.37.0}/aru/memory/store.py +40 -9
  6. {aru_code-0.36.0 → aru_code-0.37.0}/aru/runner.py +20 -0
  7. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/memory_tool.py +63 -5
  8. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/registry.py +2 -2
  9. {aru_code-0.36.0 → aru_code-0.37.0/aru_code.egg-info}/PKG-INFO +1 -1
  10. {aru_code-0.36.0 → aru_code-0.37.0}/aru_code.egg-info/SOURCES.txt +1 -0
  11. {aru_code-0.36.0 → aru_code-0.37.0}/pyproject.toml +1 -1
  12. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_memory.py +82 -1
  13. aru_code-0.37.0/tests/test_runner_interrupt.py +70 -0
  14. aru_code-0.36.0/aru/__init__.py +0 -1
  15. {aru_code-0.36.0 → aru_code-0.37.0}/LICENSE +0 -0
  16. {aru_code-0.36.0 → aru_code-0.37.0}/README.md +0 -0
  17. {aru_code-0.36.0 → aru_code-0.37.0}/aru/agent_factory.py +0 -0
  18. {aru_code-0.36.0 → aru_code-0.37.0}/aru/agents/__init__.py +0 -0
  19. {aru_code-0.36.0 → aru_code-0.37.0}/aru/agents/base.py +0 -0
  20. {aru_code-0.36.0 → aru_code-0.37.0}/aru/agents/catalog.py +0 -0
  21. {aru_code-0.36.0 → aru_code-0.37.0}/aru/agents/planner.py +0 -0
  22. {aru_code-0.36.0 → aru_code-0.37.0}/aru/cache_patch.py +0 -0
  23. {aru_code-0.36.0 → aru_code-0.37.0}/aru/checkpoints.py +0 -0
  24. {aru_code-0.36.0 → aru_code-0.37.0}/aru/cli.py +0 -0
  25. {aru_code-0.36.0 → aru_code-0.37.0}/aru/commands.py +0 -0
  26. {aru_code-0.36.0 → aru_code-0.37.0}/aru/completers.py +0 -0
  27. {aru_code-0.36.0 → aru_code-0.37.0}/aru/context.py +0 -0
  28. {aru_code-0.36.0 → aru_code-0.37.0}/aru/display.py +0 -0
  29. {aru_code-0.36.0 → aru_code-0.37.0}/aru/events.py +0 -0
  30. {aru_code-0.36.0 → aru_code-0.37.0}/aru/format/__init__.py +0 -0
  31. {aru_code-0.36.0 → aru_code-0.37.0}/aru/format/manager.py +0 -0
  32. {aru_code-0.36.0 → aru_code-0.37.0}/aru/format/runner.py +0 -0
  33. {aru_code-0.36.0 → aru_code-0.37.0}/aru/history_blocks.py +0 -0
  34. {aru_code-0.36.0 → aru_code-0.37.0}/aru/lsp/__init__.py +0 -0
  35. {aru_code-0.36.0 → aru_code-0.37.0}/aru/lsp/client.py +0 -0
  36. {aru_code-0.36.0 → aru_code-0.37.0}/aru/lsp/manager.py +0 -0
  37. {aru_code-0.36.0 → aru_code-0.37.0}/aru/lsp/protocol.py +0 -0
  38. {aru_code-0.36.0 → aru_code-0.37.0}/aru/memory/extractor.py +0 -0
  39. {aru_code-0.36.0 → aru_code-0.37.0}/aru/memory/loader.py +0 -0
  40. {aru_code-0.36.0 → aru_code-0.37.0}/aru/permissions.py +0 -0
  41. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugin_cache.py +0 -0
  42. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugins/__init__.py +0 -0
  43. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugins/custom_tools.py +0 -0
  44. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugins/hooks.py +0 -0
  45. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugins/manager.py +0 -0
  46. {aru_code-0.36.0 → aru_code-0.37.0}/aru/plugins/tool_api.py +0 -0
  47. {aru_code-0.36.0 → aru_code-0.37.0}/aru/providers.py +0 -0
  48. {aru_code-0.36.0 → aru_code-0.37.0}/aru/runtime.py +0 -0
  49. {aru_code-0.36.0 → aru_code-0.37.0}/aru/select.py +0 -0
  50. {aru_code-0.36.0 → aru_code-0.37.0}/aru/session.py +0 -0
  51. {aru_code-0.36.0 → aru_code-0.37.0}/aru/sinks.py +0 -0
  52. {aru_code-0.36.0 → aru_code-0.37.0}/aru/streaming.py +0 -0
  53. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tool_policy.py +0 -0
  54. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/__init__.py +0 -0
  55. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/_diff.py +0 -0
  56. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/_shared.py +0 -0
  57. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/apply_patch.py +0 -0
  58. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/apply_patch_prompt.txt +0 -0
  59. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/ast_tools.py +0 -0
  60. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/codebase.py +0 -0
  61. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/delegate.py +0 -0
  62. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/delegate_prompt.txt +0 -0
  63. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/file_ops.py +0 -0
  64. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/gitignore.py +0 -0
  65. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/lsp.py +0 -0
  66. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/mcp_client.py +0 -0
  67. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/plan_mode.py +0 -0
  68. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/ranker.py +0 -0
  69. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/search.py +0 -0
  70. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/shell.py +0 -0
  71. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/skill.py +0 -0
  72. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/tasklist.py +0 -0
  73. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/web.py +0 -0
  74. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tools/worktree.py +0 -0
  75. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/__init__.py +0 -0
  76. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/app.py +0 -0
  77. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/screens/__init__.py +0 -0
  78. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/screens/choice.py +0 -0
  79. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/screens/confirm.py +0 -0
  80. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/screens/search.py +0 -0
  81. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/screens/text_input.py +0 -0
  82. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/sinks.py +0 -0
  83. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/slash_bridge.py +0 -0
  84. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/ui.py +0 -0
  85. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/__init__.py +0 -0
  86. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/chat.py +0 -0
  87. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/completer.py +0 -0
  88. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/context_pane.py +0 -0
  89. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/header.py +0 -0
  90. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/inline_choice.py +0 -0
  91. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/loaded_pane.py +0 -0
  92. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/status.py +0 -0
  93. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/thinking.py +0 -0
  94. {aru_code-0.36.0 → aru_code-0.37.0}/aru/tui/widgets/tools.py +0 -0
  95. {aru_code-0.36.0 → aru_code-0.37.0}/aru/ui.py +0 -0
  96. {aru_code-0.36.0 → aru_code-0.37.0}/aru_code.egg-info/dependency_links.txt +0 -0
  97. {aru_code-0.36.0 → aru_code-0.37.0}/aru_code.egg-info/entry_points.txt +0 -0
  98. {aru_code-0.36.0 → aru_code-0.37.0}/aru_code.egg-info/requires.txt +0 -0
  99. {aru_code-0.36.0 → aru_code-0.37.0}/aru_code.egg-info/top_level.txt +0 -0
  100. {aru_code-0.36.0 → aru_code-0.37.0}/setup.cfg +0 -0
  101. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_agents_base.py +0 -0
  102. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_agents_md_coverage.py +0 -0
  103. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_apply_patch.py +0 -0
  104. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_async_tool_permission.py +0 -0
  105. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cache_patch_metrics.py +0 -0
  106. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cache_patch_stop_reason.py +0 -0
  107. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_catalog.py +0 -0
  108. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_chat_scrollable.py +0 -0
  109. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_checkpoints.py +0 -0
  110. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli.py +0 -0
  111. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_advanced.py +0 -0
  112. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_base.py +0 -0
  113. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_completers.py +0 -0
  114. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_new.py +0 -0
  115. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_run_cli.py +0 -0
  116. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_session.py +0 -0
  117. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cli_shell.py +0 -0
  118. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_codebase.py +0 -0
  119. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_confabulation_regression.py +0 -0
  120. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_config.py +0 -0
  121. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_context.py +0 -0
  122. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_context_pane.py +0 -0
  123. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_cwd_awareness.py +0 -0
  124. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_delegate.py +0 -0
  125. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_events_backward_compat.py +0 -0
  126. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_events_schema.py +0 -0
  127. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_fork_ctx_concurrency.py +0 -0
  128. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_format.py +0 -0
  129. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_gitignore.py +0 -0
  130. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_guardrails_scenarios.py +0 -0
  131. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_invoke_skill.py +0 -0
  132. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_invoked_skills.py +0 -0
  133. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_loaded_pane_path.py +0 -0
  134. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_lsp.py +0 -0
  135. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_lsp_rename.py +0 -0
  136. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_main.py +0 -0
  137. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_markdown_to_text.py +0 -0
  138. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_mcp_client.py +0 -0
  139. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_mcp_health.py +0 -0
  140. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_memory_tool.py +0 -0
  141. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_microcompact.py +0 -0
  142. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_permissions.py +0 -0
  143. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_plan_mode_refactor.py +0 -0
  144. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_plugin_cache.py +0 -0
  145. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_plugin_errors.py +0 -0
  146. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_plugin_hooks_v2.py +0 -0
  147. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_plugins.py +0 -0
  148. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_providers.py +0 -0
  149. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_ranker.py +0 -0
  150. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_reasoning.py +0 -0
  151. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_runner_recovery.py +0 -0
  152. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_runtime.py +0 -0
  153. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_select.py +0 -0
  154. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_skill_disallowed_tools.py +0 -0
  155. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_status_breakdown.py +0 -0
  156. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_status_cost.py +0 -0
  157. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_streaming_sink.py +0 -0
  158. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tasklist.py +0 -0
  159. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_thread_tool_timeout.py +0 -0
  160. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tool_policy.py +0 -0
  161. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_truncation_marker.py +0 -0
  162. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_app_boot.py +0 -0
  163. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_bindings.py +0 -0
  164. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_bus_flow.py +0 -0
  165. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_chat.py +0 -0
  166. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_completer.py +0 -0
  167. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_completer_dynamic.py +0 -0
  168. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_copy.py +0 -0
  169. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_input_behaviour.py +0 -0
  170. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_mention_expand.py +0 -0
  171. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_modals.py +0 -0
  172. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_mode_cycle.py +0 -0
  173. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_native_selection.py +0 -0
  174. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_permission_flow.py +0 -0
  175. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_plan_task_render.py +0 -0
  176. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_sidebar_toggle.py +0 -0
  177. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_slash_bridge.py +0 -0
  178. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_snapshot_smoke.py +0 -0
  179. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_thinking_and_boot.py +0 -0
  180. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_tui_widgets_visual.py +0 -0
  181. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_ui_adapter.py +0 -0
  182. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_worktree.py +0 -0
  183. {aru_code-0.36.0 → aru_code-0.37.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.36.0
3
+ Version: 0.37.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.37.0"
@@ -227,7 +227,20 @@ class AgentConfig:
227
227
  try:
228
228
  from aru.memory.loader import memory_section_for_prompt
229
229
  import os
230
- section = memory_section_for_prompt(os.getcwd())
230
+ # Prefer session.project_root (stable across subdir/worktree
231
+ # invocations) so memory is keyed to the project, not to
232
+ # whichever cwd `aru` happened to be launched from. Fall
233
+ # back to os.getcwd() during bootstrap or outside a ctx.
234
+ project_root = os.getcwd()
235
+ try:
236
+ from aru.runtime import get_ctx
237
+ _session = getattr(get_ctx(), "session", None)
238
+ _pr = getattr(_session, "project_root", None) if _session else None
239
+ if _pr:
240
+ project_root = _pr
241
+ except LookupError:
242
+ pass
243
+ section = memory_section_for_prompt(project_root)
231
244
  if section:
232
245
  parts.append(section.strip())
233
246
  except Exception: # pragma: no cover — memory module failure mustn't break prompts
@@ -10,11 +10,19 @@ Components:
10
10
 
11
11
  Storage layout:
12
12
 
13
- ~/.aru/projects/<sha256(project_root)[:12]>/memory/
13
+ ~/.aru/projects/<path-encoded>/memory/
14
14
  ├── MEMORY.md # one-line-per-memory index
15
15
  ├── feedback_*.md # one file per memory, YAML frontmatter + body
16
16
  └── user_*.md
17
17
 
18
+ ``<path-encoded>`` mirrors Claude Code's scheme: every non-alphanumeric
19
+ character in ``abspath(project_root)`` becomes a dash. Example::
20
+
21
+ D:\\OneDrive\\python_projects\\aru -> D--OneDrive-python-projects-aru
22
+
23
+ The directory is created lazily on the first ``write_memory`` call, so a
24
+ project that never writes a memory never leaves an empty folder behind.
25
+
18
26
  Config (aru.json):
19
27
 
20
28
  {
@@ -11,7 +11,6 @@ etc. until free.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import hashlib
15
14
  import os
16
15
  import re
17
16
  from dataclasses import dataclass
@@ -44,18 +43,48 @@ class MemoryEntry:
44
43
 
45
44
  # ── Paths ────────────────────────────────────────────────────────────
46
45
 
47
- def _project_hash(project_root: str) -> str:
48
- return hashlib.sha256(os.path.abspath(project_root).encode("utf-8")).hexdigest()[:12]
46
+ _PATH_ENCODE_RE = re.compile(r"[^A-Za-z0-9]")
49
47
 
50
48
 
51
- def memory_dir_for_project(project_root: str, base: str | None = None) -> Path:
52
- """Return (and create, if needed) the memory directory for *project_root*.
49
+ def _encode_project_path(project_root: str) -> str:
50
+ """Encode an absolute project path as a human-readable folder name.
53
51
 
54
- Defaults to ``~/.aru/projects/<hash>/memory``. Override ``base`` (test-only).
52
+ Matches Claude Code's scheme: every non-alphanumeric character (drive
53
+ colon, path separator, underscore, dot, dash, space, ...) becomes a
54
+ single dash, without collapsing runs. So ``D:\\`` → ``D--`` (colon
55
+ AND backslash each become a dash).
56
+
57
+ A quick ``ls ~/.aru/projects`` then shows which folder belongs to
58
+ which project without needing to decode a hash.
59
+
60
+ Examples::
61
+
62
+ D:\\OneDrive\\python_projects\\aru -> D--OneDrive-python-projects-aru
63
+ /home/u/proj -> -home-u-proj
64
+ """
65
+ return _PATH_ENCODE_RE.sub("-", os.path.abspath(project_root))
66
+
67
+
68
+ def memory_dir_for_project(
69
+ project_root: str,
70
+ base: str | None = None,
71
+ *,
72
+ create: bool = False,
73
+ ) -> Path:
74
+ """Return the memory directory path for *project_root*.
75
+
76
+ ``~/.aru/projects/<path-encoded>/memory`` by default. Override ``base``
77
+ for tests.
78
+
79
+ The directory is **not** created on disk unless ``create=True``. Read
80
+ paths (``load_memory_index``, ``list_memories``) pass the default so
81
+ a project that never writes a memory leaves no empty folder behind.
82
+ ``write_memory`` passes ``create=True``.
55
83
  """
56
84
  base_path = Path(base) if base else Path.home() / ".aru" / "projects"
57
- d = base_path / _project_hash(project_root) / "memory"
58
- d.mkdir(parents=True, exist_ok=True)
85
+ d = base_path / _encode_project_path(project_root) / "memory"
86
+ if create:
87
+ d.mkdir(parents=True, exist_ok=True)
59
88
  return d
60
89
 
61
90
 
@@ -153,7 +182,7 @@ def write_memory(project_root: str, entry: MemoryEntry,
153
182
  f"Invalid memory type {entry.type!r}; must be one of "
154
183
  f"{sorted(VALID_MEMORY_TYPES)}."
155
184
  )
156
- mem_dir = memory_dir_for_project(project_root, base=base)
185
+ mem_dir = memory_dir_for_project(project_root, base=base, create=True)
157
186
  slug = _unique_slug(mem_dir, _slugify(entry.name, entry.type))
158
187
  entry.slug = slug
159
188
  (mem_dir / entry.filename).write_text(_render_memory_file(entry), encoding="utf-8")
@@ -239,6 +268,8 @@ def search_memories(
239
268
  def delete_memory(project_root: str, slug: str, base: str | None = None) -> bool:
240
269
  """Delete the memory file + remove its index line. True if something was removed."""
241
270
  mem_dir = memory_dir_for_project(project_root, base=base)
271
+ if not mem_dir.exists():
272
+ return False
242
273
  path = mem_dir / f"{slug}.md"
243
274
  removed = path.exists()
244
275
  if removed:
@@ -670,6 +670,26 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
670
670
  except Exception:
671
671
  pass
672
672
  console.print("\n[yellow]Interrupted.[/yellow]")
673
+ # Python 3.11+: ``asyncio.run`` cancels the main task on SIGINT.
674
+ # Catching CancelledError here is not enough — the task stays in a
675
+ # "cancelling" state, so the caller's NEXT await (typically the
676
+ # REPL prompt in cli.py) re-raises CancelledError immediately,
677
+ # which looks to the user like "Ctrl+C exits aru". ``uncancel()``
678
+ # resets that counter so the turn ends cleanly and the REPL keeps
679
+ # running.
680
+ try:
681
+ current = asyncio.current_task()
682
+ if current is not None:
683
+ current.uncancel()
684
+ except Exception:
685
+ pass
686
+ # Mirror the TUI's reset at turn start — clear the shared abort
687
+ # flag so the next turn isn't short-circuited by leftover state.
688
+ try:
689
+ from aru.runtime import reset_abort
690
+ reset_abort()
691
+ except Exception:
692
+ pass
673
693
  except Exception as e:
674
694
  try:
675
695
  sink.exit()
@@ -1,17 +1,20 @@
1
- """Agent-facing memory query tool — Tier 3 #3.
1
+ """Agent-facing memory tools — Tier 3 #3.
2
2
 
3
- Exposes the per-project memory store (written by the Tier 2 auto-extractor)
4
- as a read-only tool. Two modes:
3
+ Exposes the per-project memory store as a pair of tools:
5
4
 
6
5
  - ``memory_search(slug="...")`` → returns the full body of one memory
7
6
  - ``memory_search(query="...")`` → keyword substring search over
8
7
  name / description / body, returns a
9
8
  ranked list with 200-char previews
10
9
  - ``memory_search()`` → summary stats by type
10
+ - ``memory_write(name, body, ...)`` → persist a new memory explicitly,
11
+ without waiting for the turn.end
12
+ extractor
11
13
 
12
14
  The system prompt already receives ``MEMORY.md`` as an index at startup
13
- (Tier 2 #4). This tool is the complement for *reading* a specific body
14
- or searching when the index alone isn't enough.
15
+ (Tier 2 #4). ``memory_search`` is the read complement; ``memory_write``
16
+ lets the agent honour direct user requests like "save X to memory" that
17
+ would otherwise fall through the extractor's threshold.
15
18
  """
16
19
 
17
20
  from __future__ import annotations
@@ -20,10 +23,12 @@ import os
20
23
  from collections import Counter
21
24
 
22
25
  from aru.memory.store import (
26
+ MemoryEntry,
23
27
  VALID_MEMORY_TYPES,
24
28
  list_memories,
25
29
  read_memory,
26
30
  search_memories,
31
+ write_memory,
27
32
  )
28
33
  from aru.runtime import get_ctx
29
34
 
@@ -106,3 +111,56 @@ def memory_search(query: str = "", slug: str = "") -> str:
106
111
  f"\n body: {preview}"
107
112
  )
108
113
  return "\n".join(lines)
114
+
115
+
116
+ def memory_write(name: str, body: str, type: str = "user",
117
+ description: str = "") -> str:
118
+ """Persist a durable memory for this project across future sessions.
119
+
120
+ Use when the user explicitly asks to save / remember / "lembra" / "salva"
121
+ something that should survive session boundaries. Pick the type carefully:
122
+
123
+ - ``user`` — user's persistent preferences or workflow rules
124
+ ("prefer pytest", "always type hints")
125
+ - ``feedback`` — corrections the user gave ("don't mock the DB, got burned")
126
+ - ``project`` — project-level state / decisions / deadlines / incidents
127
+ - ``reference`` — pointers to external systems (dashboards, tickets, docs)
128
+
129
+ Do NOT save:
130
+ - Code patterns or anything derivable from reading the repo
131
+ - Ephemeral conversation state
132
+ - Duplicates of what is already in the memory index
133
+
134
+ Args:
135
+ name: Short title (under 60 chars). Used as the memory's display name
136
+ and as the base for its filename slug.
137
+ body: The fact to remember (under 400 chars). Prefer a single
138
+ declarative sentence; future sessions see this verbatim.
139
+ type: One of ``user`` / ``feedback`` / ``project`` / ``reference``.
140
+ Defaults to ``user``.
141
+ description: Optional one-line summary for the MEMORY.md index
142
+ (under 100 chars). If empty, defaults to ``name``.
143
+ """
144
+ mtype = (type or "user").strip().lower()
145
+ if mtype not in VALID_MEMORY_TYPES:
146
+ return (
147
+ f"Invalid memory type {mtype!r}. Must be one of "
148
+ f"{sorted(VALID_MEMORY_TYPES)}."
149
+ )
150
+ name_clean = (name or "").strip()[:60]
151
+ body_clean = (body or "").strip()[:400]
152
+ desc_clean = (description or "").strip()[:100] or name_clean
153
+ if not name_clean or not body_clean:
154
+ return "memory_write requires both `name` and `body`."
155
+
156
+ entry = MemoryEntry(
157
+ name=name_clean,
158
+ description=desc_clean,
159
+ type=mtype,
160
+ body=body_clean,
161
+ )
162
+ persisted = write_memory(_project_root(), entry)
163
+ return (
164
+ f"Saved memory '{persisted.slug}' ({persisted.type}): "
165
+ f"{persisted.name}"
166
+ )
@@ -35,7 +35,7 @@ from aru.tools.lsp import (
35
35
  lsp_references,
36
36
  lsp_rename,
37
37
  )
38
- from aru.tools.memory_tool import memory_search
38
+ from aru.tools.memory_tool import memory_search, memory_write
39
39
  from aru.tools.web import web_fetch, web_search
40
40
  from aru.tools.worktree import worktree_info
41
41
 
@@ -82,7 +82,7 @@ _TASK_MGMT_TOOLS = [
82
82
  # clarity; excluded from subagent / planner / explorer sets.
83
83
  _SKILL_TOOLS = [invoke_skill]
84
84
 
85
- CORE_TOOLS = _READ_ONLY_TOOLS + _WRITE_TOOLS + [bash] + _NET_TOOLS + [delegate_task]
85
+ CORE_TOOLS = _READ_ONLY_TOOLS + _WRITE_TOOLS + [bash] + _NET_TOOLS + [delegate_task, memory_write]
86
86
 
87
87
  ALL_TOOLS = _TASK_MGMT_TOOLS + _SKILL_TOOLS + CORE_TOOLS
88
88
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.36.0
3
+ Version: 0.37.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -144,6 +144,7 @@ tests/test_plugins.py
144
144
  tests/test_providers.py
145
145
  tests/test_ranker.py
146
146
  tests/test_reasoning.py
147
+ tests/test_runner_interrupt.py
147
148
  tests/test_runner_recovery.py
148
149
  tests/test_runtime.py
149
150
  tests/test_select.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.36.0"
7
+ version = "0.37.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,6 +26,7 @@ from aru.memory.loader import (
26
26
  from aru.memory.store import (
27
27
  MAX_MEMORIES_PER_PROJECT,
28
28
  MemoryEntry,
29
+ _encode_project_path,
29
30
  clear_memory,
30
31
  delete_memory,
31
32
  list_memories,
@@ -150,7 +151,7 @@ def test_memory_section_includes_body_when_index_exists(project_root, memory_bas
150
151
 
151
152
 
152
153
  def test_load_memory_index_truncates_above_cap(project_root, memory_base):
153
- mem_dir = memory_dir_for_project(project_root, base=memory_base)
154
+ mem_dir = memory_dir_for_project(project_root, base=memory_base, create=True)
154
155
  # Seed a gigantic index file manually
155
156
  (mem_dir / "MEMORY.md").write_text(
156
157
  "# Memory Index\n\n" + "\n".join(f"- line {i}" for i in range(MAX_INDEX_LINES + 30)),
@@ -161,6 +162,42 @@ def test_load_memory_index_truncates_above_cap(project_root, memory_base):
161
162
  assert "truncated" in text
162
163
 
163
164
 
165
+ def test_memory_dir_is_lazy_by_default(project_root, memory_base):
166
+ """Default call must NOT create the dir — avoids phantom empty folders."""
167
+ mem_dir = memory_dir_for_project(project_root, base=memory_base)
168
+ assert not mem_dir.exists()
169
+
170
+
171
+ def test_memory_dir_creates_when_requested(project_root, memory_base):
172
+ mem_dir = memory_dir_for_project(project_root, base=memory_base, create=True)
173
+ assert mem_dir.exists()
174
+
175
+
176
+ def test_load_memory_index_does_not_create_dir(project_root, memory_base):
177
+ """Calling the loader on a clean project must leave the FS untouched."""
178
+ assert load_memory_index(project_root, base=memory_base) == ""
179
+ mem_dir = memory_dir_for_project(project_root, base=memory_base)
180
+ assert not mem_dir.exists()
181
+
182
+
183
+ def test_encode_project_path_matches_cc_scheme():
184
+ # Windows-style path: drive colon, backslashes, AND underscores all
185
+ # become dashes (matches Claude Code's scheme exactly).
186
+ assert _encode_project_path(
187
+ "D:\\OneDrive\\Documentos\\python_projects\\aru"
188
+ ).endswith("D--OneDrive-Documentos-python-projects-aru")
189
+ # Case is preserved; runs of separators are NOT collapsed.
190
+ encoded = _encode_project_path("D:\\Foo_Bar\\baz")
191
+ assert encoded.endswith("D--Foo-Bar-baz")
192
+
193
+
194
+ def test_delete_memory_missing_dir_returns_false(project_root, memory_base):
195
+ """Deleting from a project that never wrote any memory is a no-op."""
196
+ assert delete_memory(project_root, "user_whatever", base=memory_base) is False
197
+ mem_dir = memory_dir_for_project(project_root, base=memory_base)
198
+ assert not mem_dir.exists()
199
+
200
+
164
201
  # ── Extractor ────────────────────────────────────────────────────────
165
202
 
166
203
  def test_should_trigger_respects_auto_extract_flag():
@@ -213,3 +250,47 @@ def test_candidate_to_entry_builds_valid_entry():
213
250
  assert entry is not None
214
251
  assert entry.type == "user"
215
252
  assert entry.name == "Prefer typing"
253
+
254
+
255
+ # ── Agent-facing memory_write tool ────────────────────────────────────
256
+
257
+ def test_memory_write_tool_persists_and_is_queryable(project_root, memory_base, monkeypatch):
258
+ """memory_write is the agent's direct way to save a fact; memory_search must find it."""
259
+ import aru.tools.memory_tool as mt
260
+ # Pin the tool's internal resolver to our tmp project_root + base so the
261
+ # write lands in the test-scoped directory rather than ~/.aru.
262
+ monkeypatch.setattr(mt, "_project_root", lambda: project_root)
263
+ from aru.memory import store as _store
264
+ original_dir = _store.memory_dir_for_project
265
+
266
+ def _scoped_dir(pr, base=None, *, create=False):
267
+ return original_dir(pr, base=memory_base, create=create)
268
+
269
+ monkeypatch.setattr(_store, "memory_dir_for_project", _scoped_dir)
270
+
271
+ result = mt.memory_write(
272
+ name="Prefer pytest",
273
+ body="The project uses pytest exclusively; pick pytest idioms.",
274
+ type="user",
275
+ description="pytest, never unittest",
276
+ )
277
+ assert "Saved memory" in result
278
+ assert "user_prefer_pytest" in result
279
+
280
+ hits = mt.memory_search(query="pytest")
281
+ assert "Prefer pytest" in hits
282
+ assert "pytest idioms" in hits
283
+
284
+
285
+ def test_memory_write_tool_rejects_invalid_type(project_root, monkeypatch):
286
+ import aru.tools.memory_tool as mt
287
+ monkeypatch.setattr(mt, "_project_root", lambda: project_root)
288
+ out = mt.memory_write(name="X", body="b", type="bogus")
289
+ assert "Invalid memory type" in out
290
+
291
+
292
+ def test_memory_write_tool_requires_name_and_body(project_root, monkeypatch):
293
+ import aru.tools.memory_tool as mt
294
+ monkeypatch.setattr(mt, "_project_root", lambda: project_root)
295
+ assert "requires both" in mt.memory_write(name="", body="b")
296
+ assert "requires both" in mt.memory_write(name="X", body="")
@@ -0,0 +1,70 @@
1
+ """Regression tests for Ctrl+C handling in ``run_agent_capture``.
2
+
3
+ The bug: on Python 3.11+, ``asyncio.run`` cancels the main task when the
4
+ user hits Ctrl+C. ``run_agent_capture`` catches the resulting
5
+ ``CancelledError`` — but Python leaves the task in a "cancelling" state
6
+ until ``Task.uncancel()`` is called. As a result, the REPL's NEXT await
7
+ (the prompt) would raise ``CancelledError`` immediately, making Ctrl+C
8
+ during an agent turn look like it "exits aru" instead of just aborting
9
+ the turn.
10
+
11
+ These tests cover the narrow contract: after an interrupted turn, the
12
+ task must not be in a cancelling state and the abort signal must be
13
+ cleared.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+
20
+
21
+ async def _catch_and_recover_like_runner() -> None:
22
+ """Mirror the runner.py:666 except block in isolation.
23
+
24
+ We don't import ``run_agent_capture`` here because a full mock would
25
+ drag in agno + RichLiveSink + ctx — overkill for testing an invariant
26
+ on three lines. Keeping it isolated is the point: if this pattern
27
+ stays valid, the real runner keeps working too.
28
+ """
29
+ from aru.runtime import reset_abort
30
+ try:
31
+ await asyncio.sleep(10)
32
+ except (KeyboardInterrupt, asyncio.CancelledError):
33
+ current = asyncio.current_task()
34
+ if current is not None:
35
+ current.uncancel()
36
+ reset_abort()
37
+
38
+
39
+ async def test_repl_task_survives_mid_turn_cancellation():
40
+ """After Ctrl+C during a turn, the next await must NOT re-raise."""
41
+ loop = asyncio.get_running_loop()
42
+ task = asyncio.current_task()
43
+ assert task is not None
44
+ loop.call_later(0.01, task.cancel)
45
+ await _catch_and_recover_like_runner()
46
+ # If the fix works, this await completes without re-raising. This is
47
+ # the moral equivalent of the REPL reaching its next prompt.
48
+ await asyncio.sleep(0.01)
49
+ assert task.cancelling() == 0
50
+
51
+
52
+ async def test_abort_flag_is_cleared_after_interrupt():
53
+ """Leftover abort flag would silently short-circuit the next turn."""
54
+ from aru.runtime import abort_current, is_aborted, reset_abort
55
+
56
+ reset_abort()
57
+ assert is_aborted() is False
58
+
59
+ loop = asyncio.get_running_loop()
60
+ task = asyncio.current_task()
61
+ assert task is not None
62
+
63
+ # Set abort during the simulated turn, then cancel to trigger recovery.
64
+ def _trigger():
65
+ abort_current()
66
+ task.cancel()
67
+
68
+ loop.call_later(0.01, _trigger)
69
+ await _catch_and_recover_like_runner()
70
+ assert is_aborted() is False
@@ -1 +0,0 @@
1
- __version__ = "0.36.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes