diff --git a/README.md b/README.md index 6d7763c42..b376d0991 100644 --- a/README.md +++ b/README.md @@ -1742,6 +1742,7 @@ time. - `memory/history.jsonl` stores append-only summarized history - `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream +- `Dream` can also promote repeated workflows into reusable workspace skills under `skills/` - `Dream` runs on a schedule and can also be triggered manually - memory changes can be inspected and restored with built-in commands diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 8980a4baa..3f8b24314 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -595,10 +595,11 @@ class Dream: extra_allowed_dirs=extra_read, )) tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace)) - # write_file scoped to skills/ directory for skill creation + # write_file resolves relative paths from workspace root, but can only + # write under skills/ so the prompt can safely use skills//SKILL.md. skills_dir = workspace / "skills" skills_dir.mkdir(parents=True, exist_ok=True) - tools.register(WriteFileTool(workspace=skills_dir, allowed_dir=skills_dir)) + tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir)) return tools # -- skill listing -------------------------------------------------------- @@ -633,6 +634,8 @@ class Dream: async def run(self) -> bool: """Process unprocessed history entries. Returns True if work was done.""" + from nanobot.agent.skills import BUILTIN_SKILLS_DIR + last_cursor = self.store.get_last_dream_cursor() entries = self.store.read_unprocessed_history(since_cursor=last_cursor) if not entries: @@ -697,10 +700,15 @@ class Dream: phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}" tools = self._tools + skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md" messages: list[dict[str, Any]] = [ { "role": "system", - "content": render_template("agent/dream_phase2.md", strip=True), + "content": render_template( + "agent/dream_phase2.md", + strip=True, + skill_creator_path=str(skill_creator_path), + ), }, {"role": "user", "content": phase2_prompt}, ] diff --git a/nanobot/templates/agent/dream_phase2.md b/nanobot/templates/agent/dream_phase2.md index 450be7096..f833afb6a 100644 --- a/nanobot/templates/agent/dream_phase2.md +++ b/nanobot/templates/agent/dream_phase2.md @@ -21,7 +21,7 @@ Do NOT guess paths. ## Skill creation rules (for [SKILL] entries) - Use write_file to create skills//SKILL.md -- Before writing, read_file skills/skill-creator/SKILL.md for format reference (frontmatter structure, naming conventions, quality standards) +- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards) - **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow. - Include YAML frontmatter with name and description fields - Keep SKILL.md under 2000 words — concise and actionable diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 38faafa7d..eece79ed9 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock from nanobot.agent.memory import Dream, MemoryStore from nanobot.agent.runner import AgentRunResult +from nanobot.agent.skills import BUILTIN_SKILLS_DIR @pytest.fixture @@ -95,3 +96,30 @@ class TestDreamRun: entries = store.read_unprocessed_history(since_cursor=0) assert all(e["cursor"] > 0 for e in entries) + async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store): + """Dream should point skill creation guidance at the builtin skill-creator template.""" + store.append_history("Repeated workflow one") + store.append_history("Repeated workflow two") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + await dream.run() + + spec = mock_runner.run.call_args[0][0] + system_prompt = spec.initial_messages[0]["content"] + expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md") + assert expected in system_prompt + + async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store): + """Dream skill creation should allow skills//SKILL.md relative to workspace root.""" + write_tool = dream._tools.get("write_file") + assert write_tool is not None + + result = await write_tool.execute( + path="skills/test-skill/SKILL.md", + content="---\nname: test-skill\ndescription: Test\n---\n", + ) + + assert "Successfully wrote" in result + assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists() +