Add memory tools and resume entry points
- SHA
c6feb006cca80ccb9929314a8f67f88c8c3118f1- Parents
-
978ff51 - Tree
84cc7ba
c6feb00
c6feb006cca80ccb9929314a8f67f88c8c3118f1978ff51
84cc7ba| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/cli/main.py
|
30 | 8 |
| A |
src/loader/cli/options.py
|
25 | 0 |
| M |
src/loader/runtime/conversation.py
|
5 | 0 |
| M |
src/loader/runtime/hooks.py
|
29 | 0 |
| A |
src/loader/runtime/memory.py
|
232 | 0 |
| M |
src/loader/tools/base.py
|
18 | 0 |
| A |
src/loader/tools/memory_tools.py
|
300 | 0 |
| A |
tests/test_cli_resume.py
|
19 | 0 |
| A |
tests/test_memory_tools.py
|
127 | 0 |
src/loader/cli/main.pymodified@@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import asyncio |
| 4 | 4 | import re |
| 5 | +import sys | |
| 5 | 6 | |
| 6 | 7 | import click |
| 7 | 8 | import httpx |
@@ -12,6 +13,7 @@ from rich.prompt import Confirm, Prompt | ||
| 12 | 13 | from rich.table import Table |
| 13 | 14 | |
| 14 | 15 | from ..runtime.permissions import PermissionMode |
| 16 | +from .options import inject_resume_target | |
| 15 | 17 | from .rendering import ( |
| 16 | 18 | format_dod_status, |
| 17 | 19 | format_permission_mode, |
@@ -155,6 +157,7 @@ def clean_response(text: str) -> str: | ||
| 155 | 157 | @click.option("--no-context", is_flag=True, help="Skip auto-detecting project context") |
| 156 | 158 | @click.option("--plan", is_flag=True, help="Start the task in plan mode") |
| 157 | 159 | @click.option("--clarify", is_flag=True, help="Start the task in clarify mode") |
| 160 | +@click.option("--resume-target", hidden=True, default=None) | |
| 158 | 161 | @click.option("--no-recover", is_flag=True, help="Disable auto-recovery from tool errors") |
| 159 | 162 | @click.option("--no-tui", is_flag=True, help="Use simple Rich output instead of full TUI") |
| 160 | 163 | @click.option("--ctx", type=int, default=8192, help="Context window size (default: 8192, smaller = faster)") |
@@ -167,7 +170,7 @@ def clean_response(text: str) -> str: | ||
| 167 | 170 | @click.option("--verify", is_flag=True, help="Enable post-action verification (check results)") |
| 168 | 171 | @click.option("--reason", is_flag=True, help="Enable all reasoning stages (decompose + critique + confidence + verify)") |
| 169 | 172 | @click.argument("prompt", required=False) |
| 170 | -def main( | |
| 173 | +def cli( | |
| 171 | 174 | model: str | None, |
| 172 | 175 | select_model: bool, |
| 173 | 176 | backend: str, |
@@ -177,6 +180,7 @@ def main( | ||
| 177 | 180 | no_context: bool, |
| 178 | 181 | plan: bool, |
| 179 | 182 | clarify: bool, |
| 183 | + resume_target: str | None, | |
| 180 | 184 | no_recover: bool, |
| 181 | 185 | no_tui: bool, |
| 182 | 186 | ctx: int, |
@@ -191,11 +195,17 @@ def main( | ||
| 191 | 195 | ) -> None: |
| 192 | 196 | """Loader - Local AI coding assistant.""" |
| 193 | 197 | asyncio.run(_main( |
| 194 | - model, select_model, backend, yes, permission_mode, react, no_context, plan, clarify, no_recover, | |
| 198 | + model, select_model, backend, yes, permission_mode, react, no_context, plan, clarify, resume_target, no_recover, | |
| 195 | 199 | no_tui, ctx, gpu, timeout, decompose, critique, confidence, verify, reason, prompt |
| 196 | 200 | )) |
| 197 | 201 | |
| 198 | 202 | |
| 203 | +def main() -> None: | |
| 204 | + """Entry-point wrapper that supports `--resume [session-id]` syntax.""" | |
| 205 | + | |
| 206 | + cli.main(args=inject_resume_target(sys.argv[1:]), prog_name="loader") | |
| 207 | + | |
| 208 | + | |
| 199 | 209 | async def _main( |
| 200 | 210 | model: str | None, |
| 201 | 211 | select_model: bool, |
@@ -206,6 +216,7 @@ async def _main( | ||
| 206 | 216 | no_context: bool, |
| 207 | 217 | plan: bool, |
| 208 | 218 | clarify: bool, |
| 219 | + resume_target: str | None, | |
| 209 | 220 | no_recover: bool, |
| 210 | 221 | no_tui: bool, |
| 211 | 222 | ctx: int | None, |
@@ -289,6 +300,17 @@ async def _main( | ||
| 289 | 300 | reasoning=reasoning_config, |
| 290 | 301 | ) |
| 291 | 302 | agent = Agent(backend=llm, registry=registry, config=config) |
| 303 | + resumed = False | |
| 304 | + if resume_target is not None: | |
| 305 | + session_id = None if resume_target == "__latest__" else resume_target | |
| 306 | + resumed = agent.resume_session(session_id) | |
| 307 | + if not resumed and session_id is None: | |
| 308 | + console.print("[yellow]No previous session found; starting a new session.[/yellow]") | |
| 309 | + elif not resumed: | |
| 310 | + console.print(f"[red]Session not found:[/red] {session_id}") | |
| 311 | + return | |
| 312 | + else: | |
| 313 | + console.print(f"[dim]Resumed session: {agent.session.session_id}[/dim]") | |
| 292 | 314 | |
| 293 | 315 | # Show reasoning status if enabled |
| 294 | 316 | reasoning_active = [] |
@@ -310,8 +332,8 @@ async def _main( | ||
| 310 | 332 | status_parts = [ |
| 311 | 333 | f"Model: {model}", |
| 312 | 334 | f"Mode: {mode_str}", |
| 313 | - f"Workflow: {format_workflow_mode(config.workflow_mode_override or 'execute')}", | |
| 314 | - f"Permissions: {format_permission_mode(permission_mode)}", | |
| 335 | + f"Workflow: {format_workflow_mode(agent.workflow_mode)}", | |
| 336 | + f"Permissions: {format_permission_mode(agent.active_permission_mode)}", | |
| 315 | 337 | ] |
| 316 | 338 | if agent.project_context: |
| 317 | 339 | status_parts.append(f"Project: {agent.project_context.project_type}") |
@@ -333,8 +355,8 @@ async def _main( | ||
| 333 | 355 | status_parts = [ |
| 334 | 356 | f"Model: {model}", |
| 335 | 357 | f"Mode: {mode_str}", |
| 336 | - f"Workflow: {format_workflow_mode(config.workflow_mode_override or 'execute')}", | |
| 337 | - f"Permissions: {format_permission_mode(permission_mode)}", | |
| 358 | + f"Workflow: {format_workflow_mode(agent.workflow_mode)}", | |
| 359 | + f"Permissions: {format_permission_mode(agent.active_permission_mode)}", | |
| 338 | 360 | ] |
| 339 | 361 | if agent.project_context: |
| 340 | 362 | status_parts.append(f"Project: {agent.project_context.project_type}") |
@@ -356,8 +378,8 @@ async def _main( | ||
| 356 | 378 | agent=agent, |
| 357 | 379 | model_name=model, |
| 358 | 380 | mode=mode_str, |
| 359 | - workflow_mode=config.workflow_mode_override or "execute", | |
| 360 | - permission_mode=permission_mode, | |
| 381 | + workflow_mode=agent.workflow_mode, | |
| 382 | + permission_mode=agent.active_permission_mode, | |
| 361 | 383 | ) |
| 362 | 384 | await app.run_async() |
| 363 | 385 | |
src/loader/cli/options.pyadded@@ -0,0 +1,25 @@ | ||
| 1 | +"""Reusable CLI argument helpers for Loader.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | + | |
| 6 | +def inject_resume_target(argv: list[str]) -> list[str]: | |
| 7 | + """Rewrite `--resume [session-id]` into a hidden click option.""" | |
| 8 | + | |
| 9 | + rewritten: list[str] = [] | |
| 10 | + index = 0 | |
| 11 | + while index < len(argv): | |
| 12 | + arg = argv[index] | |
| 13 | + if arg != "--resume": | |
| 14 | + rewritten.append(arg) | |
| 15 | + index += 1 | |
| 16 | + continue | |
| 17 | + | |
| 18 | + next_arg = argv[index + 1] if index + 1 < len(argv) else None | |
| 19 | + if next_arg and not next_arg.startswith("-"): | |
| 20 | + rewritten.extend(["--resume-target", next_arg]) | |
| 21 | + index += 2 | |
| 22 | + else: | |
| 23 | + rewritten.extend(["--resume-target", "__latest__"]) | |
| 24 | + index += 1 | |
| 25 | + return rewritten | |
src/loader/runtime/conversation.pymodified@@ -31,6 +31,7 @@ from .dod import ( | ||
| 31 | 31 | from .events import AgentEvent, TurnSummary |
| 32 | 32 | from .executor import ToolExecutionState, ToolExecutor |
| 33 | 33 | from .hooks import build_default_tool_hooks |
| 34 | +from .memory import MemoryStore | |
| 34 | 35 | from .session import normalize_usage |
| 35 | 36 | from .tracing import RuntimeTracer |
| 36 | 37 | from .workflow import ( |
@@ -1443,6 +1444,10 @@ class ConversationRuntime: | ||
| 1443 | 1444 | iterations=summary.iterations, |
| 1444 | 1445 | ) |
| 1445 | 1446 | summary.session_id = self.agent.session.session_id |
| 1447 | + if summary.definition_of_done and summary.definition_of_done.status == "done": | |
| 1448 | + MemoryStore(self.agent.project_root).capture_definition_of_done( | |
| 1449 | + build_verification_summary(summary.definition_of_done.evidence) | |
| 1450 | + ) | |
| 1446 | 1451 | summary.trace = list(self.tracer.events) |
| 1447 | 1452 | return summary |
| 1448 | 1453 | |
src/loader/runtime/hooks.pymodified@@ -12,6 +12,7 @@ from ..agent.safeguards import ActionTracker, PreActionValidator | ||
| 12 | 12 | from ..llm.base import ToolCall |
| 13 | 13 | from ..tools.base import Tool, ToolRegistry |
| 14 | 14 | from ..tools.base import ToolResult as RegistryToolResult |
| 15 | +from .memory import MemoryStore | |
| 15 | 16 | from .permissions import PermissionOverride, PermissionPolicy |
| 16 | 17 | |
| 17 | 18 | |
@@ -258,6 +259,33 @@ class ActionHistoryHook(BaseToolHook): | ||
| 258 | 259 | return HookResult() |
| 259 | 260 | |
| 260 | 261 | |
| 262 | +class MemoryLifecycleHook(BaseToolHook): | |
| 263 | + """Mirror durable memory updates into the session notepad.""" | |
| 264 | + | |
| 265 | + async def post_tool_use(self, context: HookContext) -> HookResult: | |
| 266 | + if context.result is None or context.result.is_error: | |
| 267 | + return HookResult() | |
| 268 | + | |
| 269 | + store = MemoryStore(context.permission_policy.workspace_root) | |
| 270 | + if context.tool_call.name == "project_memory_add_note": | |
| 271 | + category = str(context.tool_call.arguments.get("category", "")).strip() | |
| 272 | + content = str(context.tool_call.arguments.get("content", "")).strip() | |
| 273 | + if category and content: | |
| 274 | + store.append_notepad_working( | |
| 275 | + f"Remembered note [{category}]: {content}" | |
| 276 | + ) | |
| 277 | + elif context.tool_call.name == "project_memory_add_directive": | |
| 278 | + directive = str(context.tool_call.arguments.get("directive", "")).strip() | |
| 279 | + priority = str( | |
| 280 | + context.tool_call.arguments.get("priority", "normal") | |
| 281 | + ).strip() | |
| 282 | + if directive: | |
| 283 | + store.append_notepad_working( | |
| 284 | + f"Remembered directive [{priority}]: {directive}" | |
| 285 | + ) | |
| 286 | + return HookResult() | |
| 287 | + | |
| 288 | + | |
| 261 | 289 | def build_default_tool_hooks( |
| 262 | 290 | *, |
| 263 | 291 | action_tracker: ActionTracker, |
@@ -273,5 +301,6 @@ def build_default_tool_hooks( | ||
| 273 | 301 | ActionValidationHook(validator), |
| 274 | 302 | RollbackTrackingHook(registry, rollback_plan), |
| 275 | 303 | ActionHistoryHook(action_tracker), |
| 304 | + MemoryLifecycleHook(), | |
| 276 | 305 | ] |
| 277 | 306 | ) |
src/loader/runtime/memory.pyadded@@ -0,0 +1,232 @@ | ||
| 1 | +"""Durable project memory and working-notepad storage under `.loader/`.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from dataclasses import dataclass | |
| 7 | +from datetime import UTC, datetime | |
| 8 | +from pathlib import Path | |
| 9 | +from typing import Any | |
| 10 | + | |
| 11 | + | |
| 12 | +def _utc_now() -> str: | |
| 13 | + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 14 | + | |
| 15 | + | |
| 16 | +@dataclass(slots=True) | |
| 17 | +class ProjectMemoryNote: | |
| 18 | + """Categorized project-memory note.""" | |
| 19 | + | |
| 20 | + category: str | |
| 21 | + content: str | |
| 22 | + timestamp: str | |
| 23 | + | |
| 24 | + def to_dict(self) -> dict[str, str]: | |
| 25 | + return { | |
| 26 | + "category": self.category, | |
| 27 | + "content": self.content, | |
| 28 | + "timestamp": self.timestamp, | |
| 29 | + } | |
| 30 | + | |
| 31 | + | |
| 32 | +@dataclass(slots=True) | |
| 33 | +class ProjectMemoryDirective: | |
| 34 | + """Persistent user or project directive.""" | |
| 35 | + | |
| 36 | + directive: str | |
| 37 | + priority: str | |
| 38 | + context: str | None | |
| 39 | + timestamp: str | |
| 40 | + | |
| 41 | + def to_dict(self) -> dict[str, str | None]: | |
| 42 | + return { | |
| 43 | + "directive": self.directive, | |
| 44 | + "priority": self.priority, | |
| 45 | + "context": self.context, | |
| 46 | + "timestamp": self.timestamp, | |
| 47 | + } | |
| 48 | + | |
| 49 | + | |
| 50 | +class MemoryStore: | |
| 51 | + """Manage project memory and the working notepad.""" | |
| 52 | + | |
| 53 | + def __init__(self, project_root: Path) -> None: | |
| 54 | + self.project_root = project_root | |
| 55 | + self.loader_root = project_root / ".loader" | |
| 56 | + self.project_memory_path = self.loader_root / "project-memory.json" | |
| 57 | + self.notepad_path = self.loader_root / "notepad.md" | |
| 58 | + | |
| 59 | + def ensure_layout(self) -> None: | |
| 60 | + """Ensure the `.loader/` directory exists.""" | |
| 61 | + | |
| 62 | + self.loader_root.mkdir(parents=True, exist_ok=True) | |
| 63 | + | |
| 64 | + def read_project_memory(self, section: str = "all") -> Any: | |
| 65 | + """Read the full project memory or one named section.""" | |
| 66 | + | |
| 67 | + memory = self._load_project_memory() | |
| 68 | + if section == "all": | |
| 69 | + return memory | |
| 70 | + return memory.get(section) | |
| 71 | + | |
| 72 | + def write_project_memory( | |
| 73 | + self, | |
| 74 | + memory: dict[str, Any], | |
| 75 | + *, | |
| 76 | + merge: bool = True, | |
| 77 | + ) -> dict[str, Any]: | |
| 78 | + """Write or merge project memory state.""" | |
| 79 | + | |
| 80 | + existing = self._load_project_memory() if merge else {} | |
| 81 | + merged = {**existing, **memory} | |
| 82 | + self._save_project_memory(merged) | |
| 83 | + return merged | |
| 84 | + | |
| 85 | + def add_project_note(self, category: str, content: str) -> dict[str, Any]: | |
| 86 | + """Append one categorized note to project memory.""" | |
| 87 | + | |
| 88 | + memory = self._load_project_memory() | |
| 89 | + notes = list(memory.get("notes", [])) | |
| 90 | + note = ProjectMemoryNote( | |
| 91 | + category=category.strip(), | |
| 92 | + content=content.strip(), | |
| 93 | + timestamp=_utc_now(), | |
| 94 | + ) | |
| 95 | + notes.append(note.to_dict()) | |
| 96 | + memory["notes"] = notes | |
| 97 | + self._save_project_memory(memory) | |
| 98 | + return note.to_dict() | |
| 99 | + | |
| 100 | + def add_project_directive( | |
| 101 | + self, | |
| 102 | + directive: str, | |
| 103 | + *, | |
| 104 | + priority: str = "normal", | |
| 105 | + context: str | None = None, | |
| 106 | + ) -> dict[str, Any]: | |
| 107 | + """Append one durable directive to project memory.""" | |
| 108 | + | |
| 109 | + memory = self._load_project_memory() | |
| 110 | + directives = list(memory.get("directives", [])) | |
| 111 | + directive_entry = ProjectMemoryDirective( | |
| 112 | + directive=directive.strip(), | |
| 113 | + priority=(priority or "normal").strip(), | |
| 114 | + context=context.strip() if context else None, | |
| 115 | + timestamp=_utc_now(), | |
| 116 | + ) | |
| 117 | + directives.append(directive_entry.to_dict()) | |
| 118 | + memory["directives"] = directives | |
| 119 | + self._save_project_memory(memory) | |
| 120 | + return directive_entry.to_dict() | |
| 121 | + | |
| 122 | + def read_notepad(self, section: str = "all") -> str: | |
| 123 | + """Read the full notepad or one named section.""" | |
| 124 | + | |
| 125 | + sections = self._load_notepad_sections() | |
| 126 | + if section == "all": | |
| 127 | + return self._render_notepad(sections) | |
| 128 | + return sections[section] | |
| 129 | + | |
| 130 | + def write_notepad_priority(self, content: str) -> str: | |
| 131 | + """Replace the priority context section.""" | |
| 132 | + | |
| 133 | + sections = self._load_notepad_sections() | |
| 134 | + sections["priority"] = content.strip() | |
| 135 | + self._save_notepad_sections(sections) | |
| 136 | + return sections["priority"] | |
| 137 | + | |
| 138 | + def append_notepad_working(self, content: str) -> str: | |
| 139 | + """Append one timestamped working-memory entry.""" | |
| 140 | + | |
| 141 | + sections = self._load_notepad_sections() | |
| 142 | + entry = f"- [{_utc_now()}] {content.strip()}" | |
| 143 | + sections["working"] = self._append_markdown_entry(sections["working"], entry) | |
| 144 | + self._save_notepad_sections(sections) | |
| 145 | + return entry | |
| 146 | + | |
| 147 | + def append_notepad_manual(self, content: str) -> str: | |
| 148 | + """Append one manual note that should not be auto-pruned.""" | |
| 149 | + | |
| 150 | + sections = self._load_notepad_sections() | |
| 151 | + entry = f"- {content.strip()}" | |
| 152 | + sections["manual"] = self._append_markdown_entry(sections["manual"], entry) | |
| 153 | + self._save_notepad_sections(sections) | |
| 154 | + return entry | |
| 155 | + | |
| 156 | + def capture_definition_of_done(self, evidence_summary: str) -> dict[str, Any] | None: | |
| 157 | + """Persist a useful evidence summary into project memory.""" | |
| 158 | + | |
| 159 | + normalized = evidence_summary.strip() | |
| 160 | + if not normalized or normalized == "Verification: skipped (no evidence required).": | |
| 161 | + return None | |
| 162 | + return self.add_project_note("definition_of_done", normalized) | |
| 163 | + | |
| 164 | + def _load_project_memory(self) -> dict[str, Any]: | |
| 165 | + self.ensure_layout() | |
| 166 | + if not self.project_memory_path.exists(): | |
| 167 | + return {} | |
| 168 | + try: | |
| 169 | + raw = json.loads(self.project_memory_path.read_text()) | |
| 170 | + except json.JSONDecodeError: | |
| 171 | + return {} | |
| 172 | + return raw if isinstance(raw, dict) else {} | |
| 173 | + | |
| 174 | + def _save_project_memory(self, memory: dict[str, Any]) -> None: | |
| 175 | + self.ensure_layout() | |
| 176 | + self.project_memory_path.write_text(json.dumps(memory, indent=2, sort_keys=True)) | |
| 177 | + | |
| 178 | + def _load_notepad_sections(self) -> dict[str, str]: | |
| 179 | + self.ensure_layout() | |
| 180 | + sections = { | |
| 181 | + "priority": "", | |
| 182 | + "working": "", | |
| 183 | + "manual": "", | |
| 184 | + } | |
| 185 | + if not self.notepad_path.exists(): | |
| 186 | + return sections | |
| 187 | + | |
| 188 | + current: str | None = None | |
| 189 | + for line in self.notepad_path.read_text().splitlines(): | |
| 190 | + if line == "## Priority Context": | |
| 191 | + current = "priority" | |
| 192 | + continue | |
| 193 | + if line == "## Working Memory": | |
| 194 | + current = "working" | |
| 195 | + continue | |
| 196 | + if line == "## Manual Notes": | |
| 197 | + current = "manual" | |
| 198 | + continue | |
| 199 | + if current is None or line == "# Loader Notepad": | |
| 200 | + continue | |
| 201 | + sections[current] = ( | |
| 202 | + f"{sections[current]}\n{line}".strip() | |
| 203 | + if sections[current] | |
| 204 | + else line | |
| 205 | + ) | |
| 206 | + return sections | |
| 207 | + | |
| 208 | + def _save_notepad_sections(self, sections: dict[str, str]) -> None: | |
| 209 | + self.ensure_layout() | |
| 210 | + self.notepad_path.write_text(self._render_notepad(sections)) | |
| 211 | + | |
| 212 | + @staticmethod | |
| 213 | + def _append_markdown_entry(existing: str, entry: str) -> str: | |
| 214 | + return f"{existing}\n{entry}".strip() if existing else entry | |
| 215 | + | |
| 216 | + @staticmethod | |
| 217 | + def _render_notepad(sections: dict[str, str]) -> str: | |
| 218 | + return "\n".join( | |
| 219 | + [ | |
| 220 | + "# Loader Notepad", | |
| 221 | + "", | |
| 222 | + "## Priority Context", | |
| 223 | + sections.get("priority", "").strip() or "_Empty_", | |
| 224 | + "", | |
| 225 | + "## Working Memory", | |
| 226 | + sections.get("working", "").strip() or "_Empty_", | |
| 227 | + "", | |
| 228 | + "## Manual Notes", | |
| 229 | + sections.get("manual", "").strip() or "_Empty_", | |
| 230 | + "", | |
| 231 | + ] | |
| 232 | + ) | |
src/loader/tools/base.pymodified@@ -179,6 +179,16 @@ def create_default_registry( | ||
| 179 | 179 | ) -> ToolRegistry: |
| 180 | 180 | """Create a registry with default tools.""" |
| 181 | 181 | from .file_tools import EditTool, GlobTool, ReadTool, WriteTool |
| 182 | + from .memory_tools import ( | |
| 183 | + NotepadReadTool, | |
| 184 | + NotepadWriteManualTool, | |
| 185 | + NotepadWritePriorityTool, | |
| 186 | + NotepadWriteWorkingTool, | |
| 187 | + ProjectMemoryAddDirectiveTool, | |
| 188 | + ProjectMemoryAddNoteTool, | |
| 189 | + ProjectMemoryReadTool, | |
| 190 | + ProjectMemoryWriteTool, | |
| 191 | + ) | |
| 182 | 192 | from .search_tools import GrepTool |
| 183 | 193 | from .shell_tools import BashTool |
| 184 | 194 | from .workflow_tools import AskUserQuestionTool, TodoWriteTool |
@@ -192,5 +202,13 @@ def create_default_registry( | ||
| 192 | 202 | registry.register(GrepTool()) |
| 193 | 203 | registry.register(TodoWriteTool()) |
| 194 | 204 | registry.register(AskUserQuestionTool()) |
| 205 | + registry.register(ProjectMemoryReadTool()) | |
| 206 | + registry.register(ProjectMemoryWriteTool()) | |
| 207 | + registry.register(ProjectMemoryAddNoteTool()) | |
| 208 | + registry.register(ProjectMemoryAddDirectiveTool()) | |
| 209 | + registry.register(NotepadReadTool()) | |
| 210 | + registry.register(NotepadWritePriorityTool()) | |
| 211 | + registry.register(NotepadWriteWorkingTool()) | |
| 212 | + registry.register(NotepadWriteManualTool()) | |
| 195 | 213 | |
| 196 | 214 | return registry |
src/loader/tools/memory_tools.pyadded@@ -0,0 +1,300 @@ | ||
| 1 | +"""Native Loader tools for project memory and working notes.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from pathlib import Path | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from ..runtime.memory import MemoryStore | |
| 10 | +from ..runtime.permissions import PermissionMode | |
| 11 | +from .base import Tool, ToolResult | |
| 12 | + | |
| 13 | + | |
| 14 | +class MemoryTool(Tool): | |
| 15 | + """Shared base class for `.loader/` memory tools.""" | |
| 16 | + | |
| 17 | + def __init__(self, workspace_root: Path | str | None = None) -> None: | |
| 18 | + self.workspace_root = ( | |
| 19 | + Path(workspace_root).expanduser().resolve() if workspace_root else None | |
| 20 | + ) | |
| 21 | + | |
| 22 | + def set_workspace_root(self, workspace_root: Path | None) -> None: | |
| 23 | + self.workspace_root = workspace_root | |
| 24 | + | |
| 25 | + def store(self) -> MemoryStore: | |
| 26 | + return MemoryStore(self.workspace_root or Path.cwd()) | |
| 27 | + | |
| 28 | + | |
| 29 | +class ProjectMemoryReadTool(MemoryTool): | |
| 30 | + """Read project memory.""" | |
| 31 | + | |
| 32 | + required_permission = PermissionMode.READ_ONLY | |
| 33 | + | |
| 34 | + @property | |
| 35 | + def name(self) -> str: | |
| 36 | + return "project_memory_read" | |
| 37 | + | |
| 38 | + @property | |
| 39 | + def description(self) -> str: | |
| 40 | + return "Read project memory from .loader/project-memory.json." | |
| 41 | + | |
| 42 | + @property | |
| 43 | + def parameters(self) -> dict[str, Any]: | |
| 44 | + return { | |
| 45 | + "type": "object", | |
| 46 | + "properties": { | |
| 47 | + "section": { | |
| 48 | + "type": "string", | |
| 49 | + "enum": [ | |
| 50 | + "all", | |
| 51 | + "techStack", | |
| 52 | + "build", | |
| 53 | + "conventions", | |
| 54 | + "structure", | |
| 55 | + "notes", | |
| 56 | + "directives", | |
| 57 | + ], | |
| 58 | + "description": "Optional project-memory section to read.", | |
| 59 | + } | |
| 60 | + }, | |
| 61 | + } | |
| 62 | + | |
| 63 | + async def execute(self, section: str = "all", **kwargs: Any) -> ToolResult: | |
| 64 | + payload = self.store().read_project_memory(section=section or "all") | |
| 65 | + return ToolResult( | |
| 66 | + output=json.dumps(payload, indent=2, sort_keys=True), | |
| 67 | + metadata={"section": section or "all", "payload": payload}, | |
| 68 | + ) | |
| 69 | + | |
| 70 | + | |
| 71 | +class ProjectMemoryWriteTool(MemoryTool): | |
| 72 | + """Write project memory.""" | |
| 73 | + | |
| 74 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 75 | + | |
| 76 | + @property | |
| 77 | + def name(self) -> str: | |
| 78 | + return "project_memory_write" | |
| 79 | + | |
| 80 | + @property | |
| 81 | + def description(self) -> str: | |
| 82 | + return "Write or merge project memory in .loader/project-memory.json." | |
| 83 | + | |
| 84 | + @property | |
| 85 | + def parameters(self) -> dict[str, Any]: | |
| 86 | + return { | |
| 87 | + "type": "object", | |
| 88 | + "properties": { | |
| 89 | + "memory": { | |
| 90 | + "type": "object", | |
| 91 | + "description": "Memory object to write.", | |
| 92 | + }, | |
| 93 | + "merge": { | |
| 94 | + "type": "boolean", | |
| 95 | + "description": "Merge with existing memory when true.", | |
| 96 | + }, | |
| 97 | + }, | |
| 98 | + "required": ["memory"], | |
| 99 | + } | |
| 100 | + | |
| 101 | + async def execute( | |
| 102 | + self, | |
| 103 | + memory: dict[str, Any], | |
| 104 | + merge: bool = True, | |
| 105 | + **kwargs: Any, | |
| 106 | + ) -> ToolResult: | |
| 107 | + payload = self.store().write_project_memory(memory, merge=merge) | |
| 108 | + return ToolResult( | |
| 109 | + output=json.dumps(payload, indent=2, sort_keys=True), | |
| 110 | + metadata={"memory": payload, "merge": merge}, | |
| 111 | + ) | |
| 112 | + | |
| 113 | + | |
| 114 | +class ProjectMemoryAddNoteTool(MemoryTool): | |
| 115 | + """Append a categorized note to project memory.""" | |
| 116 | + | |
| 117 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 118 | + | |
| 119 | + @property | |
| 120 | + def name(self) -> str: | |
| 121 | + return "project_memory_add_note" | |
| 122 | + | |
| 123 | + @property | |
| 124 | + def description(self) -> str: | |
| 125 | + return "Add a categorized note to project memory." | |
| 126 | + | |
| 127 | + @property | |
| 128 | + def parameters(self) -> dict[str, Any]: | |
| 129 | + return { | |
| 130 | + "type": "object", | |
| 131 | + "properties": { | |
| 132 | + "category": {"type": "string"}, | |
| 133 | + "content": {"type": "string"}, | |
| 134 | + }, | |
| 135 | + "required": ["category", "content"], | |
| 136 | + } | |
| 137 | + | |
| 138 | + async def execute(self, category: str, content: str, **kwargs: Any) -> ToolResult: | |
| 139 | + payload = self.store().add_project_note(category, content) | |
| 140 | + return ToolResult( | |
| 141 | + output=json.dumps(payload, indent=2, sort_keys=True), | |
| 142 | + metadata=payload, | |
| 143 | + ) | |
| 144 | + | |
| 145 | + | |
| 146 | +class ProjectMemoryAddDirectiveTool(MemoryTool): | |
| 147 | + """Append a persistent directive to project memory.""" | |
| 148 | + | |
| 149 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 150 | + | |
| 151 | + @property | |
| 152 | + def name(self) -> str: | |
| 153 | + return "project_memory_add_directive" | |
| 154 | + | |
| 155 | + @property | |
| 156 | + def description(self) -> str: | |
| 157 | + return "Add a durable directive to project memory." | |
| 158 | + | |
| 159 | + @property | |
| 160 | + def parameters(self) -> dict[str, Any]: | |
| 161 | + return { | |
| 162 | + "type": "object", | |
| 163 | + "properties": { | |
| 164 | + "directive": {"type": "string"}, | |
| 165 | + "priority": { | |
| 166 | + "type": "string", | |
| 167 | + "enum": ["high", "normal"], | |
| 168 | + }, | |
| 169 | + "context": {"type": "string"}, | |
| 170 | + }, | |
| 171 | + "required": ["directive"], | |
| 172 | + } | |
| 173 | + | |
| 174 | + async def execute( | |
| 175 | + self, | |
| 176 | + directive: str, | |
| 177 | + priority: str = "normal", | |
| 178 | + context: str | None = None, | |
| 179 | + **kwargs: Any, | |
| 180 | + ) -> ToolResult: | |
| 181 | + payload = self.store().add_project_directive( | |
| 182 | + directive, | |
| 183 | + priority=priority, | |
| 184 | + context=context, | |
| 185 | + ) | |
| 186 | + return ToolResult( | |
| 187 | + output=json.dumps(payload, indent=2, sort_keys=True), | |
| 188 | + metadata=payload, | |
| 189 | + ) | |
| 190 | + | |
| 191 | + | |
| 192 | +class NotepadReadTool(MemoryTool): | |
| 193 | + """Read Loader's durable working notepad.""" | |
| 194 | + | |
| 195 | + required_permission = PermissionMode.READ_ONLY | |
| 196 | + | |
| 197 | + @property | |
| 198 | + def name(self) -> str: | |
| 199 | + return "notepad_read" | |
| 200 | + | |
| 201 | + @property | |
| 202 | + def description(self) -> str: | |
| 203 | + return "Read the Loader notepad from .loader/notepad.md." | |
| 204 | + | |
| 205 | + @property | |
| 206 | + def parameters(self) -> dict[str, Any]: | |
| 207 | + return { | |
| 208 | + "type": "object", | |
| 209 | + "properties": { | |
| 210 | + "section": { | |
| 211 | + "type": "string", | |
| 212 | + "enum": ["all", "priority", "working", "manual"], | |
| 213 | + } | |
| 214 | + }, | |
| 215 | + } | |
| 216 | + | |
| 217 | + async def execute(self, section: str = "all", **kwargs: Any) -> ToolResult: | |
| 218 | + payload = self.store().read_notepad(section=section or "all") | |
| 219 | + return ToolResult( | |
| 220 | + output=payload, | |
| 221 | + metadata={"section": section or "all", "payload": payload}, | |
| 222 | + ) | |
| 223 | + | |
| 224 | + | |
| 225 | +class NotepadWritePriorityTool(MemoryTool): | |
| 226 | + """Replace the priority context section in the notepad.""" | |
| 227 | + | |
| 228 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 229 | + | |
| 230 | + @property | |
| 231 | + def name(self) -> str: | |
| 232 | + return "notepad_write_priority" | |
| 233 | + | |
| 234 | + @property | |
| 235 | + def description(self) -> str: | |
| 236 | + return "Replace the priority-context section in .loader/notepad.md." | |
| 237 | + | |
| 238 | + @property | |
| 239 | + def parameters(self) -> dict[str, Any]: | |
| 240 | + return { | |
| 241 | + "type": "object", | |
| 242 | + "properties": {"content": {"type": "string"}}, | |
| 243 | + "required": ["content"], | |
| 244 | + } | |
| 245 | + | |
| 246 | + async def execute(self, content: str, **kwargs: Any) -> ToolResult: | |
| 247 | + payload = self.store().write_notepad_priority(content) | |
| 248 | + return ToolResult(output=payload, metadata={"content": payload}) | |
| 249 | + | |
| 250 | + | |
| 251 | +class NotepadWriteWorkingTool(MemoryTool): | |
| 252 | + """Append one timestamped working-memory note.""" | |
| 253 | + | |
| 254 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 255 | + | |
| 256 | + @property | |
| 257 | + def name(self) -> str: | |
| 258 | + return "notepad_write_working" | |
| 259 | + | |
| 260 | + @property | |
| 261 | + def description(self) -> str: | |
| 262 | + return "Append a timestamped entry to the working-memory section." | |
| 263 | + | |
| 264 | + @property | |
| 265 | + def parameters(self) -> dict[str, Any]: | |
| 266 | + return { | |
| 267 | + "type": "object", | |
| 268 | + "properties": {"content": {"type": "string"}}, | |
| 269 | + "required": ["content"], | |
| 270 | + } | |
| 271 | + | |
| 272 | + async def execute(self, content: str, **kwargs: Any) -> ToolResult: | |
| 273 | + payload = self.store().append_notepad_working(content) | |
| 274 | + return ToolResult(output=payload, metadata={"content": payload}) | |
| 275 | + | |
| 276 | + | |
| 277 | +class NotepadWriteManualTool(MemoryTool): | |
| 278 | + """Append one manual note.""" | |
| 279 | + | |
| 280 | + required_permission = PermissionMode.WORKSPACE_WRITE | |
| 281 | + | |
| 282 | + @property | |
| 283 | + def name(self) -> str: | |
| 284 | + return "notepad_write_manual" | |
| 285 | + | |
| 286 | + @property | |
| 287 | + def description(self) -> str: | |
| 288 | + return "Append a manual note to .loader/notepad.md." | |
| 289 | + | |
| 290 | + @property | |
| 291 | + def parameters(self) -> dict[str, Any]: | |
| 292 | + return { | |
| 293 | + "type": "object", | |
| 294 | + "properties": {"content": {"type": "string"}}, | |
| 295 | + "required": ["content"], | |
| 296 | + } | |
| 297 | + | |
| 298 | + async def execute(self, content: str, **kwargs: Any) -> ToolResult: | |
| 299 | + payload = self.store().append_notepad_manual(content) | |
| 300 | + return ToolResult(output=payload, metadata={"content": payload}) | |
tests/test_cli_resume.pyadded@@ -0,0 +1,19 @@ | ||
| 1 | +"""Tests for CLI resume argument rewriting.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from loader.cli.options import inject_resume_target | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_inject_resume_target_supports_flag_and_named_session() -> None: | |
| 9 | + assert inject_resume_target([]) == [] | |
| 10 | + assert inject_resume_target(["--resume"]) == ["--resume-target", "__latest__"] | |
| 11 | + assert inject_resume_target(["--resume", "session-123"]) == [ | |
| 12 | + "--resume-target", | |
| 13 | + "session-123", | |
| 14 | + ] | |
| 15 | + assert inject_resume_target(["--resume", "session-123", "fix runtime"]) == [ | |
| 16 | + "--resume-target", | |
| 17 | + "session-123", | |
| 18 | + "fix runtime", | |
| 19 | + ] | |
tests/test_memory_tools.pyadded@@ -0,0 +1,127 @@ | ||
| 1 | +"""Tests for project memory and notepad tools.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +import pytest | |
| 9 | + | |
| 10 | +from loader.agent.loop import AgentConfig | |
| 11 | +from loader.llm.base import CompletionResponse, ToolCall | |
| 12 | +from loader.runtime.executor import ToolExecutionState, ToolExecutor | |
| 13 | +from loader.runtime.hooks import HookManager, MemoryLifecycleHook | |
| 14 | +from loader.runtime.permissions import PermissionMode, build_permission_policy | |
| 15 | +from loader.runtime.tracing import RuntimeTracer | |
| 16 | +from loader.tools.base import create_default_registry | |
| 17 | +from tests.helpers.runtime_harness import ScriptedBackend, run_scenario | |
| 18 | + | |
| 19 | + | |
| 20 | +@pytest.mark.asyncio | |
| 21 | +async def test_project_memory_tools_round_trip(temp_dir: Path) -> None: | |
| 22 | + registry = create_default_registry(temp_dir) | |
| 23 | + | |
| 24 | + write_result = await registry.execute( | |
| 25 | + "project_memory_write", | |
| 26 | + memory={"techStack": "Python", "build": "uv run pytest -q"}, | |
| 27 | + merge=True, | |
| 28 | + ) | |
| 29 | + read_result = await registry.execute("project_memory_read", section="all") | |
| 30 | + directive_result = await registry.execute( | |
| 31 | + "project_memory_add_directive", | |
| 32 | + directive="Use uv, never pip.", | |
| 33 | + priority="high", | |
| 34 | + ) | |
| 35 | + | |
| 36 | + assert not write_result.is_error | |
| 37 | + assert not read_result.is_error | |
| 38 | + assert not directive_result.is_error | |
| 39 | + memory = json.loads(read_result.output) | |
| 40 | + assert memory["techStack"] == "Python" | |
| 41 | + assert registry.get("project_memory_read").required_permission == PermissionMode.READ_ONLY | |
| 42 | + assert registry.get("project_memory_write").required_permission == PermissionMode.WORKSPACE_WRITE | |
| 43 | + | |
| 44 | + | |
| 45 | +@pytest.mark.asyncio | |
| 46 | +async def test_notepad_tools_round_trip(temp_dir: Path) -> None: | |
| 47 | + registry = create_default_registry(temp_dir) | |
| 48 | + | |
| 49 | + await registry.execute("notepad_write_priority", content="Keep Loader focused on runtime quality.") | |
| 50 | + await registry.execute("notepad_write_working", content="Audit session resume behavior.") | |
| 51 | + await registry.execute("notepad_write_manual", content="Never delete refs without asking.") | |
| 52 | + read_result = await registry.execute("notepad_read", section="all") | |
| 53 | + | |
| 54 | + assert not read_result.is_error | |
| 55 | + assert "Keep Loader focused on runtime quality." in read_result.output | |
| 56 | + assert "Audit session resume behavior." in read_result.output | |
| 57 | + assert "Never delete refs without asking." in read_result.output | |
| 58 | + | |
| 59 | + | |
| 60 | +@pytest.mark.asyncio | |
| 61 | +async def test_memory_lifecycle_hook_mirrors_directives_into_notepad(temp_dir: Path) -> None: | |
| 62 | + registry = create_default_registry(temp_dir) | |
| 63 | + policy = build_permission_policy( | |
| 64 | + active_mode=PermissionMode.WORKSPACE_WRITE, | |
| 65 | + workspace_root=temp_dir, | |
| 66 | + tool_requirements=registry.get_tool_requirements(), | |
| 67 | + ) | |
| 68 | + executor = ToolExecutor( | |
| 69 | + registry, | |
| 70 | + RuntimeTracer(), | |
| 71 | + policy, | |
| 72 | + hooks=HookManager([MemoryLifecycleHook()]), | |
| 73 | + ) | |
| 74 | + | |
| 75 | + outcome = await executor.execute_tool_call( | |
| 76 | + ToolCall( | |
| 77 | + id="directive-1", | |
| 78 | + name="project_memory_add_directive", | |
| 79 | + arguments={ | |
| 80 | + "directive": "Use uv, never pip.", | |
| 81 | + "priority": "high", | |
| 82 | + }, | |
| 83 | + ), | |
| 84 | + source="native", | |
| 85 | + skip_confirmation=True, | |
| 86 | + ) | |
| 87 | + | |
| 88 | + assert outcome.state == ToolExecutionState.EXECUTED | |
| 89 | + notepad = (temp_dir / ".loader" / "notepad.md").read_text() | |
| 90 | + assert "Remembered directive [high]: Use uv, never pip." in notepad | |
| 91 | + | |
| 92 | + | |
| 93 | +@pytest.mark.asyncio | |
| 94 | +async def test_definition_of_done_summary_is_captured_in_project_memory( | |
| 95 | + temp_dir: Path, | |
| 96 | +) -> None: | |
| 97 | + target = temp_dir / "memory-proof.txt" | |
| 98 | + backend = ScriptedBackend( | |
| 99 | + completions=[ | |
| 100 | + CompletionResponse( | |
| 101 | + content="I'll create the file.", | |
| 102 | + tool_calls=[ | |
| 103 | + ToolCall( | |
| 104 | + id="write-1", | |
| 105 | + name="write", | |
| 106 | + arguments={"file_path": str(target), "content": "memory proof\n"}, | |
| 107 | + ) | |
| 108 | + ], | |
| 109 | + ), | |
| 110 | + CompletionResponse(content="The file is in place."), | |
| 111 | + ] | |
| 112 | + ) | |
| 113 | + | |
| 114 | + await run_scenario( | |
| 115 | + "Create memory-proof.txt in the workspace root.", | |
| 116 | + backend, | |
| 117 | + config=AgentConfig(auto_context=False, stream=False), | |
| 118 | + project_root=temp_dir, | |
| 119 | + ) | |
| 120 | + | |
| 121 | + memory = json.loads((temp_dir / ".loader" / "project-memory.json").read_text()) | |
| 122 | + notes = memory.get("notes", []) | |
| 123 | + assert any( | |
| 124 | + note.get("category") == "definition_of_done" | |
| 125 | + and "Verification:" in note.get("content", "") | |
| 126 | + for note in notes | |
| 127 | + ) | |