agentu 2.2.0__tar.gz → 2.3.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 (159) hide show
  1. {agentu-2.2.0 → agentu-2.3.0}/CHANGELOG.md +14 -0
  2. {agentu-2.2.0 → agentu-2.3.0}/PKG-INFO +1 -1
  3. {agentu-2.2.0 → agentu-2.3.0}/pyproject.toml +1 -1
  4. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent.py +4 -0
  5. agentu-2.3.0/src/agentu/_core/agent_memory.py +519 -0
  6. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/memory.py +52 -8
  7. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workspace.py +16 -0
  8. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/PKG-INFO +1 -1
  9. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/SOURCES.txt +1 -0
  10. agentu-2.3.0/tests/test_memory_patterns.py +141 -0
  11. agentu-2.2.0/src/agentu/_core/agent_memory.py +0 -244
  12. {agentu-2.2.0 → agentu-2.3.0}/.agents/workflows/version-bump.md +0 -0
  13. {agentu-2.2.0 → agentu-2.3.0}/.github/workflows/python-package.yml +0 -0
  14. {agentu-2.2.0 → agentu-2.3.0}/.github/workflows/test.yml +0 -0
  15. {agentu-2.2.0 → agentu-2.3.0}/.gitignore +0 -0
  16. {agentu-2.2.0 → agentu-2.3.0}/LICENSE +0 -0
  17. {agentu-2.2.0 → agentu-2.3.0}/README.md +0 -0
  18. {agentu-2.2.0 → agentu-2.3.0}/docs/ENHANCEMENTS.md +0 -0
  19. {agentu-2.2.0 → agentu-2.3.0}/docs/index.html +0 -0
  20. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/basic.py +0 -0
  21. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/code_mode.py +0 -0
  22. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/memory.py +0 -0
  23. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/multimodal.py +0 -0
  24. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/simple_agent.py +0 -0
  25. {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/structured_output.py +0 -0
  26. {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf-processing/FORMS.md +0 -0
  27. {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf-processing/SKILL.md +0 -0
  28. {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf_utils.py +0 -0
  29. {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills_auto_demo.py +0 -0
  30. {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills_demo.py +0 -0
  31. {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/orchestrator.py +0 -0
  32. {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/ralph_demo.py +0 -0
  33. {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/workflow.py +0 -0
  34. {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/api.py +0 -0
  35. {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/example_mcp_sessions.py +0 -0
  36. {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/example_sessions.py +0 -0
  37. {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/serve_agent.py +0 -0
  38. {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/example_eval.py +0 -0
  39. {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/example_observe.py +0 -0
  40. {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/rationale_demo.py +0 -0
  41. {agentu-2.2.0 → agentu-2.3.0}/setup.cfg +0 -0
  42. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/__init__.py +0 -0
  43. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/__init__.py +0 -0
  44. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_context.py +0 -0
  45. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_hooks.py +0 -0
  46. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_sandbox.py +0 -0
  47. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_storage.py +0 -0
  48. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_workflow.py +0 -0
  49. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/config.py +0 -0
  50. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/context.py +0 -0
  51. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/hooks.py +0 -0
  52. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/multimodal.py +0 -0
  53. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/safety.py +0 -0
  54. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/structured.py +0 -0
  55. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/tools.py +0 -0
  56. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/utils.py +0 -0
  57. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/__init__.py +0 -0
  58. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/cache.py +0 -0
  59. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/embeddings.py +0 -0
  60. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/semantic.py +0 -0
  61. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/storage.py +0 -0
  62. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/sync.py +0 -0
  63. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/tiered.py +0 -0
  64. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/discovery.py +0 -0
  65. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/__init__.py +0 -0
  66. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/eval.py +0 -0
  67. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/trajectory.py +0 -0
  68. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/__init__.py +0 -0
  69. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/config.py +0 -0
  70. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/extensions.py +0 -0
  71. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/tool.py +0 -0
  72. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/transport.py +0 -0
  73. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/__init__.py +0 -0
  74. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/storage.py +0 -0
  75. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/__init__.py +0 -0
  76. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/guardrails.py +0 -0
  77. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/middleware.py +0 -0
  78. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/notify.py +0 -0
  79. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/observe.py +0 -0
  80. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/otel.py +0 -0
  81. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/__init__.py +0 -0
  82. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/checkpoint.py +0 -0
  83. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/cli.py +0 -0
  84. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/redis_backend.py +0 -0
  85. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/sandbox.py +0 -0
  86. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/serve.py +0 -0
  87. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/session.py +0 -0
  88. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/tasks.py +0 -0
  89. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/__init__.py +0 -0
  90. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/loader.py +0 -0
  91. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/search.py +0 -0
  92. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/skill.py +0 -0
  93. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/static/dashboard.html +0 -0
  94. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/storage/__init__.py +0 -0
  95. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/__init__.py +0 -0
  96. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/ralph.py +0 -0
  97. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/schedule.py +0 -0
  98. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/subagent.py +0 -0
  99. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/workflow.py +0 -0
  100. {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/worktree.py +0 -0
  101. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/dependency_links.txt +0 -0
  102. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/entry_points.txt +0 -0
  103. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/requires.txt +0 -0
  104. {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/top_level.txt +0 -0
  105. {agentu-2.2.0 → agentu-2.3.0}/tests/__init__.py +0 -0
  106. {agentu-2.2.0 → agentu-2.3.0}/tests/conftest.py +0 -0
  107. {agentu-2.2.0 → agentu-2.3.0}/tests/test_agent.py +0 -0
  108. {agentu-2.2.0 → agentu-2.3.0}/tests/test_agent_skills.py +0 -0
  109. {agentu-2.2.0 → agentu-2.3.0}/tests/test_agents_md.py +0 -0
  110. {agentu-2.2.0 → agentu-2.3.0}/tests/test_auto_skills.py +0 -0
  111. {agentu-2.2.0 → agentu-2.3.0}/tests/test_builders.py +0 -0
  112. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache.py +0 -0
  113. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_embeddings.py +0 -0
  114. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_integration.py +0 -0
  115. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_semantic.py +0 -0
  116. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_storage.py +0 -0
  117. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_sync.py +0 -0
  118. {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_tiered.py +0 -0
  119. {agentu-2.2.0 → agentu-2.3.0}/tests/test_checkpoint.py +0 -0
  120. {agentu-2.2.0 → agentu-2.3.0}/tests/test_codemode.py +0 -0
  121. {agentu-2.2.0 → agentu-2.3.0}/tests/test_config.py +0 -0
  122. {agentu-2.2.0 → agentu-2.3.0}/tests/test_context.py +0 -0
  123. {agentu-2.2.0 → agentu-2.3.0}/tests/test_eval.py +0 -0
  124. {agentu-2.2.0 → agentu-2.3.0}/tests/test_eval_reporting.py +0 -0
  125. {agentu-2.2.0 → agentu-2.3.0}/tests/test_github_skills.py +0 -0
  126. {agentu-2.2.0 → agentu-2.3.0}/tests/test_guardrails.py +0 -0
  127. {agentu-2.2.0 → agentu-2.3.0}/tests/test_harness.py +0 -0
  128. {agentu-2.2.0 → agentu-2.3.0}/tests/test_hooks.py +0 -0
  129. {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp.py +0 -0
  130. {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp_extensions.py +0 -0
  131. {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp_stdio.py +0 -0
  132. {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory.py +0 -0
  133. {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory_semantic.py +0 -0
  134. {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory_storage.py +0 -0
  135. {agentu-2.2.0 → agentu-2.3.0}/tests/test_middleware.py +0 -0
  136. {agentu-2.2.0 → agentu-2.3.0}/tests/test_middleware_notify.py +0 -0
  137. {agentu-2.2.0 → agentu-2.3.0}/tests/test_multi_turn.py +0 -0
  138. {agentu-2.2.0 → agentu-2.3.0}/tests/test_observe.py +0 -0
  139. {agentu-2.2.0 → agentu-2.3.0}/tests/test_otel.py +0 -0
  140. {agentu-2.2.0 → agentu-2.3.0}/tests/test_ralph.py +0 -0
  141. {agentu-2.2.0 → agentu-2.3.0}/tests/test_resume.py +0 -0
  142. {agentu-2.2.0 → agentu-2.3.0}/tests/test_safety.py +0 -0
  143. {agentu-2.2.0 → agentu-2.3.0}/tests/test_sandbox.py +0 -0
  144. {agentu-2.2.0 → agentu-2.3.0}/tests/test_schedule.py +0 -0
  145. {agentu-2.2.0 → agentu-2.3.0}/tests/test_search.py +0 -0
  146. {agentu-2.2.0 → agentu-2.3.0}/tests/test_serve.py +0 -0
  147. {agentu-2.2.0 → agentu-2.3.0}/tests/test_server_scale.py +0 -0
  148. {agentu-2.2.0 → agentu-2.3.0}/tests/test_session.py +0 -0
  149. {agentu-2.2.0 → agentu-2.3.0}/tests/test_skill.py +0 -0
  150. {agentu-2.2.0 → agentu-2.3.0}/tests/test_skill_md.py +0 -0
  151. {agentu-2.2.0 → agentu-2.3.0}/tests/test_storage.py +0 -0
  152. {agentu-2.2.0 → agentu-2.3.0}/tests/test_streaming.py +0 -0
  153. {agentu-2.2.0 → agentu-2.3.0}/tests/test_structured_output.py +0 -0
  154. {agentu-2.2.0 → agentu-2.3.0}/tests/test_subagent.py +0 -0
  155. {agentu-2.2.0 → agentu-2.3.0}/tests/test_tools.py +0 -0
  156. {agentu-2.2.0 → agentu-2.3.0}/tests/test_workflow.py +0 -0
  157. {agentu-2.2.0 → agentu-2.3.0}/tests/test_workspace.py +0 -0
  158. {agentu-2.2.0 → agentu-2.3.0}/tests/test_worktree.py +0 -0
  159. {agentu-2.2.0 → agentu-2.3.0}/uv.lock +0 -0
@@ -4,6 +4,20 @@ All notable changes to agentu will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ ## [2.3.0] - 2026-07-18
8
+
9
+ ### Added
10
+
11
+ - **Always-on memory patterns** (inspired by [Google's always-on-memory-agent](https://github.com/GoogleCloudPlatform/generative-ai/tree/main/gemini/agents/always-on-memory-agent)):
12
+ - **Structured memory extraction** — `MemoryEntry` now carries `summary`, `entities`, `topics`, `source`, and `consolidated` fields. The LLM auto-extracts these on every `remember()` call by default. No manual tagging needed.
13
+ - **Background consolidation** — `agent.with_consolidation(every=30)` runs a timer that reviews unconsolidated memories, finds cross-cutting patterns, and stores synthesized insights. Like the brain during sleep.
14
+ - **Inbox file watcher** — `agent.with_inbox("./inbox")` polls a directory for new files, processes them via the agent, and moves them to `.processed/`. Workspace-configurable via `inbox.watch` in `agent.yaml`.
15
+ - Auto-extraction skips `conversation`-type memories (chat turns don't need it)
16
+ - Disable with `Agent(..., auto_extract_memory=False)` if you want raw storage
17
+ - `consolidate_memories` tool injected automatically when consolidation is enabled
18
+ - Workspace support: `inbox.watch`, `inbox.poll_interval` in `agent.yaml`
19
+ - 14 new tests across 3 test classes
20
+
7
21
 
8
22
  ## [2.2.0] - 2026-07-16
9
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentu
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A harness-engineered AI agent runtime with tool isolation, self-correction, and permission scoping
5
5
  Author-email: Hemanth HM <hemanth.hm@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentu"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "A harness-engineered AI agent runtime with tool isolation, self-correction, and permission scoping"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -355,6 +355,10 @@ class Agent(MemoryMixin, SandboxMixin, HooksMixin, ContextMixin, WorkflowMixin,
355
355
  if config.permissions:
356
356
  agent.with_permissions(**config.permissions)
357
357
 
358
+ # Apply inbox
359
+ if config.inbox_path:
360
+ agent.with_inbox(config.inbox_path, config.inbox_poll_interval)
361
+
358
362
  return agent
359
363
 
360
364
  def _add_tool_internal(self, tool: Union[Tool, Callable], deferred: bool = False) -> Tool:
@@ -0,0 +1,519 @@
1
+ """MemoryMixin – memory-related methods extracted from Agent."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Dict, Any, Optional, List, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .agent import Agent
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class MemoryMixin:
14
+ """Mixin providing memory operations for the Agent class.
15
+
16
+ Methods here assume they are mixed into an Agent instance that has
17
+ ``memory_enabled``, ``memory``, and ``observer`` attributes.
18
+
19
+ When the agent has a ``_vector_backend`` configured (via
20
+ ``with_vectors()``), ``remember()`` and ``recall(semantic=True)``
21
+ will use it for embedding-based storage and retrieval.
22
+ """
23
+
24
+ def _get_vector_backend_sync(self):
25
+ """Resolve vector backend, lazily creating from DSN if needed.
26
+
27
+ Returns the backend or None if not configured.
28
+ LanceDBBackend.create() is synchronous, so this works in any
29
+ context (sync or async).
30
+ """
31
+ # Direct backend (set via with_vectors(backend_obj))
32
+ backend = getattr(self, '_vector_backend', None)
33
+ if backend is not None:
34
+ return backend
35
+ # DSN configured but not yet created (with_vectors("./path"))
36
+ dsn = getattr(self, '_vector_dsn', None)
37
+ if dsn is not None:
38
+ try:
39
+ from ..storage import LanceDBBackend
40
+ backend = LanceDBBackend.create(dsn)
41
+ # Cache so we don't re-create on every call
42
+ self._vector_backend = backend
43
+ return backend
44
+ except Exception:
45
+ logger.debug("Failed to lazily create vector backend", exc_info=True)
46
+ return None
47
+
48
+ def remember(self, content: str, memory_type: str = 'conversation',
49
+ metadata: Optional[Dict[str, Any]] = None, importance: float = 0.5,
50
+ store_long_term: bool = False, summary: Optional[str] = None,
51
+ entities: Optional[List[str]] = None, topics: Optional[List[str]] = None,
52
+ source: Optional[str] = None) -> None:
53
+ """Store information in memory.
54
+
55
+ The LLM automatically extracts summary, entities, topics, and
56
+ importance from the content. Just pass raw text — the LLM does
57
+ the rest. If you provide structured fields manually, the LLM
58
+ call is skipped.
59
+
60
+ Auto-extraction is skipped for ``memory_type='conversation'``
61
+ (chat turns don't need it) and can be disabled globally with
62
+ ``Agent(..., auto_extract_memory=False)``.
63
+
64
+ Args:
65
+ content: The content to remember
66
+ memory_type: Type of memory ('conversation', 'fact', 'task', 'observation')
67
+ metadata: Additional metadata
68
+ importance: Importance score (0.0 to 1.0)
69
+ store_long_term: If True, store directly in long-term memory
70
+ summary: Short summary (auto-extracted by default)
71
+ entities: Key entities (auto-extracted by default)
72
+ topics: Topic tags (auto-extracted by default)
73
+ source: Optional source identifier
74
+ """
75
+ if not self.memory_enabled:
76
+ logger.warning("Memory is not enabled for this agent")
77
+ return
78
+
79
+ # Auto-extract structured metadata via LLM when:
80
+ # 1. No structured fields were provided manually
81
+ # 2. Not a conversation turn (those are just chat history)
82
+ # 3. auto_extract_memory is not disabled (defaults to True)
83
+ auto_extract = getattr(self, 'auto_extract_memory', True)
84
+ needs_extraction = (
85
+ auto_extract
86
+ and not summary and not entities and not topics
87
+ and memory_type != 'conversation'
88
+ )
89
+ if needs_extraction:
90
+ try:
91
+ loop = asyncio.get_running_loop()
92
+ loop.create_task(self._remember_with_extraction(
93
+ content, memory_type, metadata, importance,
94
+ store_long_term, source,
95
+ ))
96
+ return # async path handles storage
97
+ except RuntimeError:
98
+ # No event loop — try sync extraction
99
+ try:
100
+ extracted = asyncio.run(self._extract_memory_metadata(content))
101
+ summary = extracted.get('summary', summary)
102
+ entities = extracted.get('entities', entities)
103
+ topics = extracted.get('topics', topics)
104
+ importance = extracted.get('importance', importance)
105
+ except Exception:
106
+ logger.debug("LLM extraction failed, storing without metadata", exc_info=True)
107
+
108
+ # Store in standard memory system
109
+ entry = self.memory.remember(
110
+ content=content,
111
+ memory_type=memory_type,
112
+ metadata=metadata,
113
+ importance=importance,
114
+ store_long_term=store_long_term,
115
+ summary=summary,
116
+ entities=entities,
117
+ topics=topics,
118
+ source=source
119
+ )
120
+
121
+ # Also store in vector backend for semantic search if available
122
+ vector_backend = self._get_vector_backend_sync()
123
+ if vector_backend is not None and (store_long_term or importance >= 0.7):
124
+ self._store_to_vector_backend(content, entry, memory_type, metadata)
125
+
126
+ async def _extract_memory_metadata(self, content: str) -> Dict[str, Any]:
127
+ """Use the LLM to extract structured metadata from content.
128
+
129
+ Returns a dict with ``summary``, ``entities``, ``topics``,
130
+ and ``importance``.
131
+ """
132
+ import json as _json
133
+
134
+ extraction_prompt = (
135
+ "Extract structured metadata from this text. "
136
+ "Return ONLY valid JSON with these fields:\n"
137
+ '{"summary": "1-2 sentence summary", '
138
+ '"entities": ["people", "orgs", "products", "concepts"], '
139
+ '"topics": ["2-4 topic tags"], '
140
+ '"importance": 0.0 to 1.0}\n\n'
141
+ f"Text:\n{content[:3000]}"
142
+ )
143
+
144
+ try:
145
+ raw = await self._call_llm(extraction_prompt)
146
+ # Parse JSON from the response — handle markdown fences
147
+ text = raw.strip()
148
+ if text.startswith('```'):
149
+ text = text.split('\n', 1)[1] if '\n' in text else text[3:]
150
+ text = text.rsplit('```', 1)[0]
151
+ result = _json.loads(text.strip())
152
+ return {
153
+ 'summary': result.get('summary'),
154
+ 'entities': result.get('entities', []),
155
+ 'topics': result.get('topics', []),
156
+ 'importance': float(result.get('importance', 0.5)),
157
+ }
158
+ except Exception:
159
+ logger.debug("Failed to parse LLM extraction response", exc_info=True)
160
+ return {}
161
+
162
+ async def _remember_with_extraction(
163
+ self, content: str, memory_type: str,
164
+ metadata: Optional[Dict[str, Any]], importance: float,
165
+ store_long_term: bool, source: Optional[str],
166
+ ) -> None:
167
+ """Async path: extract metadata then store."""
168
+ try:
169
+ extracted = await self._extract_memory_metadata(content)
170
+ except Exception:
171
+ logger.debug("LLM extraction failed, storing without metadata", exc_info=True)
172
+ extracted = {}
173
+
174
+ entry = self.memory.remember(
175
+ content=content,
176
+ memory_type=memory_type,
177
+ metadata=metadata,
178
+ importance=extracted.get('importance', importance),
179
+ store_long_term=store_long_term,
180
+ summary=extracted.get('summary'),
181
+ entities=extracted.get('entities'),
182
+ topics=extracted.get('topics'),
183
+ source=source,
184
+ )
185
+
186
+ vector_backend = self._get_vector_backend_sync()
187
+ if vector_backend is not None and (store_long_term or importance >= 0.7):
188
+ self._store_to_vector_backend(content, entry, memory_type, metadata)
189
+
190
+
191
+ def _store_to_vector_backend(self, content: str, entry: Any,
192
+ memory_type: str,
193
+ metadata: Optional[Dict[str, Any]]) -> None:
194
+ """Store a memory entry in the vector backend (fire-and-forget)."""
195
+ embedding_provider = getattr(self.memory, 'embedding_provider', None)
196
+ if embedding_provider is None:
197
+ logger.debug("No embedding provider for vector backend storage")
198
+ return
199
+
200
+ # Resolve backend once (already cached by _get_vector_backend_sync)
201
+ backend = self._get_vector_backend_sync()
202
+ if backend is None:
203
+ return
204
+
205
+ async def _store():
206
+ try:
207
+ embedding = await embedding_provider.embed(content)
208
+ meta = dict(metadata or {})
209
+ meta['memory_type'] = memory_type
210
+ meta['content'] = content
211
+ key = f"mem:{getattr(entry, 'id', id(entry))}"
212
+ await backend.store(key, embedding, meta)
213
+ except Exception:
214
+ logger.debug("Vector backend store failed", exc_info=True)
215
+
216
+ try:
217
+ loop = asyncio.get_running_loop()
218
+ loop.create_task(_store())
219
+ except RuntimeError:
220
+ asyncio.run(_store())
221
+
222
+ def recall(self, query: Optional[str] = None, memory_type: Optional[str] = None,
223
+ limit: int = 5, include_short_term: bool = True,
224
+ semantic: bool = False, semantic_threshold: float = 0.0) -> list:
225
+ """Recall memories.
226
+
227
+ Args:
228
+ query: Search query (if None, returns recent memories)
229
+ memory_type: Filter by memory type
230
+ limit: Maximum number of results
231
+ include_short_term: Whether to include short-term memories
232
+ semantic: If True, use embedding-based similarity search
233
+ instead of substring matching (requires embedding provider
234
+ or a vector backend configured via ``with_vectors()``)
235
+ semantic_threshold: Minimum similarity score for semantic recall
236
+
237
+ Returns:
238
+ List of MemoryEntry objects
239
+ """
240
+ if not self.memory_enabled:
241
+ logger.warning("Memory is not enabled for this agent")
242
+ return []
243
+
244
+ # Try vector backend for semantic recall if available and
245
+ # the in-memory semantic index isn't ready
246
+ vector_backend = self._get_vector_backend_sync()
247
+ if (semantic and query and vector_backend is not None
248
+ and not getattr(self.memory.long_term, '_semantic_ready', False)):
249
+ vector_results = self._recall_from_vector_backend(
250
+ query, limit, semantic_threshold
251
+ )
252
+ if vector_results:
253
+ return vector_results
254
+
255
+ return self.memory.recall(
256
+ query, memory_type, limit, include_short_term,
257
+ semantic=semantic, semantic_threshold=semantic_threshold,
258
+ )
259
+
260
+ def _recall_from_vector_backend(self, query: str, limit: int,
261
+ threshold: float) -> list:
262
+ """Search the vector backend for semantically similar memories."""
263
+ embedding_provider = getattr(self.memory, 'embedding_provider', None)
264
+ if embedding_provider is None:
265
+ return []
266
+
267
+ # Resolve backend once (already cached)
268
+ backend = self._get_vector_backend_sync()
269
+ if backend is None:
270
+ return []
271
+
272
+ async def _search():
273
+ query_embedding = await embedding_provider.embed(query)
274
+ results = await backend.search(
275
+ query_embedding, limit=limit
276
+ )
277
+ return results
278
+
279
+ try:
280
+ loop = asyncio.get_running_loop()
281
+ except RuntimeError:
282
+ loop = None
283
+
284
+ try:
285
+ if loop and loop.is_running():
286
+ import concurrent.futures
287
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
288
+ results = pool.submit(asyncio.run, _search()).result()
289
+ else:
290
+ results = asyncio.run(_search())
291
+ except Exception:
292
+ logger.debug("Vector backend search failed", exc_info=True)
293
+ return []
294
+
295
+ # Convert vector backend results to MemoryEntry-like objects
296
+ from ..memory.memory import MemoryEntry
297
+ entries = []
298
+ for key, score, meta in results:
299
+ if score < threshold:
300
+ continue
301
+ content = meta.get('content', '')
302
+ entry = MemoryEntry(
303
+ content=content,
304
+ memory_type=meta.get('memory_type', 'fact'),
305
+ metadata=meta,
306
+ importance=score,
307
+ )
308
+ entries.append(entry)
309
+ return entries
310
+
311
+ def get_memory_context(self, max_entries: int = 5) -> str:
312
+ """Get formatted context from memories.
313
+
314
+ Args:
315
+ max_entries: Maximum number of memory entries to include
316
+
317
+ Returns:
318
+ Formatted string with memory context
319
+ """
320
+ if not self.memory_enabled:
321
+ return ""
322
+
323
+ return self.memory.get_context(max_entries)
324
+
325
+ def consolidate_memory(self, importance_threshold: float = 0.6) -> None:
326
+ """Consolidate short-term memories to long-term storage.
327
+
328
+ Args:
329
+ importance_threshold: Minimum importance to consolidate
330
+ """
331
+ if not self.memory_enabled:
332
+ logger.warning("Memory is not enabled for this agent")
333
+ return
334
+
335
+ self.memory.consolidate_to_long_term(importance_threshold)
336
+
337
+ def clear_short_term_memory(self) -> None:
338
+ """Clear short-term memory."""
339
+ if not self.memory_enabled:
340
+ logger.warning("Memory is not enabled for this agent")
341
+ return
342
+
343
+ self.memory.clear_short_term()
344
+
345
+ def save_memory(self) -> None:
346
+ """Save memory to persistent storage."""
347
+ if not self.memory_enabled:
348
+ logger.warning("Memory is not enabled for this agent")
349
+ return
350
+
351
+ self.memory.save()
352
+
353
+ def get_memory_stats(self) -> Dict[str, Any]:
354
+ """Get memory statistics.
355
+
356
+ Returns:
357
+ Dictionary with memory stats
358
+ """
359
+ if not self.memory_enabled:
360
+ return {'memory_enabled': False}
361
+
362
+ stats = self.memory.stats()
363
+ stats['memory_enabled'] = True
364
+ return stats
365
+
366
+ def with_consolidation(self, every: int = 30) -> 'Agent':
367
+ """Enable background memory consolidation.
368
+
369
+ Runs on a timer, reviewing unconsolidated memories, finding
370
+ connections and patterns, and generating synthesized insights.
371
+ Like the human brain during sleep.
372
+
373
+ Args:
374
+ every: Minutes between consolidation runs (default: 30)
375
+
376
+ Returns:
377
+ Self for chaining.
378
+ """
379
+ consolidation_prompt = (
380
+ "Review all unconsolidated memories. For each group of related memories:\n"
381
+ "1. Find connections and patterns between them\n"
382
+ "2. Generate a synthesized insight\n"
383
+ "3. Use the consolidate_memories tool to store your findings\n"
384
+ "Focus on cross-cutting themes that connect different memories."
385
+ )
386
+ self.with_schedule(every=every, prompt=consolidation_prompt)
387
+
388
+ # Add the consolidation tool
389
+ from .tools import Tool
390
+
391
+ def consolidate_memories(
392
+ insight: str,
393
+ related_topics: list,
394
+ source_summaries: list,
395
+ ) -> dict:
396
+ """Store a consolidation insight from reviewing memories.
397
+
398
+ Args:
399
+ insight: The cross-cutting pattern or insight discovered.
400
+ related_topics: Topics that connect the consolidated memories.
401
+ source_summaries: Summaries of the memories that were consolidated.
402
+
403
+ Returns:
404
+ Confirmation of stored consolidation.
405
+ """
406
+ # Store the insight as a high-importance memory
407
+ self.remember(
408
+ content=insight,
409
+ memory_type='consolidation',
410
+ importance=0.9,
411
+ store_long_term=True,
412
+ summary=insight,
413
+ topics=related_topics,
414
+ source='consolidation',
415
+ )
416
+
417
+ # Mark source memories as consolidated
418
+ if self.memory:
419
+ for entry in self.memory.short_term.entries:
420
+ if not entry.consolidated and entry.summary in source_summaries:
421
+ entry.consolidated = True
422
+ for entry in self.memory.long_term.entries:
423
+ if not entry.consolidated and entry.summary in source_summaries:
424
+ entry.consolidated = True
425
+
426
+ return {
427
+ "status": "consolidated",
428
+ "insight": insight,
429
+ "topics": related_topics,
430
+ "sources_processed": len(source_summaries),
431
+ }
432
+
433
+ self._add_tool_internal(Tool(consolidate_memories))
434
+ return self
435
+
436
+ def with_inbox(self, inbox_path: str = './inbox', poll_interval: int = 5) -> 'Agent':
437
+ """Watch a directory for new files and ingest them as memories.
438
+
439
+ Dropped files are processed by the agent and stored as memories.
440
+ After processing, files are moved to a .processed/ subdirectory.
441
+
442
+ Args:
443
+ inbox_path: Directory to watch for new files.
444
+ poll_interval: Seconds between directory polls.
445
+
446
+ Returns:
447
+ Self for chaining.
448
+ """
449
+ import os
450
+ self._inbox_path = os.path.abspath(inbox_path)
451
+ self._inbox_poll_interval = poll_interval
452
+ self._inbox_processed = set() # Track processed files
453
+ return self
454
+
455
+ async def _poll_inbox(self) -> None:
456
+ """Poll the inbox directory for new files (called by the event loop)."""
457
+ import os
458
+ inbox = getattr(self, '_inbox_path', None)
459
+ if not inbox or not os.path.isdir(inbox):
460
+ return
461
+
462
+ processed_dir = os.path.join(inbox, '.processed')
463
+ os.makedirs(processed_dir, exist_ok=True)
464
+
465
+ processed = getattr(self, '_inbox_processed', set())
466
+
467
+ for filename in os.listdir(inbox):
468
+ filepath = os.path.join(inbox, filename)
469
+
470
+ # Skip directories, hidden files, already processed
471
+ if (os.path.isdir(filepath) or filename.startswith('.')
472
+ or filepath in processed):
473
+ continue
474
+
475
+ try:
476
+ # Read file content
477
+ with open(filepath, 'r', errors='replace') as f:
478
+ content = f.read()
479
+
480
+ if not content.strip():
481
+ continue
482
+
483
+ # Ingest via the agent
484
+ logger.info(f"Inbox: processing {filename}")
485
+ await self.infer(
486
+ f"Process and remember this information from file '{filename}':\n\n{content[:4000]}"
487
+ )
488
+
489
+ # Move to processed
490
+ import shutil
491
+ dest = os.path.join(processed_dir, filename)
492
+ shutil.move(filepath, dest)
493
+ processed.add(filepath)
494
+ logger.info(f"Inbox: processed {filename} → .processed/")
495
+
496
+ except Exception as e:
497
+ logger.error(f"Inbox: failed to process {filename}: {e}")
498
+ processed.add(filepath) # Don't retry on error
499
+
500
+ async def start_inbox(self) -> None:
501
+ """Start the inbox file watcher as a background task."""
502
+ import asyncio
503
+ interval = getattr(self, '_inbox_poll_interval', 5)
504
+
505
+ async def _watch_loop():
506
+ while True:
507
+ await self._poll_inbox()
508
+ await asyncio.sleep(interval)
509
+
510
+ self._inbox_task = asyncio.create_task(_watch_loop())
511
+ logger.info(f"Inbox watcher started: {self._inbox_path}")
512
+
513
+ def stop_inbox(self) -> None:
514
+ """Stop the inbox file watcher."""
515
+ task = getattr(self, '_inbox_task', None)
516
+ if task:
517
+ task.cancel()
518
+ self._inbox_task = None
519
+ logger.info("Inbox watcher stopped")